Skip to main content

futu_mcp/handlers/
analysis.rs

1//! 行情分析 / 关联参考域 handler。
2//!
3//! v1.4.25 3 个简单实用的:capital_flow / capital_distribution / market_state
4//! v1.4.26 补 4 个核心参考类:history_kline / owner_plate / reference / option_chain
5//! 后续参考域工具已拆到 `handlers/reference/*` 与对应 `tools/reference*`
6//! 模块;本文件保留早期 capital / kline / owner-plate / option-chain 等入口。
7//!
8//! 命名映射到 Futu 官方 Python SDK(py-futu-api):
9//! - `get_capital_flow` → `OpenQuoteContext.get_capital_flow`
10//! - `get_capital_distribution` → `OpenQuoteContext.get_capital_distribution`
11//! - `get_market_state` → `OpenQuoteContext.get_market_state`
12//! - `get_history_kline` → `OpenQuoteContext.request_history_kline`
13//! - `get_owner_plate` → `OpenQuoteContext.get_owner_plate`
14//! - `get_reference` → `OpenQuoteContext.get_referencestock_list`
15//! - `get_option_chain` → `OpenQuoteContext.get_option_chain`
16
17use std::sync::Arc;
18
19use anyhow::{Result, anyhow, bail};
20use futu_core::qot_subscription;
21use futu_net::client::FutuClient;
22use futu_qot::types::{KLType, RehabType};
23use futu_surface_spec::input::{parse_rehab_type_id, validate_history_session_id};
24use prost::Message;
25use serde::Serialize;
26
27use crate::qot_sdk_adapter;
28use crate::state::parse_symbol;
29
30fn market_prefix(m: i32) -> &'static str {
31    futu_core::market::qot_market_display_prefix(futu_core::market::QotMarketId::new(m))
32        .unwrap_or("UNK")
33}
34
35#[cfg(test)]
36mod tests;
37
38// ============================================================
39// get_capital_flow / `Qot_GetCapitalFlow` (CMD 3211)
40// ============================================================
41
42pub async fn get_capital_flow(
43    client: &Arc<FutuClient>,
44    symbol: &str,
45    period_type: Option<i32>,
46    begin_time: Option<String>,
47    end_time: Option<String>,
48) -> Result<String> {
49    let sec = parse_symbol(symbol)?;
50    let req = futu_proto::qot_get_capital_flow::Request {
51        c2s: futu_proto::qot_get_capital_flow::C2s {
52            security: futu_proto::qot_common::Security {
53                market: sec.market as i32,
54                code: sec.code,
55            },
56            period_type,
57            begin_time,
58            end_time,
59            header: None, // v1.4.110 codex Slice 1 schema 占位
60        },
61    };
62    let body = req.encode_to_vec();
63    let frame = client
64        .request(futu_core::proto_id::QOT_GET_CAPITAL_FLOW, body)
65        .await?;
66    let resp = futu_proto::qot_get_capital_flow::Response::decode(frame.body.as_ref())
67        .map_err(|e| anyhow!("decode capital_flow: {e}"))?;
68    if resp.ret_type != 0 {
69        bail!(
70            "capital_flow ret_type={} msg={:?}",
71            resp.ret_type,
72            resp.ret_msg
73        );
74    }
75    let s2c = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
76    // v1.4.98 T1-7: expose CapitalFlowItem 全 9 字段 (之前只读 in_flow +
77    // timestamp, 漏 main / super / big / mid / sml in_flow + time string).
78    // 主力 / 特大 / 大 / 中 / 小单细分 = LLM agent smart-money 信号常用.
79    // 来源: proto/Qot_GetCapitalFlow.proto:17-26 已 generated, 仅 expose 层加.
80    let raw_json = serde_json::to_string_pretty(&serde_json::json!({
81        "flow_item_list": s2c.flow_item_list.iter().map(|f| {
82            serde_json::json!({
83                "in_flow": f.in_flow,
84                "time": f.time,
85                "timestamp": f.timestamp,
86                "main_in_flow": f.main_in_flow,
87                "super_in_flow": f.super_in_flow,
88                "big_in_flow": f.big_in_flow,
89                "mid_in_flow": f.mid_in_flow,
90                "sml_in_flow": f.sml_in_flow,
91            })
92        }).collect::<Vec<_>>(),
93        "last_valid_time": s2c.last_valid_time,
94        "last_valid_timestamp": s2c.last_valid_timestamp,
95        "symbol": symbol,
96    }))?;
97    Ok(raw_json)
98}
99
100// ============================================================
101// get_capital_distribution / `Qot_GetCapitalDistribution` (CMD 3212)
102// ============================================================
103
104#[derive(Serialize)]
105struct CapitalDistributionOut {
106    capital_in_super: f64,
107    capital_in_big: f64,
108    capital_in_mid: f64,
109    capital_in_small: f64,
110    capital_out_super: f64,
111    capital_out_big: f64,
112    capital_out_mid: f64,
113    capital_out_small: f64,
114    update_time: String,
115}
116
117pub async fn get_capital_distribution(client: &Arc<FutuClient>, symbol: &str) -> Result<String> {
118    let sec = parse_symbol(symbol)?;
119    let req = futu_proto::qot_get_capital_distribution::Request {
120        c2s: futu_proto::qot_get_capital_distribution::C2s {
121            security: futu_proto::qot_common::Security {
122                market: sec.market as i32,
123                code: sec.code,
124            },
125            header: None, // v1.4.110 codex Slice 1 schema 占位
126        },
127    };
128    let body = req.encode_to_vec();
129    let frame = client
130        .request(futu_core::proto_id::QOT_GET_CAPITAL_DISTRIBUTION, body)
131        .await?;
132    let resp = futu_proto::qot_get_capital_distribution::Response::decode(frame.body.as_ref())
133        .map_err(|e| anyhow!("decode capital_distribution: {e}"))?;
134    if resp.ret_type != 0 {
135        bail!(
136            "capital_distribution ret_type={} msg={:?}",
137            resp.ret_type,
138            resp.ret_msg
139        );
140    }
141    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
142    let out = CapitalDistributionOut {
143        capital_in_super: s.capital_in_super.unwrap_or(0.0),
144        capital_in_big: s.capital_in_big,
145        capital_in_mid: s.capital_in_mid,
146        capital_in_small: s.capital_in_small,
147        capital_out_super: s.capital_out_super.unwrap_or(0.0),
148        capital_out_big: s.capital_out_big,
149        capital_out_mid: s.capital_out_mid,
150        capital_out_small: s.capital_out_small,
151        update_time: s.update_time.unwrap_or_default(),
152    };
153    Ok(serde_json::to_string_pretty(&out)?)
154}
155
156// ============================================================
157// get_market_state / `Qot_GetMarketState` (CMD 3223)
158// ============================================================
159
160#[derive(Serialize)]
161struct MarketStateOut {
162    code: String,
163    name: String,
164    market_state: i32,
165}
166
167pub async fn get_market_state(client: &Arc<FutuClient>, symbols: &[String]) -> Result<String> {
168    // v1.4.106 codex 0641 F1 (P2): 列表型 input 契约 — 空列表 / 任一非法
169    // symbol → 整体 reject (default ON 严格语义), 不再 silent drop 无效项
170    // 用显式 for-loop + map_err 保留非法项的 index/symbol, 让调用方看到准确原因.
171    //
172    // 之前行为: ["HK.00700", "GARBAGE", "US.AAPL"] → silent drop "GARBAGE"
173    //   → backend 收到 2 项, 用户看 3 项响应 (但其实 1 项 silent miss).
174    // 现在行为: 同样输入 → 整体 reject + 明确指出 "GARBAGE" 解析失败.
175    if symbols.is_empty() {
176        bail!("market_state: symbols empty (必须至少传入 1 个 MARKET.CODE)");
177    }
178    let mut sec_list: Vec<futu_proto::qot_common::Security> = Vec::with_capacity(symbols.len());
179    for (i, s) in symbols.iter().enumerate() {
180        let sec = parse_symbol(s).map_err(|e| {
181            anyhow!(
182                "market_state: symbols[{i}] invalid ({s:?}): {e} — 整体 reject, 不 partial-success"
183            )
184        })?;
185        sec_list.push(futu_proto::qot_common::Security {
186            market: sec.market as i32,
187            code: sec.code,
188        });
189    }
190    // 经 futu_qot::symbol_list helper 二次校验 (market != 0, code != "")
191    // — parse_symbol 已挡 bad case, 此 call 等价 invariant assert.
192    let _parsed = futu_qot::symbol_list::parse_required_symbol_list(&sec_list)
193        .map_err(|e| anyhow!("market_state: {e}"))?;
194    let req = futu_proto::qot_get_market_state::Request {
195        c2s: futu_proto::qot_get_market_state::C2s {
196            security_list: sec_list,
197            header: None,
198        },
199    };
200    let body = req.encode_to_vec();
201    let frame = client
202        .request(futu_core::proto_id::QOT_GET_MARKET_STATE, body)
203        .await?;
204    let resp = futu_proto::qot_get_market_state::Response::decode(frame.body.as_ref())
205        .map_err(|e| anyhow!("decode market_state: {e}"))?;
206    if resp.ret_type != 0 {
207        bail!(
208            "market_state ret_type={} msg={:?}",
209            resp.ret_type,
210            resp.ret_msg
211        );
212    }
213    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
214    let out: Vec<MarketStateOut> = s
215        .market_info_list
216        .iter()
217        .map(|m| MarketStateOut {
218            code: format!("{}.{}", market_prefix(m.security.market), m.security.code),
219            name: m.name.clone(),
220            market_state: m.market_state,
221        })
222        .collect();
223    Ok(serde_json::to_string_pretty(&out)?)
224}
225
226// ============================================================
227// v1.4.26 新增:history_kline / owner_plate / reference / option_chain
228// ============================================================
229
230// ===== get_history_kline / `Qot_RequestHistoryKL` (CMD 3103) =====
231
232fn parse_kl_type_local(s: &str) -> Result<KLType> {
233    let Some(t) = qot_subscription::qot_kl_type_from_str_alias(s)
234        .and_then(qot_sdk_adapter::kl_type_from_public_id)
235    else {
236        let other = s.trim().to_ascii_lowercase();
237        bail!(
238            "unknown kl_type {other:?} \
239             (day|week|month|quarter|year|1min|3min|5min|10min|15min|30min|60min|120min|180min|240min)"
240        );
241    };
242    Ok(t)
243}
244
245fn parse_rehab_type(s: &str) -> Result<RehabType> {
246    let rehab_type = parse_rehab_type_id(s)
247        .map_err(|error| anyhow!("unknown rehab_type {:?}: {error}", error.raw()))?;
248    qot_sdk_adapter::rehab_type_from_id(rehab_type)
249        .ok_or_else(|| anyhow!("unsupported shared rehab_type id {rehab_type}"))
250}
251
252#[derive(Serialize)]
253struct HistoryKLineOut {
254    time: String,
255    timestamp: f64,
256    open: f64,
257    high: f64,
258    low: f64,
259    close: f64,
260    volume: i64,
261    turnover: f64,
262    pe: f64,
263    change_rate: f64,
264    turnover_rate: f64,
265}
266
267/// 历史 K 线(对齐 py-futu-api `OpenQuoteContext.request_history_kline`)。
268///
269/// 和 `futu_get_kline`(`handlers::market::get_kline`)的区别:
270/// - 支持显式 `rehab_type`(前复权/后复权/不复权),`get_kline` 默认 None
271/// - `max_count` 可设大数值拉 1000+ 条(`get_kline` 有 lookback 估算限制)
272pub async fn get_history_kline(
273    client: &Arc<FutuClient>,
274    symbol: &str,
275    kl_type_str: &str,
276    rehab_type_str: &str,
277    begin: &str,
278    end: &str,
279    max_count: Option<i32>,
280    need_kl_fields_flag: Option<i64>,
281    extended_time: Option<bool>,
282    session: Option<i32>,
283    next_req_key: Option<&[u8]>,
284) -> Result<String> {
285    let sec = parse_symbol(symbol)?;
286    let kl_type = parse_kl_type_local(kl_type_str)?;
287    let rehab_type = parse_rehab_type(rehab_type_str)?;
288    let session = validate_history_session_id(session)
289        .map_err(|error| anyhow!("invalid history_kline session: {error}"))?;
290    let result = futu_qot::history_kl::request_history_kl(
291        client,
292        &futu_qot::history_kl::RequestHistoryKLParams {
293            security: &sec,
294            rehab_type,
295            kl_type,
296            begin_time: begin,
297            end_time: end,
298            max_num: max_count,
299            need_kl_fields_flag,
300            next_req_key,
301            extended_time,
302            session,
303        },
304    )
305    .await?;
306    let out: Vec<HistoryKLineOut> = result
307        .kl_list
308        .iter()
309        .map(|k| HistoryKLineOut {
310            time: k.time.clone(),
311            timestamp: k.timestamp,
312            open: k.open_price,
313            high: k.high_price,
314            low: k.low_price,
315            close: k.close_price,
316            volume: k.volume,
317            turnover: k.turnover,
318            pe: k.pe,
319            change_rate: k.change_rate,
320            turnover_rate: k.turnover_rate,
321        })
322        .collect();
323    let next_req_key = result.next_req_key.as_ref().map(|key| {
324        use base64::Engine as _;
325        base64::engine::general_purpose::STANDARD.encode(key)
326    });
327    Ok(serde_json::to_string_pretty(&serde_json::json!({
328        "symbol": symbol,
329        "kl_type": kl_type_str,
330        "rehab_type": rehab_type_str,
331        "extended_time": extended_time,
332        "session": session,
333        "need_kl_fields_flag": need_kl_fields_flag,
334        "next_req_key": next_req_key,
335        "kl_list": out,
336    }))?)
337}
338
339// ===== get_owner_plate / `Qot_GetOwnerPlate` (CMD 3207) =====
340
341#[derive(Serialize)]
342struct OwnerPlateOut {
343    symbol: String,
344    plates: Vec<PlateInfo>,
345}
346
347#[derive(Serialize)]
348struct PlateInfo {
349    code: String,
350    name: String,
351    plate_type: i32,
352}
353
354/// 股票所属板块(一只票可能属于多个板块,如行业/概念/地域)。
355pub async fn get_owner_plate(client: &Arc<FutuClient>, symbols: &[String]) -> Result<String> {
356    if symbols.is_empty() {
357        bail!("empty symbols");
358    }
359    let sec_list: Vec<_> = symbols
360        .iter()
361        .map(|s| parse_symbol(s))
362        .collect::<Result<Vec<_>>>()?;
363    let s2c = futu_qot::market_misc::get_owner_plate(client, &sec_list).await?;
364    let out: Vec<OwnerPlateOut> = s2c
365        .owner_plate_list
366        .iter()
367        .map(|entry| {
368            let sym = format!("{:?}.{}", entry.security.market, entry.security.code);
369            OwnerPlateOut {
370                symbol: sym,
371                plates: entry
372                    .plate_info_list
373                    .iter()
374                    .map(|p| PlateInfo {
375                        code: p.plate.code.clone(),
376                        name: p.name.clone(),
377                        plate_type: p.plate_type.unwrap_or(0),
378                    })
379                    .collect(),
380            }
381        })
382        .collect();
383    Ok(serde_json::to_string_pretty(&out)?)
384}
385
386// ===== get_reference / `Qot_GetReference` (CMD 3206) =====
387
388fn parse_reference_type(s: &str) -> Result<i32> {
389    // 对齐 Qot_Common.ReferenceType enum
390    // 1=Warrant 涡轮,2=Future 期货,3=Option 期权
391    match s.trim().to_ascii_lowercase().as_str() {
392        "warrant" => Ok(1),
393        "future" | "futures" => Ok(2),
394        "option" => Ok(3),
395        other => bail!("unknown reference_type {other:?} (warrant|future|option)"),
396    }
397}
398
399#[derive(Serialize)]
400struct ReferenceOut {
401    code: String,
402    name: String,
403    lot_size: i32,
404    sec_type: i32,
405}
406
407/// 获取关联证券(正股↔涡轮/期货/期权)。
408///
409/// 例:`get_reference("HK.00700", "warrant")` 返回腾讯所有涡轮。
410pub async fn get_reference(
411    client: &Arc<FutuClient>,
412    symbol: &str,
413    reference_type_str: &str,
414) -> Result<String> {
415    let sec = parse_symbol(symbol)?;
416    let ref_type = parse_reference_type(reference_type_str)?;
417    let list = futu_qot::market_misc::get_reference(client, &sec, ref_type).await?;
418    let out: Vec<ReferenceOut> = list
419        .iter()
420        .map(|s| ReferenceOut {
421            code: s.security.code.clone(),
422            name: s.name.clone(),
423            lot_size: s.lot_size,
424            sec_type: s.sec_type,
425        })
426        .collect();
427    Ok(serde_json::to_string_pretty(&out)?)
428}
429
430// ===== get_option_chain / `Qot_GetOptionChain` (CMD 3209) =====
431
432/// v1.4.98 T1-3 (mobile-source-audit): 单条期权 (call+put 配对, 同 strike).
433/// 之前 OptionChainEntry 只返 strike_time + Vec<String> symbol 字符串 →
434/// 期权 trader 必须额外调 N 次 get_snapshot 拿 strike_price / IV / Greeks.
435/// 现透传 OptionStaticExData (proto/Qot_Common.proto:711-723) 全 6 静态字段
436/// (strike_price 是 trader 第一关心), 让单次 chain query 拿全静态数据.
437///
438/// **Note**: Greeks (delta/gamma/theta/vega/rho/IV) 是 live-data 在 snapshot
439/// (proto OptionSnapshotExData v1.4.94 M3 已 expose), chain 只含 static.
440/// 完整 Greek 仍需 batch get_snapshot (1 次 N symbols).
441#[derive(Serialize)]
442struct OptionRow {
443    /// 行权价 (期权 trader 第一关心字段)
444    strike_price: f64,
445    /// 看涨合约 symbol (None = 此 strike 无 call)
446    call_symbol: Option<String>,
447    /// 看跌合约 symbol (None = 此 strike 无 put)
448    put_symbol: Option<String>,
449    /// 是否停牌 (call 优先, fallback put)
450    suspend: Option<bool>,
451    /// 发行市场名 (e.g. "HKEX" / "OPRA")
452    market: Option<String>,
453    /// 指数期权类型 (仅指数期权有, IndexOptionType enum)
454    index_option_type: Option<i32>,
455    /// 交割周期 (ExpirationCycle: Weekly/Monthly/Quarterly)
456    expiration_cycle: Option<i32>,
457    /// 标准期权 (OptionStandardType enum)
458    option_standard_type: Option<i32>,
459    /// 结算方式 (OptionSettlementMode enum)
460    option_settlement_mode: Option<i32>,
461}
462
463#[derive(Serialize)]
464struct OptionChainEntry {
465    strike_time: String,
466    /// v1.4.98 T1-3: 升级为 Vec<OptionRow> 每条含 strike_price + 静态字段.
467    /// 旧 call_symbols / put_symbols 字段保留 for 向后兼容 (重复信息).
468    options: Vec<OptionRow>,
469    /// **deprecated** (v1.4.98 T1-3): 用 `options[].call_symbol` 代替.
470    /// 保留只为向后兼容, 下版可删.
471    call_symbols: Vec<String>,
472    /// **deprecated** (v1.4.98 T1-3): 用 `options[].put_symbol` 代替.
473    put_symbols: Vec<String>,
474}
475
476/// 期权链(看涨/看跌合约列表)。
477///
478/// - `owner_symbol`: 正股(如 `HK.00700` / `US.AAPL`)
479/// - `begin_time` / `end_time`: 到期日范围,格式 `YYYY-MM-DD`
480/// - `option_type_str`: "all" / "call" / "put"
481/// - `data_filter`: v1.4.38 Phase 3 新增,Greek server-side filter;`None` → v1.4.37 行为
482pub struct OptionChainInput<'a> {
483    pub owner_symbol: &'a str,
484    pub begin_time: &'a str,
485    pub end_time: &'a str,
486    pub option_type_str: Option<&'a str>,
487    pub data_filter: Option<futu_proto::qot_get_option_chain::DataFilter>,
488}
489
490pub async fn get_option_chain(
491    client: &Arc<FutuClient>,
492    input: OptionChainInput<'_>,
493) -> Result<String> {
494    let owner = parse_symbol(input.owner_symbol)?;
495    let option_type = match input.option_type_str.map(str::trim) {
496        Some("all") | None => Some(0), // OptionType_ALL
497        Some("call") => Some(1),
498        Some("put") => Some(2),
499        Some(other) => bail!("unknown option_type {other:?} (all|call|put)"),
500    };
501    let s2c = futu_qot::market_misc::get_option_chain(
502        client,
503        &owner,
504        input.begin_time,
505        input.end_time,
506        option_type,
507        None,
508        input.data_filter,
509    )
510    .await?;
511    // OptionItem 的 `call` / `put` 都是 Option<SecurityStaticInfo>,单条 item
512    // 表示一对同行权价的看涨+看跌合约;我们按到期日(strike_time)聚合
513    let out: Vec<OptionChainEntry> = s2c
514        .option_chain
515        .iter()
516        .map(|entry| {
517            let mut calls = Vec::new();
518            let mut puts = Vec::new();
519            // v1.4.98 T1-3: per-row OptionRow 含 strike_price + 6 static fields.
520            // OptionStaticExData 在 SecurityStaticInfo.option_ex_data 上;
521            // call/put 同 strike, 优先取 call 的 ex_data, fallback put.
522            let mut option_rows: Vec<OptionRow> = Vec::new();
523            for item in &entry.option {
524                if let Some(c) = &item.call {
525                    calls.push(c.basic.security.code.clone());
526                }
527                if let Some(p) = &item.put {
528                    puts.push(p.basic.security.code.clone());
529                }
530                let ex = item
531                    .call
532                    .as_ref()
533                    .and_then(|c| c.option_ex_data.as_ref())
534                    .or_else(|| item.put.as_ref().and_then(|p| p.option_ex_data.as_ref()));
535                if let Some(ex) = ex {
536                    option_rows.push(OptionRow {
537                        strike_price: ex.strike_price,
538                        call_symbol: item.call.as_ref().map(|c| c.basic.security.code.clone()),
539                        put_symbol: item.put.as_ref().map(|p| p.basic.security.code.clone()),
540                        suspend: Some(ex.suspend),
541                        market: Some(ex.market.clone()),
542                        index_option_type: ex.index_option_type,
543                        expiration_cycle: ex.expiration_cycle,
544                        option_standard_type: ex.option_standard_type,
545                        option_settlement_mode: ex.option_settlement_mode,
546                    });
547                }
548            }
549            OptionChainEntry {
550                strike_time: entry.strike_time.clone(),
551                options: option_rows,
552                call_symbols: calls,
553                put_symbols: puts,
554            }
555        })
556        .collect();
557    Ok(serde_json::to_string_pretty(&out)?)
558}