futu_mcp/handlers/
market.rs

1//! 行情 handler:kline / orderbook / ticker / rt / static / broker
2
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::{bail, Result};
7use futu_net::client::FutuClient;
8use futu_qot::types::{KLType, RehabType, SubType};
9use serde::Serialize;
10
11use crate::state::{format_symbol, parse_symbol};
12
13// ===== K 线 =====
14
15#[derive(Serialize)]
16struct KLineOut {
17    time: String,
18    timestamp: f64,
19    open: f64,
20    high: f64,
21    low: f64,
22    close: f64,
23    last_close: f64,
24    change_rate: f64,
25    volume: i64,
26    turnover: f64,
27    turnover_rate: f64,
28    pe: f64,
29}
30
31pub fn parse_kl_type(s: &str) -> Result<KLType> {
32    let t = match s.trim().to_ascii_lowercase().as_str() {
33        "day" => KLType::Day,
34        "week" => KLType::Week,
35        "month" => KLType::Month,
36        "quarter" => KLType::Quarter,
37        "year" => KLType::Year,
38        "1min" => KLType::Min1,
39        "3min" => KLType::Min3,
40        "5min" => KLType::Min5,
41        "15min" => KLType::Min15,
42        "30min" => KLType::Min30,
43        "60min" => KLType::Min60,
44        other => bail!("unknown kline type {other:?} (day|week|month|quarter|year|1min|...)"),
45    };
46    Ok(t)
47}
48
49fn estimate_lookback_days(kl_type: KLType, count: i32) -> i32 {
50    let n = count.max(1);
51    match kl_type {
52        KLType::Day => (n as f32 * 1.5).ceil() as i32 + 10,
53        KLType::Week => n * 8 + 30,
54        KLType::Month => n * 32 + 90,
55        KLType::Quarter => n * 95 + 180,
56        KLType::Year => n * 370 + 365,
57        KLType::Min1 => (n as f32 / 240.0).ceil() as i32 + 3,
58        KLType::Min3 => (n as f32 / 80.0).ceil() as i32 + 3,
59        KLType::Min5 => (n as f32 / 48.0).ceil() as i32 + 3,
60        KLType::Min15 => (n as f32 / 16.0).ceil() as i32 + 3,
61        KLType::Min30 => (n as f32 / 8.0).ceil() as i32 + 5,
62        KLType::Min60 => (n as f32 / 4.0).ceil() as i32 + 7,
63        _ => 365,
64    }
65}
66
67pub async fn get_kline(
68    client: &Arc<FutuClient>,
69    symbol: &str,
70    kl_type_str: &str,
71    count: Option<i32>,
72    begin: Option<&str>,
73    end: Option<&str>,
74) -> Result<String> {
75    let sec = parse_symbol(symbol)?;
76    let kl_type = parse_kl_type(kl_type_str)?;
77
78    let today = chrono::Local::now().date_naive();
79    let end_date = match end {
80        Some(s) => chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")?,
81        None => today,
82    };
83    let n = count.unwrap_or(100);
84    let lookback = estimate_lookback_days(kl_type, n);
85    let begin_date = match begin {
86        Some(s) => chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d")?,
87        None => end_date
88            .checked_sub_days(chrono::Days::new(lookback as u64))
89            .unwrap_or(end_date),
90    };
91
92    let result = futu_qot::history_kl::get_history_kl(
93        client,
94        &sec,
95        RehabType::None,
96        kl_type,
97        &begin_date.format("%Y-%m-%d").to_string(),
98        &end_date.format("%Y-%m-%d").to_string(),
99        Some(n),
100    )
101    .await?;
102
103    let out: Vec<KLineOut> = result
104        .kl_list
105        .iter()
106        .map(|k| KLineOut {
107            time: k.time.clone(),
108            timestamp: k.timestamp,
109            open: k.open_price,
110            high: k.high_price,
111            low: k.low_price,
112            close: k.close_price,
113            last_close: k.last_close_price,
114            change_rate: k.change_rate,
115            volume: k.volume,
116            turnover: k.turnover,
117            turnover_rate: k.turnover_rate,
118            pe: k.pe,
119        })
120        .collect();
121
122    Ok(serde_json::to_string_pretty(&out)?)
123}
124
125// ===== OrderBook =====
126
127#[derive(Serialize)]
128struct Level {
129    price: f64,
130    volume: i64,
131    orders: i32,
132}
133
134#[derive(Serialize)]
135struct OrderBookOut {
136    symbol: String,
137    bids: Vec<Level>,
138    asks: Vec<Level>,
139}
140
141pub async fn get_orderbook(client: &Arc<FutuClient>, symbol: &str, depth: i32) -> Result<String> {
142    let sec = parse_symbol(symbol)?;
143    futu_qot::sub::subscribe(
144        client,
145        std::slice::from_ref(&sec),
146        &[SubType::OrderBook],
147        true,
148        true,
149    )
150    .await?;
151    tokio::time::sleep(Duration::from_millis(300)).await;
152
153    let ob = futu_qot::order_book::get_order_book(client, &sec, depth).await?;
154    let out = OrderBookOut {
155        symbol: format_symbol(&ob.security),
156        bids: ob
157            .bid_list
158            .iter()
159            .map(|e| Level {
160                price: e.price,
161                volume: e.volume,
162                orders: e.order_count,
163            })
164            .collect(),
165        asks: ob
166            .ask_list
167            .iter()
168            .map(|e| Level {
169                price: e.price,
170                volume: e.volume,
171                orders: e.order_count,
172            })
173            .collect(),
174    };
175    Ok(serde_json::to_string_pretty(&out)?)
176}
177
178// ===== Ticker =====
179
180#[derive(Serialize)]
181struct TickerOut {
182    time: String,
183    sequence: i64,
184    price: f64,
185    volume: i64,
186    turnover: f64,
187    dir: i32,
188    ticker_type: Option<i32>,
189    timestamp: f64,
190}
191
192pub async fn get_ticker(client: &Arc<FutuClient>, symbol: &str, count: i32) -> Result<String> {
193    let sec = parse_symbol(symbol)?;
194    futu_qot::sub::subscribe(
195        client,
196        std::slice::from_ref(&sec),
197        &[SubType::Ticker],
198        true,
199        true,
200    )
201    .await?;
202    tokio::time::sleep(Duration::from_millis(300)).await;
203
204    let result = futu_qot::ticker::get_ticker(client, &sec, count).await?;
205    let out: Vec<TickerOut> = result
206        .ticker_list
207        .iter()
208        .map(|t| TickerOut {
209            time: t.time.clone(),
210            sequence: t.sequence,
211            price: t.price,
212            volume: t.volume,
213            turnover: t.turnover,
214            dir: t.dir,
215            ticker_type: t.ticker_type,
216            timestamp: t.timestamp,
217        })
218        .collect();
219    Ok(serde_json::to_string_pretty(&out)?)
220}
221
222// ===== RT =====
223
224#[derive(Serialize)]
225struct RtOut {
226    time: String,
227    minute: i32,
228    is_blank: bool,
229    price: f64,
230    last_close_price: f64,
231    avg_price: f64,
232    volume: i64,
233    turnover: f64,
234    timestamp: f64,
235}
236
237pub async fn get_rt(client: &Arc<FutuClient>, symbol: &str) -> Result<String> {
238    let sec = parse_symbol(symbol)?;
239    futu_qot::sub::subscribe(
240        client,
241        std::slice::from_ref(&sec),
242        &[SubType::RT],
243        true,
244        true,
245    )
246    .await?;
247    tokio::time::sleep(Duration::from_millis(300)).await;
248
249    let result = futu_qot::rt::get_rt(client, &sec).await?;
250    let out: Vec<RtOut> = result
251        .rt_list
252        .iter()
253        .map(|r| RtOut {
254            time: r.time.clone(),
255            minute: r.minute,
256            is_blank: r.is_blank,
257            price: r.price,
258            last_close_price: r.last_close_price,
259            avg_price: r.avg_price,
260            volume: r.volume,
261            turnover: r.turnover,
262            timestamp: r.timestamp,
263        })
264        .collect();
265    Ok(serde_json::to_string_pretty(&out)?)
266}
267
268// ===== Static =====
269
270#[derive(Serialize)]
271struct StaticOut {
272    symbol: String,
273    id: i64,
274    name: String,
275    sec_type: i32,
276    lot_size: i32,
277    list_time: String,
278    delisting: bool,
279}
280
281pub async fn get_static(client: &Arc<FutuClient>, symbols: &[String]) -> Result<String> {
282    let secs: Result<Vec<_>> = symbols.iter().map(|s| parse_symbol(s)).collect();
283    let secs = secs?;
284
285    let infos = futu_qot::static_info::get_static_info(client, &secs).await?;
286    let out: Vec<StaticOut> = infos
287        .iter()
288        .map(|i| StaticOut {
289            symbol: format_symbol(&i.security),
290            id: i.id,
291            name: i.name.clone(),
292            sec_type: i.sec_type,
293            lot_size: i.lot_size,
294            list_time: i.list_time.clone(),
295            delisting: i.delisting,
296        })
297        .collect();
298    Ok(serde_json::to_string_pretty(&out)?)
299}
300
301// ===== Broker =====
302
303#[derive(Serialize)]
304struct BrokerOut {
305    side: &'static str,
306    pos: i32,
307    id: i64,
308    name: String,
309}
310
311pub async fn get_broker(client: &Arc<FutuClient>, symbol: &str) -> Result<String> {
312    let sec = parse_symbol(symbol)?;
313    futu_qot::sub::subscribe(
314        client,
315        std::slice::from_ref(&sec),
316        &[SubType::Broker],
317        true,
318        true,
319    )
320    .await?;
321    tokio::time::sleep(Duration::from_millis(300)).await;
322
323    let data = futu_qot::broker::get_broker(client, &sec).await?;
324    let mut out = Vec::new();
325    for a in &data.ask_list {
326        out.push(BrokerOut {
327            side: "ask",
328            pos: a.pos,
329            id: a.id,
330            name: a.name.clone(),
331        });
332    }
333    for b in &data.bid_list {
334        out.push(BrokerOut {
335            side: "bid",
336            pos: b.pos,
337            id: b.id,
338            name: b.name.clone(),
339        });
340    }
341    Ok(serde_json::to_string_pretty(&out)?)
342}