Skip to main content

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::{Result, bail};
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    odd_lot: bool,
138    bids: Vec<Level>,
139    asks: Vec<Level>,
140}
141
142pub async fn get_orderbook(
143    client: &Arc<FutuClient>,
144    symbol: &str,
145    depth: i32,
146    odd_lot: bool,
147) -> Result<String> {
148    let sec = parse_symbol(symbol)?;
149    let sub_type = if odd_lot {
150        SubType::OrderBookOdd
151    } else {
152        SubType::OrderBook
153    };
154    futu_qot::sub::subscribe(client, std::slice::from_ref(&sec), &[sub_type], true, true).await?;
155    tokio::time::sleep(Duration::from_millis(300)).await;
156
157    let ob =
158        futu_qot::order_book::get_order_book_with_type(client, &sec, depth, odd_lot.then_some(1))
159            .await?;
160    let out = OrderBookOut {
161        symbol: format_symbol(&ob.security),
162        odd_lot,
163        bids: ob
164            .bid_list
165            .iter()
166            .map(|e| Level {
167                price: e.price,
168                volume: e.volume,
169                orders: e.order_count,
170            })
171            .collect(),
172        asks: ob
173            .ask_list
174            .iter()
175            .map(|e| Level {
176                price: e.price,
177                volume: e.volume,
178                orders: e.order_count,
179            })
180            .collect(),
181    };
182    Ok(serde_json::to_string_pretty(&out)?)
183}
184
185// ===== Ticker =====
186
187#[derive(Serialize)]
188struct TickerOut {
189    time: String,
190    sequence: i64,
191    price: f64,
192    volume: i64,
193    turnover: f64,
194    dir: i32,
195    ticker_type: Option<i32>,
196    timestamp: f64,
197}
198
199pub async fn get_ticker(client: &Arc<FutuClient>, symbol: &str, count: i32) -> Result<String> {
200    let sec = parse_symbol(symbol)?;
201    futu_qot::sub::subscribe(
202        client,
203        std::slice::from_ref(&sec),
204        &[SubType::Ticker],
205        true,
206        true,
207    )
208    .await?;
209    tokio::time::sleep(Duration::from_millis(300)).await;
210
211    let result = futu_qot::ticker::get_ticker(client, &sec, count).await?;
212    let out: Vec<TickerOut> = result
213        .ticker_list
214        .iter()
215        .map(|t| TickerOut {
216            time: t.time.clone(),
217            sequence: t.sequence,
218            price: t.price,
219            volume: t.volume,
220            turnover: t.turnover,
221            dir: t.dir,
222            ticker_type: t.ticker_type,
223            timestamp: t.timestamp,
224        })
225        .collect();
226    Ok(serde_json::to_string_pretty(&out)?)
227}
228
229// ===== RT =====
230
231#[derive(Serialize)]
232struct RtOut {
233    time: String,
234    minute: i32,
235    is_blank: bool,
236    price: f64,
237    last_close_price: f64,
238    avg_price: f64,
239    volume: i64,
240    turnover: f64,
241    timestamp: f64,
242}
243
244pub async fn get_rt(client: &Arc<FutuClient>, symbol: &str) -> Result<String> {
245    let sec = parse_symbol(symbol)?;
246    futu_qot::sub::subscribe(
247        client,
248        std::slice::from_ref(&sec),
249        &[SubType::RT],
250        true,
251        true,
252    )
253    .await?;
254    tokio::time::sleep(Duration::from_millis(300)).await;
255
256    let result = futu_qot::rt::get_rt(client, &sec).await?;
257    let out: Vec<RtOut> = result
258        .rt_list
259        .iter()
260        .map(|r| RtOut {
261            time: r.time.clone(),
262            minute: r.minute,
263            is_blank: r.is_blank,
264            price: r.price,
265            last_close_price: r.last_close_price,
266            avg_price: r.avg_price,
267            volume: r.volume,
268            turnover: r.turnover,
269            timestamp: r.timestamp,
270        })
271        .collect();
272    Ok(serde_json::to_string_pretty(&out)?)
273}
274
275// ===== Static =====
276
277#[derive(Serialize)]
278struct StaticOut {
279    symbol: String,
280    id: i64,
281    name: String,
282    sec_type: i32,
283    lot_size: i32,
284    list_time: String,
285    delisting: bool,
286    /// v1.4.93 P1-3 (BUG-5318-003): exchange_code (e.g. "CME"/"NYMEX"/"NYSE")
287    /// 派生自 daemon `derive_exch_type_with_fallback` (cache-first + mkt_id
288    /// fallback). 见 `futu_core::exch_type::exch_type_to_string` 完整映射表.
289    /// `null` (字段省略) 表示 daemon `exch_type=Unknown(0)` 且 mkt_id 未在表.
290    #[serde(skip_serializing_if = "Option::is_none")]
291    exchange_code: Option<String>,
292}
293
294pub async fn get_static(client: &Arc<FutuClient>, symbols: &[String]) -> Result<String> {
295    let secs: Result<Vec<_>> = symbols.iter().map(|s| parse_symbol(s)).collect();
296    let secs = secs?;
297
298    let infos = futu_qot::static_info::get_static_info(client, &secs).await?;
299    let out: Vec<StaticOut> = infos
300        .iter()
301        .map(|i| StaticOut {
302            symbol: format_symbol(&i.security),
303            id: i.id,
304            name: i.name.clone(),
305            sec_type: i.sec_type,
306            lot_size: i.lot_size,
307            list_time: i.list_time.clone(),
308            delisting: i.delisting,
309            // v1.4.93 P1-3: JSON 输出 unknown → 字段省略.
310            exchange_code: i.exchange_code().map(String::from),
311        })
312        .collect();
313    Ok(serde_json::to_string_pretty(&out)?)
314}
315
316// ===== Broker =====
317
318#[derive(Serialize)]
319struct BrokerOut {
320    side: &'static str,
321    pos: i32,
322    id: i64,
323    name: String,
324}
325
326pub async fn get_broker(client: &Arc<FutuClient>, symbol: &str) -> Result<String> {
327    let sec = parse_symbol(symbol)?;
328    futu_qot::sub::subscribe(
329        client,
330        std::slice::from_ref(&sec),
331        &[SubType::Broker],
332        true,
333        true,
334    )
335    .await?;
336    tokio::time::sleep(Duration::from_millis(300)).await;
337
338    let data = futu_qot::broker::get_broker(client, &sec).await?;
339    let mut out = Vec::new();
340    for a in &data.ask_list {
341        out.push(BrokerOut {
342            side: "ask",
343            pos: a.pos,
344            id: a.id,
345            name: a.name.clone(),
346        });
347    }
348    for b in &data.bid_list {
349        out.push(BrokerOut {
350            side: "bid",
351            pos: b.pos,
352            id: b.id,
353            name: b.name.clone(),
354        });
355    }
356    Ok(serde_json::to_string_pretty(&out)?)
357}