Skip to main content

futu_mcp/handlers/core/
snapshot.rs

1//! mcp/handlers/core/snapshot — 7 Out types + get_snapshot (Snapshot/EquityFundamental/Warrant/Future/OptionGreeks/Overnight + main)
2//! (v1.4.110 CC Batch N: 拆自 core.rs L97-484)
3
4use std::sync::Arc;
5
6use futu_net::client::FutuClient;
7use serde::Serialize;
8
9use crate::state::parse_symbol;
10use anyhow::Result;
11
12#[derive(Serialize)]
13struct SnapshotOut {
14    symbol: String,
15    name: Option<String>,
16    update_time: String,
17    cur_price: f64,
18    last_close: f64,
19    change_rate: f64,
20    open: f64,
21    high: f64,
22    low: f64,
23    volume: i64,
24    turnover: f64,
25    turnover_rate: f64,
26    amplitude: Option<f64>,
27    avg_price: Option<f64>,
28    volume_ratio: Option<f64>,
29    highest52: Option<f64>,
30    lowest52: Option<f64>,
31    ask_price: Option<f64>,
32    bid_price: Option<f64>,
33    is_suspend: bool,
34    lot_size: i32,
35    /// v1.4.72 BUG-006 L4 (eli v1.4.69 P1): US 夜盘 overnight OHLCV 数据。
36    /// 仅 US 股票在夜盘时段有值。其他市场 / regular hours → None。
37    /// 对齐 proto `SnapshotBasicData.overnight` (field 42)。
38    #[serde(skip_serializing_if = "Option::is_none")]
39    overnight: Option<OvernightOut>,
40    /// v1.4.93 P1-3 (BUG-5318-003): exchange_code (e.g. "CME"/"NYMEX"/"NYSE").
41    /// snapshot proto 自身不带 `exch_type` 字段, MCP handler 在 snapshot 之外
42    /// 额外调一次 `get_static_info` 获取. `null` (字段省略) = unknown.
43    /// 见 `futu_core::exch_type::exch_type_to_string` 完整映射表.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    exchange_code: Option<String>,
46    /// v1.4.94 Tier M3 (mobile-driven extension): 期权希腊字母 + IV + 溢价 +
47    /// open interest. 仅期权 symbol 有此字段 (snapshot proto `option_ex_data`);
48    /// 普通股 / 期货 / 基金等 → None.
49    ///
50    /// 来源: OpenD `Qot_Common.proto OptionSnapshotExData` (字段 8-13).
51    /// 之前 MCP `get_snapshot` 只返 basic OHLC, 期权用户必须自己 subscribe +
52    /// parse OptionBasicQotExData (复杂). v1.4.94 直接在 snapshot response 里返.
53    #[serde(skip_serializing_if = "Option::is_none")]
54    option_greeks: Option<OptionGreeksOut>,
55
56    /// v1.4.98 T1-2 (mobile-source-audit Phase 2): 正股 fundamental 字段 (PE / PB /
57    /// EPS / 股息 / NAV / 市值 / 流通股本 等 16 字段). 仅 equity symbol 有.
58    /// 来源: `Qot_GetSecuritySnapshot.proto EquitySnapshotExData`.
59    /// LLM agent fundamental analysis 必备 (选股 / 估值 / 股息策略).
60    #[serde(skip_serializing_if = "Option::is_none")]
61    equity_fundamental: Option<EquityFundamentalOut>,
62
63    /// v1.4.98 T1-2: 港股 warrant / CBBC / 牛熊证 字段 (delta / IV / premium /
64    /// 换股比率 / 街货 / 杠杆 等 19 字段). 仅 HK warrant 有.
65    /// 来源: `Qot_GetSecuritySnapshot.proto WarrantSnapshotExData`.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    warrant_data: Option<WarrantDataOut>,
68
69    /// v1.4.98 T1-2: 期货特有字段 (昨结 / 持仓量 / 日增仓 / 主连标识).
70    /// 仅 future symbol 有. 来源: `FutureSnapshotExData`.
71    /// 期货 trader momentum 信号必看.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    future_data: Option<FutureDataOut>,
74}
75
76/// v1.4.98 T1-2: 正股 fundamental analysis 字段.
77/// 对齐 Qot_GetSecuritySnapshot.proto::EquitySnapshotExData (16 字段).
78#[derive(Serialize)]
79struct EquityFundamentalOut {
80    /// 总股本 (issued_shares)
81    issued_shares: i64,
82    /// 总市值 = 总股本 * 当前价
83    issued_market_val: f64,
84    /// 资产净值
85    net_asset: f64,
86    /// 盈利 (亏损)
87    net_profit: f64,
88    /// 每股盈利 (EPS)
89    earnings_per_share: f64,
90    /// 流通股本
91    outstanding_shares: i64,
92    /// 流通市值
93    outstanding_market_val: f64,
94    /// 每股净资产 (NAV per share)
95    net_asset_per_share: f64,
96    /// 收益率 (百分比, 20 = 20%)
97    ey_rate: f64,
98    /// 市盈率 (PE)
99    pe_rate: f64,
100    /// 市净率 (PB)
101    pb_rate: f64,
102    /// 市盈率 TTM (Trailing 12 Months)
103    pe_ttm_rate: f64,
104    /// 股息 TTM
105    dividend_ttm: Option<f64>,
106    /// 股息率 TTM (百分比)
107    dividend_ratio_ttm: Option<f64>,
108    /// 上一年度派息 (LFY = Last Fiscal Year)
109    dividend_lfy: Option<f64>,
110    /// 上一年度股息率 LFY (百分比)
111    dividend_lfy_ratio: Option<f64>,
112}
113
114/// v1.4.98 T1-2: 港股 warrant / CBBC / 牛熊证 字段.
115/// 对齐 Qot_GetSecuritySnapshot.proto::WarrantSnapshotExData.
116#[derive(Serialize)]
117struct WarrantDataOut {
118    /// 换股比率
119    conversion_rate: f64,
120    /// 窝轮类型 (Qot_Common.WarrantType enum)
121    warrant_type: i32,
122    /// 行使价
123    strike_price: f64,
124    /// 到期日字符串
125    maturity_time: String,
126    /// 最后交易日字符串
127    end_trade_time: String,
128    /// 收回价 (仅牛熊证)
129    recovery_price: f64,
130    /// 街货量
131    street_volume: i64,
132    /// 发行量
133    issue_volume: i64,
134    /// 街货占比 (百分比)
135    street_rate: f64,
136    /// 对冲值 (delta, 仅认购/认沽)
137    delta: f64,
138    /// 引申波幅 (IV, 仅认购/认沽)
139    implied_volatility: f64,
140    /// 溢价 (百分比)
141    premium: f64,
142    /// 杠杆比率 (倍)
143    leverage: Option<f64>,
144    /// 价内/价外 (百分比)
145    ipop: Option<f64>,
146    /// 打和点
147    break_even_point: Option<f64>,
148    /// 换股价
149    conversion_price: Option<f64>,
150}
151
152/// v1.4.98 T1-2: 期货特有字段 (持仓量 / 主连).
153/// 对齐 Qot_GetSecuritySnapshot.proto::FutureSnapshotExData.
154#[derive(Serialize)]
155struct FutureDataOut {
156    /// 昨结算价
157    last_settle_price: f64,
158    /// 持仓量
159    position: i32,
160    /// 日增仓 (momentum 信号)
161    position_change: i32,
162    /// 最后交易日字符串 (仅非主连)
163    last_trade_time: String,
164    /// 是否主连合约
165    is_main_contract: bool,
166}
167
168/// v1.4.94 Tier M3 (mobile-driven extension): 期权希腊字母 + 关键指标 output.
169///
170/// 对齐 OpenD `Qot_Common.proto OptionSnapshotExData` (snapshot) +
171/// `OptionBasicQotExData` (real-time). MCP get_snapshot 仅在 symbol 是期权时返此字段.
172#[derive(Serialize)]
173struct OptionGreeksOut {
174    /// 隐含波动率 (%)
175    implied_volatility: f64,
176    /// 溢价 (%)
177    premium: f64,
178    /// Delta (-1 ~ 1, call 正 put 负, 反映价格变化对期权的杠杆)
179    delta: f64,
180    /// Gamma (Delta 对底层价格的二阶导)
181    gamma: f64,
182    /// Vega (隐含波动率每变 1% 期权价格的变化)
183    vega: f64,
184    /// Theta (每天衰减的时间价值)
185    theta: f64,
186    /// Rho (无风险利率每变 1% 期权价格的变化)
187    rho: f64,
188    /// 未平仓合约数 (open interest)
189    #[serde(skip_serializing_if = "Option::is_none")]
190    open_interest: Option<i32>,
191    /// 行权价
192    #[serde(skip_serializing_if = "Option::is_none")]
193    strike_price: Option<f64>,
194    /// 合约乘数 (1 张期权对应几股 underlying)
195    #[serde(skip_serializing_if = "Option::is_none")]
196    contract_size: Option<i32>,
197    /// 距离到期日天数 (DTE), 负数表已过期
198    #[serde(skip_serializing_if = "Option::is_none")]
199    expiry_date_distance: Option<i32>,
200    /// 合约名义金额 (仅港股期权)
201    #[serde(skip_serializing_if = "Option::is_none")]
202    contract_nominal_value: Option<f64>,
203    /// 净未平仓合约数 (仅港股期权)
204    #[serde(skip_serializing_if = "Option::is_none")]
205    net_open_interest: Option<i32>,
206}
207
208/// v1.4.72 BUG-006 L4: 美股夜盘 OHLCV 数据(对齐 proto `Qot_Common::PreAfterMarketData`)
209#[derive(Serialize)]
210struct OvernightOut {
211    /// 夜盘最新价
212    #[serde(skip_serializing_if = "Option::is_none")]
213    price: Option<f64>,
214    /// 夜盘最高价
215    #[serde(skip_serializing_if = "Option::is_none")]
216    high: Option<f64>,
217    /// 夜盘最低价
218    #[serde(skip_serializing_if = "Option::is_none")]
219    low: Option<f64>,
220    /// 夜盘成交量
221    #[serde(skip_serializing_if = "Option::is_none")]
222    volume: Option<i64>,
223    /// 夜盘成交额
224    #[serde(skip_serializing_if = "Option::is_none")]
225    turnover: Option<f64>,
226    /// 夜盘涨跌
227    #[serde(skip_serializing_if = "Option::is_none")]
228    change_val: Option<f64>,
229    /// 夜盘涨跌幅(%)
230    #[serde(skip_serializing_if = "Option::is_none")]
231    change_rate: Option<f64>,
232    /// 夜盘振幅
233    #[serde(skip_serializing_if = "Option::is_none")]
234    amplitude: Option<f64>,
235}
236
237pub async fn get_snapshot(client: &Arc<FutuClient>, symbol: &str) -> Result<String> {
238    let sec = parse_symbol(symbol)?;
239    let s2c = futu_qot::snapshot::get_security_snapshot(client, std::slice::from_ref(&sec)).await?;
240    let snap = s2c
241        .snapshot_list
242        .first()
243        .ok_or_else(|| anyhow::anyhow!("empty snapshot result"))?;
244    let b = &snap.basic;
245
246    let change_rate = if b.last_close_price.abs() > f64::EPSILON {
247        (b.cur_price - b.last_close_price) / b.last_close_price * 100.0
248    } else {
249        0.0
250    };
251
252    let market_prefix = match b.security.market {
253        1 => "HK",
254        2 => "HK_FUTURE",
255        11 => "US",
256        21 => "SH",
257        22 => "SZ",
258        31 => "SG",
259        41 => "JP",
260        51 => "AU",
261        61 => "MY",
262        71 => "CA",
263        81 => "FX",
264        91 => "CRYPTO",
265        _ => "UNKNOWN",
266    };
267
268    // v1.4.93 P1-3 (BUG-5318-003): snapshot proto 不带 exch_type, 额外调一次
269    // get_static_info 拿 exch_type → exchange_code 字符串. 失败时退化成 None
270    // (其余字段照返, 不阻断主路径; CLAUDE.md 反模式 D 防御).
271    let exchange_code: Option<String> =
272        match futu_qot::static_info::get_static_info(client, std::slice::from_ref(&sec)).await {
273            Ok(infos) => infos
274                .first()
275                .and_then(|i| i.exchange_code())
276                .map(String::from),
277            Err(e) => {
278                tracing::warn!(
279                    "get_static_info for exchange_code lookup failed: {e}; \
280                     exchange_code 字段省略"
281                );
282                None
283            }
284        };
285
286    let out = SnapshotOut {
287        symbol: format!("{market_prefix}.{}", b.security.code),
288        name: b.name.clone(),
289        update_time: b.update_time.clone(),
290        cur_price: b.cur_price,
291        last_close: b.last_close_price,
292        change_rate,
293        open: b.open_price,
294        high: b.high_price,
295        low: b.low_price,
296        volume: b.volume,
297        turnover: b.turnover,
298        turnover_rate: b.turnover_rate,
299        amplitude: b.amplitude,
300        avg_price: b.avg_price,
301        volume_ratio: b.volume_ratio,
302        highest52: b.highest52_weeks_price,
303        lowest52: b.lowest52_weeks_price,
304        ask_price: b.ask_price,
305        bid_price: b.bid_price,
306        is_suspend: b.is_suspend,
307        lot_size: b.lot_size,
308        // v1.4.72 BUG-006 L4: 透传 proto overnight 字段到 MCP 输出
309        overnight: b.overnight.as_ref().map(|on| OvernightOut {
310            price: on.price,
311            high: on.high_price,
312            low: on.low_price,
313            volume: on.volume,
314            turnover: on.turnover,
315            change_val: on.change_val,
316            change_rate: on.change_rate,
317            amplitude: on.amplitude,
318        }),
319        // v1.4.93 P1-3
320        exchange_code,
321        // v1.4.94 Tier M3 (mobile-driven extension): 期权希腊字母 — 仅期权 symbol
322        // 有 option_ex_data, 普通股/期货等 → None
323        option_greeks: snap.option_ex_data.as_ref().map(|o| OptionGreeksOut {
324            implied_volatility: o.implied_volatility,
325            premium: o.premium,
326            delta: o.delta,
327            gamma: o.gamma,
328            vega: o.vega,
329            theta: o.theta,
330            rho: o.rho,
331            open_interest: Some(o.open_interest),
332            strike_price: Some(o.strike_price),
333            contract_size: Some(o.contract_size),
334            expiry_date_distance: o.expiry_date_distance,
335            contract_nominal_value: o.contract_nominal_value,
336            net_open_interest: o.net_open_interest,
337        }),
338        // v1.4.98 T1-2: 正股 fundamental (PE/PB/EPS/股息/NAV/市值)
339        equity_fundamental: snap.equity_ex_data.as_ref().map(|e| EquityFundamentalOut {
340            issued_shares: e.issued_shares,
341            issued_market_val: e.issued_market_val,
342            net_asset: e.net_asset,
343            net_profit: e.net_profit,
344            earnings_per_share: e.earnings_pershare,
345            outstanding_shares: e.outstanding_shares,
346            outstanding_market_val: e.outstanding_market_val,
347            net_asset_per_share: e.net_asset_pershare,
348            ey_rate: e.ey_rate,
349            pe_rate: e.pe_rate,
350            pb_rate: e.pb_rate,
351            pe_ttm_rate: e.pe_ttm_rate,
352            dividend_ttm: e.dividend_ttm,
353            dividend_ratio_ttm: e.dividend_ratio_ttm,
354            dividend_lfy: e.dividend_lfy,
355            dividend_lfy_ratio: e.dividend_lfy_ratio,
356        }),
357        // v1.4.98 T1-2: 港股 warrant / CBBC (delta/IV/premium/换股/街货/杠杆)
358        warrant_data: snap.warrant_ex_data.as_ref().map(|w| WarrantDataOut {
359            conversion_rate: w.conversion_rate,
360            warrant_type: w.warrant_type,
361            strike_price: w.strike_price,
362            maturity_time: w.maturity_time.clone(),
363            end_trade_time: w.end_trade_time.clone(),
364            recovery_price: w.recovery_price,
365            street_volume: w.street_volumn,
366            issue_volume: w.issue_volumn,
367            street_rate: w.street_rate,
368            delta: w.delta,
369            implied_volatility: w.implied_volatility,
370            premium: w.premium,
371            leverage: w.leverage,
372            ipop: w.ipop,
373            break_even_point: w.break_even_point,
374            conversion_price: w.conversion_price,
375        }),
376        // v1.4.98 T1-2: 期货 (持仓量/日增仓/主连)
377        future_data: snap.future_ex_data.as_ref().map(|f| FutureDataOut {
378            last_settle_price: f.last_settle_price,
379            position: f.position,
380            position_change: f.position_change,
381            last_trade_time: f.last_trade_time.clone(),
382            is_main_contract: f.is_main_contract,
383        }),
384    };
385    Ok(serde_json::to_string_pretty(&out)?)
386}
387
388// ============================================================
389// v1.4.30: 网关元数据 / 用户信息 / 延迟统计
390// ============================================================