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