1use 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 #[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 #[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 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 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 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;