Skip to main content

futucli/cmd/
snapshot.rs

1//! `futucli snapshot` — 获取股票快照(单次,无需订阅)
2
3use anyhow::Result;
4use serde::Serialize;
5use tabled::Tabled;
6
7use crate::common::{connect_gateway, parse_symbol};
8use crate::output::OutputFormat;
9
10#[derive(Tabled)]
11struct SnapshotRow {
12    #[tabled(rename = "Symbol")]
13    symbol: String,
14    #[tabled(rename = "Name")]
15    name: String,
16    #[tabled(rename = "Price")]
17    price: String,
18    #[tabled(rename = "Change%")]
19    change_pct: String,
20    #[tabled(rename = "Open")]
21    open: String,
22    #[tabled(rename = "High")]
23    high: String,
24    #[tabled(rename = "Low")]
25    low: String,
26    #[tabled(rename = "Volume")]
27    volume: String,
28    #[tabled(rename = "Turnover")]
29    turnover: String,
30    /// v1.4.93 P1-3 (BUG-5318-003): exchange_code (e.g. "CME"/"NYMEX")
31    #[tabled(rename = "Exchange")]
32    exchange_code: String,
33}
34
35#[derive(Serialize)]
36struct SnapshotOptionOwnerJson {
37    market: i32,
38    code: String,
39}
40
41#[derive(Serialize)]
42struct SnapshotOptionJson {
43    option_type: i32,
44    option_owner: SnapshotOptionOwnerJson,
45    strike_time: String,
46    strike_price: f64,
47    contract_size: i32,
48    open_interest: i32,
49    implied_volatility: f64,
50    premium: f64,
51    delta: f64,
52    gamma: f64,
53    vega: f64,
54    theta: f64,
55    rho: f64,
56    #[serde(skip_serializing_if = "Option::is_none")]
57    strike_timestamp: Option<f64>,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    index_option_type: Option<i32>,
60    #[serde(skip_serializing_if = "Option::is_none")]
61    net_open_interest: Option<i32>,
62    #[serde(skip_serializing_if = "Option::is_none")]
63    expiry_date_distance: Option<i32>,
64    #[serde(skip_serializing_if = "Option::is_none")]
65    contract_nominal_value: Option<f64>,
66    #[serde(skip_serializing_if = "Option::is_none")]
67    owner_lot_multiplier: Option<f64>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    option_area_type: Option<i32>,
70    #[serde(skip_serializing_if = "Option::is_none")]
71    contract_multiplier: Option<f64>,
72    #[serde(skip_serializing_if = "Option::is_none")]
73    contract_size_float: Option<f64>,
74}
75
76impl From<&futu_proto::qot_get_security_snapshot::OptionSnapshotExData> for SnapshotOptionJson {
77    fn from(option: &futu_proto::qot_get_security_snapshot::OptionSnapshotExData) -> Self {
78        Self {
79            option_type: option.r#type,
80            option_owner: SnapshotOptionOwnerJson {
81                market: option.owner.market,
82                code: option.owner.code.clone(),
83            },
84            strike_time: option.strike_time.clone(),
85            strike_price: option.strike_price,
86            contract_size: option.contract_size,
87            open_interest: option.open_interest,
88            implied_volatility: option.implied_volatility,
89            premium: option.premium,
90            delta: option.delta,
91            gamma: option.gamma,
92            vega: option.vega,
93            theta: option.theta,
94            rho: option.rho,
95            strike_timestamp: option.strike_timestamp,
96            index_option_type: option.index_option_type,
97            net_open_interest: option.net_open_interest,
98            expiry_date_distance: option.expiry_date_distance,
99            contract_nominal_value: option.contract_nominal_value,
100            owner_lot_multiplier: option.owner_lot_multiplier,
101            option_area_type: option.option_area_type,
102            contract_multiplier: option.contract_multiplier,
103            contract_size_float: option.contract_size_float,
104        }
105    }
106}
107
108#[derive(Serialize)]
109struct SnapshotJson {
110    symbol: String,
111    market: i32,
112    code: String,
113    name: String,
114    sec_type: i32,
115    lot_size: i32,
116    is_suspend: bool,
117    list_time: String,
118    update_time: String,
119    cur_price: f64,
120    last_close_price: f64,
121    change_pct: f64,
122    open_price: f64,
123    high_price: f64,
124    low_price: f64,
125    volume: i64,
126    turnover: f64,
127    turnover_rate: f64,
128    ask_price: Option<f64>,
129    bid_price: Option<f64>,
130    ask_vol: Option<i64>,
131    bid_vol: Option<i64>,
132    amplitude: Option<f64>,
133    avg_price: Option<f64>,
134    volume_ratio: Option<f64>,
135    highest52_weeks_price: Option<f64>,
136    lowest52_weeks_price: Option<f64>,
137    #[serde(flatten)]
138    option: Option<SnapshotOptionJson>,
139    /// v1.4.93 P1-3 (BUG-5318-003): exchange_code 派生自 SecurityStaticInfo
140    /// (e.g. "CME"/"NYMEX"/"CBOT"/"CBOE"/"COMEX"/"NYSE"/"Nasdaq"). snapshot
141    /// proto 自身不带 `exch_type`, 所以 CLI 在 snapshot 之外**额外调一次
142    /// `get_static_info`** (per-symbol round trip) 拿到 `exch_type` 后映射.
143    /// `null` = daemon `exch_type=0` 且 mkt_id 未在表中.
144    #[serde(skip_serializing_if = "Option::is_none")]
145    exchange_code: Option<String>,
146}
147
148fn snapshot_json_from(
149    snap: &futu_proto::qot_get_security_snapshot::Snapshot,
150    exchange_code: Option<&'static str>,
151) -> SnapshotJson {
152    let basic = &snap.basic;
153    let cur = basic.cur_price;
154    let last = basic.last_close_price;
155    let change_pct = if last != 0.0 {
156        (cur - last) / last * 100.0
157    } else {
158        0.0
159    };
160    let market_prefix = market_prefix_from_i32(basic.security.market);
161
162    SnapshotJson {
163        symbol: format!("{}.{}", market_prefix, basic.security.code),
164        market: basic.security.market,
165        code: basic.security.code.clone(),
166        name: basic.name.clone().unwrap_or_default(),
167        sec_type: basic.r#type,
168        lot_size: basic.lot_size,
169        is_suspend: basic.is_suspend,
170        list_time: basic.list_time.clone(),
171        update_time: basic.update_time.clone(),
172        cur_price: cur,
173        last_close_price: last,
174        change_pct,
175        open_price: basic.open_price,
176        high_price: basic.high_price,
177        low_price: basic.low_price,
178        volume: basic.volume,
179        turnover: basic.turnover,
180        turnover_rate: basic.turnover_rate,
181        ask_price: basic.ask_price,
182        bid_price: basic.bid_price,
183        ask_vol: basic.ask_vol,
184        bid_vol: basic.bid_vol,
185        amplitude: basic.amplitude,
186        avg_price: basic.avg_price,
187        volume_ratio: basic.volume_ratio,
188        highest52_weeks_price: basic.highest52_weeks_price,
189        lowest52_weeks_price: basic.lowest52_weeks_price,
190        option: snap.option_ex_data.as_ref().map(SnapshotOptionJson::from),
191        // v1.4.93 P1-3: JSON 输出 unknown → 字段省略(None + skip_serializing_if).
192        exchange_code: exchange_code.map(String::from),
193    }
194}
195
196pub async fn run(gateway: &str, symbols: &[String], format: OutputFormat) -> Result<()> {
197    let secs: Vec<_> = symbols
198        .iter()
199        .map(|s| parse_symbol(s))
200        .collect::<Result<_>>()?;
201
202    let (client, _push_rx) = connect_gateway(gateway, "futucli-snapshot").await?;
203    let s2c = futu_qot::snapshot::get_security_snapshot(&client, &secs).await?;
204
205    // v1.4.93 P1-3 (BUG-5318-003): snapshot proto 自身不带 exch_type
206    // (`SnapshotBasicData` field 1-42 全部数据点都是行情, 无 exchange 字段).
207    // CLI 额外调一次 `get_static_info` 拿 exch_type, 按 (market, code) → key
208    // 索引到 snapshot 输出. 失败时单 symbol 退化成 None (其它 symbol 正常),
209    // 不阻断主流程 (CLAUDE.md 反模式 D 防御: 不为单点 cosmetic 字段炸 CLI).
210    let exch_code_by_key: std::collections::HashMap<(i32, String), &'static str> =
211        match futu_qot::static_info::get_static_info(&client, &secs).await {
212            Ok(infos) => infos
213                .iter()
214                .filter_map(|i| {
215                    i.exchange_code()
216                        .map(|s| ((i.security.market as i32, i.security.code.clone()), s))
217                })
218                .collect(),
219            Err(e) => {
220                tracing::warn!(
221                    "get_static_info for exchange_code lookup failed: {e}; exchange_code 字段省略"
222                );
223                std::collections::HashMap::new()
224            }
225        };
226
227    let mut rows = Vec::new();
228    let mut jsons = Vec::new();
229
230    for snap in &s2c.snapshot_list {
231        let basic = &snap.basic;
232        let cur = basic.cur_price;
233        let last = basic.last_close_price;
234        let change_pct = if last != 0.0 {
235            (cur - last) / last * 100.0
236        } else {
237            0.0
238        };
239        let market_prefix = market_prefix_from_i32(basic.security.market);
240        let symbol = format!("{}.{}", market_prefix, basic.security.code);
241        let sign = if cur >= last { "+" } else { "" };
242        let name = basic.name.clone().unwrap_or_default();
243        let exchange_code: Option<&'static str> = exch_code_by_key
244            .get(&(basic.security.market, basic.security.code.clone()))
245            .copied();
246
247        rows.push(SnapshotRow {
248            symbol: symbol.clone(),
249            name: name.clone(),
250            price: format!("{cur:.3}"),
251            change_pct: format!("{sign}{change_pct:.2}%"),
252            open: format!("{:.3}", basic.open_price),
253            high: format!("{:.3}", basic.high_price),
254            low: format!("{:.3}", basic.low_price),
255            volume: basic.volume.to_string(),
256            turnover: format!("{:.0}", basic.turnover),
257            // v1.4.93 P1-3: tabled row unknown → "-".
258            exchange_code: exchange_code.unwrap_or("-").to_string(),
259        });
260
261        jsons.push(snapshot_json_from(snap, exchange_code));
262    }
263
264    format.print_rows(&rows, &jsons)?;
265    Ok(())
266}
267
268fn market_prefix_from_i32(m: i32) -> &'static str {
269    futu_core::market::qot_market_display_prefix(futu_core::market::QotMarketId::new(m))
270        .unwrap_or("UNKNOWN")
271}
272
273#[cfg(test)]
274mod tests;