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 SnapshotJson {
37    symbol: String,
38    market: i32,
39    code: String,
40    name: String,
41    sec_type: i32,
42    lot_size: i32,
43    is_suspend: bool,
44    list_time: String,
45    update_time: String,
46    cur_price: f64,
47    last_close_price: f64,
48    change_pct: f64,
49    open_price: f64,
50    high_price: f64,
51    low_price: f64,
52    volume: i64,
53    turnover: f64,
54    turnover_rate: f64,
55    ask_price: Option<f64>,
56    bid_price: Option<f64>,
57    ask_vol: Option<i64>,
58    bid_vol: Option<i64>,
59    amplitude: Option<f64>,
60    avg_price: Option<f64>,
61    volume_ratio: Option<f64>,
62    highest52_weeks_price: Option<f64>,
63    lowest52_weeks_price: Option<f64>,
64    /// v1.4.93 P1-3 (BUG-5318-003): exchange_code 派生自 SecurityStaticInfo
65    /// (e.g. "CME"/"NYMEX"/"CBOT"/"CBOE"/"COMEX"/"NYSE"/"Nasdaq"). snapshot
66    /// proto 自身不带 `exch_type`, 所以 CLI 在 snapshot 之外**额外调一次
67    /// `get_static_info`** (per-symbol round trip) 拿到 `exch_type` 后映射.
68    /// `null` = daemon `exch_type=0` 且 mkt_id 未在表中.
69    #[serde(skip_serializing_if = "Option::is_none")]
70    exchange_code: Option<String>,
71}
72
73pub async fn run(gateway: &str, symbols: &[String], format: OutputFormat) -> Result<()> {
74    let secs: Vec<_> = symbols
75        .iter()
76        .map(|s| parse_symbol(s))
77        .collect::<Result<_>>()?;
78
79    let (client, _push_rx) = connect_gateway(gateway, "futucli-snapshot").await?;
80    let s2c = futu_qot::snapshot::get_security_snapshot(&client, &secs).await?;
81
82    // v1.4.93 P1-3 (BUG-5318-003): snapshot proto 自身不带 exch_type
83    // (`SnapshotBasicData` field 1-42 全部数据点都是行情, 无 exchange 字段).
84    // CLI 额外调一次 `get_static_info` 拿 exch_type, 按 (market, code) → key
85    // 索引到 snapshot 输出. 失败时单 symbol 退化成 None (其它 symbol 正常),
86    // 不阻断主流程 (CLAUDE.md 反模式 D 防御: 不为单点 cosmetic 字段炸 CLI).
87    let exch_code_by_key: std::collections::HashMap<(i32, String), &'static str> =
88        match futu_qot::static_info::get_static_info(&client, &secs).await {
89            Ok(infos) => infos
90                .iter()
91                .filter_map(|i| {
92                    i.exchange_code()
93                        .map(|s| ((i.security.market as i32, i.security.code.clone()), s))
94                })
95                .collect(),
96            Err(e) => {
97                tracing::warn!(
98                    "get_static_info for exchange_code lookup failed: {e}; exchange_code 字段省略"
99                );
100                std::collections::HashMap::new()
101            }
102        };
103
104    let mut rows = Vec::new();
105    let mut jsons = Vec::new();
106
107    for snap in &s2c.snapshot_list {
108        let basic = &snap.basic;
109        let cur = basic.cur_price;
110        let last = basic.last_close_price;
111        let change_pct = if last != 0.0 {
112            (cur - last) / last * 100.0
113        } else {
114            0.0
115        };
116        let market_prefix = market_prefix_from_i32(basic.security.market);
117        let symbol = format!("{}.{}", market_prefix, basic.security.code);
118        let sign = if cur >= last { "+" } else { "" };
119        let name = basic.name.clone().unwrap_or_default();
120        let exchange_code: Option<&'static str> = exch_code_by_key
121            .get(&(basic.security.market, basic.security.code.clone()))
122            .copied();
123
124        rows.push(SnapshotRow {
125            symbol: symbol.clone(),
126            name: name.clone(),
127            price: format!("{cur:.3}"),
128            change_pct: format!("{sign}{change_pct:.2}%"),
129            open: format!("{:.3}", basic.open_price),
130            high: format!("{:.3}", basic.high_price),
131            low: format!("{:.3}", basic.low_price),
132            volume: basic.volume.to_string(),
133            turnover: format!("{:.0}", basic.turnover),
134            // v1.4.93 P1-3: tabled row unknown → "-".
135            exchange_code: exchange_code.unwrap_or("-").to_string(),
136        });
137
138        jsons.push(SnapshotJson {
139            symbol,
140            market: basic.security.market,
141            code: basic.security.code.clone(),
142            name,
143            sec_type: basic.r#type,
144            lot_size: basic.lot_size,
145            is_suspend: basic.is_suspend,
146            list_time: basic.list_time.clone(),
147            update_time: basic.update_time.clone(),
148            cur_price: cur,
149            last_close_price: last,
150            change_pct,
151            open_price: basic.open_price,
152            high_price: basic.high_price,
153            low_price: basic.low_price,
154            volume: basic.volume,
155            turnover: basic.turnover,
156            turnover_rate: basic.turnover_rate,
157            ask_price: basic.ask_price,
158            bid_price: basic.bid_price,
159            ask_vol: basic.ask_vol,
160            bid_vol: basic.bid_vol,
161            amplitude: basic.amplitude,
162            avg_price: basic.avg_price,
163            volume_ratio: basic.volume_ratio,
164            highest52_weeks_price: basic.highest52_weeks_price,
165            lowest52_weeks_price: basic.lowest52_weeks_price,
166            // v1.4.93 P1-3: JSON 输出 unknown → 字段省略(None + skip_serializing_if).
167            exchange_code: exchange_code.map(String::from),
168        });
169    }
170
171    format.print_rows(&rows, &jsons)?;
172    Ok(())
173}
174
175fn market_prefix_from_i32(m: i32) -> &'static str {
176    match m {
177        1 => "HK",
178        2 => "HK_FUTURE",
179        11 => "US",
180        21 => "SH",
181        22 => "SZ",
182        _ => "UNKNOWN",
183    }
184}