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    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    /// v1.4.93 P1-3 (BUG-5318-003): exchange_code (e.g. "CME"/"NYMEX"/"NYSE")
280    /// 派生自 daemon `derive_exch_type_with_fallback` (cache-first + mkt_id
281    /// fallback). 见 `futu_core::exch_type::exch_type_to_string` 完整映射表.
282    /// `null` (字段省略) 表示 daemon `exch_type=Unknown(0)` 且 mkt_id 未在表.
283    #[serde(skip_serializing_if = "Option::is_none")]
284    exchange_code: Option<String>,
285}
286
287pub async fn get_static(client: &Arc<FutuClient>, symbols: &[String]) -> Result<String> {
288    let secs: Result<Vec<_>> = symbols.iter().map(|s| parse_symbol(s)).collect();
289    let secs = secs?;
290
291    let infos = futu_qot::static_info::get_static_info(client, &secs).await?;
292    let out: Vec<StaticOut> = infos
293        .iter()
294        .map(|i| StaticOut {
295            symbol: format_symbol(&i.security),
296            id: i.id,
297            name: i.name.clone(),
298            sec_type: i.sec_type,
299            lot_size: i.lot_size,
300            list_time: i.list_time.clone(),
301            delisting: i.delisting,
302            // v1.4.93 P1-3: JSON 输出 unknown → 字段省略.
303            exchange_code: i.exchange_code().map(String::from),
304        })
305        .collect();
306    Ok(serde_json::to_string_pretty(&out)?)
307}
308
309// ===== Broker =====
310
311#[derive(Serialize)]
312struct BrokerOut {
313    side: &'static str,
314    pos: i32,
315    id: i64,
316    name: String,
317}
318
319pub async fn get_broker(client: &Arc<FutuClient>, symbol: &str) -> Result<String> {
320    let sec = parse_symbol(symbol)?;
321    futu_qot::sub::subscribe(
322        client,
323        std::slice::from_ref(&sec),
324        &[SubType::Broker],
325        true,
326        true,
327    )
328    .await?;
329    tokio::time::sleep(Duration::from_millis(300)).await;
330
331    let data = futu_qot::broker::get_broker(client, &sec).await?;
332    let mut out = Vec::new();
333    for a in &data.ask_list {
334        out.push(BrokerOut {
335            side: "ask",
336            pos: a.pos,
337            id: a.id,
338            name: a.name.clone(),
339        });
340    }
341    for b in &data.bid_list {
342        out.push(BrokerOut {
343            side: "bid",
344            pos: b.pos,
345            id: b.id,
346            name: b.name.clone(),
347        });
348    }
349    Ok(serde_json::to_string_pretty(&out)?)
350}