Skip to main content

futucli/cmd/
sys.rs

1//! v1.4.30:系统元数据查询(global-state / user-info / delay-statistics)
2//!
3//! - `global-state` → `GetGlobalState` (CMD 1002)
4//! - `user-info` → `GetUserInfo` (CMD 1005)
5//! - `delay-statistics` → `GetDelayStatistics` (CMD 1007)
6//!
7//! 这三个 proto 是"网关自身元数据",对齐 py-futu-api 的 `OpenContext.get_*`。
8//! 不走 futu-qot helper,直接 prost 往 futu-net 发。
9
10use anyhow::{Result, anyhow, bail};
11use prost::Message;
12use serde::Serialize;
13use tabled::Tabled;
14
15use crate::common::connect_gateway;
16use crate::output::OutputFormat;
17
18mod quote_rights;
19mod subscription;
20#[cfg(test)]
21mod tests;
22mod user_info;
23
24pub use quote_rights::run_quote_rights;
25#[cfg(test)]
26use quote_rights::{quote_right_quota_rows, quote_right_rows, quote_right_user_rows};
27pub use subscription::{run_query_subscription, run_unsubscribe, run_used_quota};
28pub use user_info::run_user_info;
29#[cfg(test)]
30use user_info::user_attribution_region_label;
31
32// ============================================================
33// global-state
34// ============================================================
35
36#[derive(Tabled)]
37struct GlobalStateRow {
38    #[tabled(rename = "Field")]
39    field: String,
40    #[tabled(rename = "Value")]
41    value: String,
42}
43
44#[derive(Serialize)]
45struct GlobalStateJson {
46    market_hk: i32,
47    market_us: i32,
48    market_sh: i32,
49    market_sz: i32,
50    market_hk_future: i32,
51    market_us_future: Option<i32>,
52    market_sg_future: Option<i32>,
53    market_jp_future: Option<i32>,
54    market_sg: Option<i32>,
55    market_my: Option<i32>,
56    market_jp: Option<i32>,
57    qot_logined: bool,
58    trd_logined: bool,
59    server_ver: i32,
60    server_build_no: i32,
61    server_time: i64,
62    conn_id: Option<u64>,
63}
64
65// v1.4.31: market_state_label 抽到 futu_qot::types::market_state_label
66// (历史 2 份拷贝合并到 leaf crate 统一维护,避免漂移)
67use futu_qot::types::market_state_label;
68use futu_surface_spec::endpoints::get_delay_statistics::{
69    DEFAULT_QOT_PUSH_STAGE, default_segment_list_vec, default_type_list_vec,
70};
71
72pub async fn run_global_state(gateway: &str, format: OutputFormat) -> Result<()> {
73    let (client, _rx) = connect_gateway(gateway, "futucli-global-state").await?;
74    let req = futu_proto::get_global_state::Request {
75        c2s: futu_proto::get_global_state::C2s { user_id: 0 },
76    };
77    let body = req.encode_to_vec();
78    let frame = client
79        .request(futu_core::proto_id::GET_GLOBAL_STATE, body)
80        .await?;
81    let resp = futu_proto::get_global_state::Response::decode(frame.body.as_ref())
82        .map_err(|e| anyhow!("decode global_state: {e}"))?;
83    if resp.ret_type != 0 {
84        bail!(
85            "global_state ret_type={} msg={:?}",
86            resp.ret_type,
87            resp.ret_msg
88        );
89    }
90    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
91    let rows = vec![
92        GlobalStateRow {
93            field: "market_hk".into(),
94            value: format!("{} ({})", market_state_label(s.market_hk), s.market_hk),
95        },
96        GlobalStateRow {
97            field: "market_us".into(),
98            value: format!("{} ({})", market_state_label(s.market_us), s.market_us),
99        },
100        GlobalStateRow {
101            field: "market_sh".into(),
102            value: format!("{} ({})", market_state_label(s.market_sh), s.market_sh),
103        },
104        GlobalStateRow {
105            field: "market_sz".into(),
106            value: format!("{} ({})", market_state_label(s.market_sz), s.market_sz),
107        },
108        GlobalStateRow {
109            field: "market_hk_future".into(),
110            value: format!(
111                "{} ({})",
112                market_state_label(s.market_hk_future),
113                s.market_hk_future
114            ),
115        },
116        GlobalStateRow {
117            field: "market_sg".into(),
118            value: s
119                .market_sg
120                .map(|v| format!("{} ({})", market_state_label(v), v))
121                .unwrap_or_else(|| "-".into()),
122        },
123        GlobalStateRow {
124            field: "market_my".into(),
125            value: s
126                .market_my
127                .map(|v| format!("{} ({})", market_state_label(v), v))
128                .unwrap_or_else(|| "-".into()),
129        },
130        GlobalStateRow {
131            field: "market_jp".into(),
132            value: s
133                .market_jp
134                .map(|v| format!("{} ({})", market_state_label(v), v))
135                .unwrap_or_else(|| "-".into()),
136        },
137        GlobalStateRow {
138            field: "qot_logined".into(),
139            value: s.qot_logined.to_string(),
140        },
141        GlobalStateRow {
142            field: "trd_logined".into(),
143            value: s.trd_logined.to_string(),
144        },
145        GlobalStateRow {
146            field: "server_ver".into(),
147            value: s.server_ver.to_string(),
148        },
149        GlobalStateRow {
150            field: "server_build_no".into(),
151            value: s.server_build_no.to_string(),
152        },
153        GlobalStateRow {
154            field: "server_time".into(),
155            value: s.time.to_string(),
156        },
157        GlobalStateRow {
158            field: "conn_id".into(),
159            value: s
160                .conn_id
161                .map(|c| c.to_string())
162                .unwrap_or_else(|| "-".into()),
163        },
164    ];
165    let json = GlobalStateJson {
166        market_hk: s.market_hk,
167        market_us: s.market_us,
168        market_sh: s.market_sh,
169        market_sz: s.market_sz,
170        market_hk_future: s.market_hk_future,
171        market_us_future: s.market_us_future,
172        market_sg_future: s.market_sg_future,
173        market_jp_future: s.market_jp_future,
174        market_sg: s.market_sg,
175        market_my: s.market_my,
176        market_jp: s.market_jp,
177        qot_logined: s.qot_logined,
178        trd_logined: s.trd_logined,
179        server_ver: s.server_ver,
180        server_build_no: s.server_build_no,
181        server_time: s.time,
182        conn_id: s.conn_id,
183    };
184    format.print_rows(&rows, &[json])?;
185    Ok(())
186}
187
188// ============================================================
189// delay-statistics
190// ============================================================
191
192#[derive(Tabled)]
193struct DelayStatRow {
194    #[tabled(rename = "Category")]
195    category: String,
196    #[tabled(rename = "Samples")]
197    samples: usize,
198}
199
200#[derive(Serialize)]
201struct DelayStatJson {
202    qot_push_categories: usize,
203    req_reply_samples: usize,
204    place_order_samples: usize,
205}
206
207pub async fn run_delay_statistics(gateway: &str, format: OutputFormat) -> Result<()> {
208    let (client, _rx) = connect_gateway(gateway, "futucli-delay-statistics").await?;
209    let req = futu_proto::get_delay_statistics::Request {
210        c2s: futu_proto::get_delay_statistics::C2s {
211            type_list: default_type_list_vec(),
212            qot_push_stage: Some(DEFAULT_QOT_PUSH_STAGE),
213            segment_list: default_segment_list_vec(),
214        },
215    };
216    let body = req.encode_to_vec();
217    let frame = client
218        .request(futu_core::proto_id::GET_DELAY_STATISTICS, body)
219        .await?;
220    let resp = futu_proto::get_delay_statistics::Response::decode(frame.body.as_ref())
221        .map_err(|e| anyhow!("decode delay_statistics: {e}"))?;
222    if resp.ret_type != 0 {
223        bail!(
224            "delay_statistics ret_type={} msg={:?}",
225            resp.ret_type,
226            resp.ret_msg
227        );
228    }
229    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
230    let rows = vec![
231        DelayStatRow {
232            category: "qot_push".into(),
233            samples: s.qot_push_statistics_list.len(),
234        },
235        DelayStatRow {
236            category: "req_reply".into(),
237            samples: s.req_reply_statistics_list.len(),
238        },
239        DelayStatRow {
240            category: "place_order".into(),
241            samples: s.place_order_statistics_list.len(),
242        },
243    ];
244    let json = DelayStatJson {
245        qot_push_categories: s.qot_push_statistics_list.len(),
246        req_reply_samples: s.req_reply_statistics_list.len(),
247        place_order_samples: s.place_order_statistics_list.len(),
248    };
249    format.print_rows(&rows, &[json])?;
250    Ok(())
251}
252
253// ===================================================================
254// v1.4.98 T2-8 (mobile-source-audit Phase 2): NN+MM token state query
255// CMD 1326 CS_CMDID_NewToken_GetStateInfo
256// ===================================================================
257
258#[derive(serde::Serialize, tabled::Tabled)]
259struct TokenStateRow {
260    /// Token app brand
261    brand: String,
262    /// 1=已绑定 / 0=未绑定
263    bind: u32,
264    /// 1=已启用 / 0=未启用
265    enable: u32,
266}
267
268#[derive(serde::Serialize)]
269struct TokenStateJson {
270    nn_token_enable: u32,
271    nn_token_bind: u32,
272    mm_token_enable: u32,
273    mm_token_bind: u32,
274}
275
276pub async fn run_token_state(gateway: &str, format: OutputFormat) -> Result<()> {
277    use futu_backend::proto_internal::futu_token_state;
278
279    let (client, _rx) = connect_gateway(gateway, "futucli-token-state").await?;
280    let req = futu_token_state::DaemonGetTokenStateReq {
281        c2s: futu_token_state::daemon_get_token_state_req::C2s { app_id: None },
282    };
283    let body = req.encode_to_vec();
284    let frame = client
285        .request(futu_core::proto_id::GET_TOKEN_STATE, body)
286        .await?;
287    let resp = futu_token_state::DaemonGetTokenStateRsp::decode(frame.body.as_ref())
288        .map_err(|e| anyhow!("decode token_state: {e}"))?;
289    if resp.ret_type != 0 {
290        bail!(
291            "token_state ret_type={} msg={:?}",
292            resp.ret_type,
293            resp.ret_msg
294        );
295    }
296    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
297    let rows = vec![
298        TokenStateRow {
299            brand: "NN (Futu Token)".into(),
300            bind: s.nn_token_bind.unwrap_or(0),
301            enable: s.nn_token_enable.unwrap_or(0),
302        },
303        TokenStateRow {
304            brand: "MM (moomoo Token)".into(),
305            bind: s.mm_token_bind.unwrap_or(0),
306            enable: s.mm_token_enable.unwrap_or(0),
307        },
308    ];
309    let json = TokenStateJson {
310        nn_token_enable: s.nn_token_enable.unwrap_or(0),
311        nn_token_bind: s.nn_token_bind.unwrap_or(0),
312        mm_token_enable: s.mm_token_enable.unwrap_or(0),
313        mm_token_bind: s.mm_token_bind.unwrap_or(0),
314    };
315    format.print_rows(&rows, &[json])?;
316    Ok(())
317}
318
319// ===================================================================
320// v1.4.98 T2-2 (mobile-source-audit Phase 2): risk-free rate
321// CMD 20231 GetRiskFreeRate (无加密)
322// ===================================================================
323
324#[derive(serde::Serialize, tabled::Tabled)]
325struct RiskFreeRateRow {
326    market: String,
327    /// 利率 (百分比数值, e.g. 4.5 表示 4.5%)
328    rate_pct: String,
329    /// raw uint64 (×10^9)
330    raw: u64,
331}
332
333#[derive(serde::Serialize)]
334struct RiskFreeRateJson {
335    hk_rate_pct: Option<f64>,
336    us_rate_pct: Option<f64>,
337    jp_rate_pct: Option<f64>,
338    update_time: Option<i64>,
339    hk_rate_raw: Option<u64>,
340    us_rate_raw: Option<u64>,
341    jp_rate_raw: Option<u64>,
342}
343
344pub async fn run_risk_free_rate(gateway: &str, format: OutputFormat) -> Result<()> {
345    use futu_backend::proto_internal::risk_free_rate;
346
347    let (client, _rx) = connect_gateway(gateway, "futucli-risk-free-rate").await?;
348    let req = risk_free_rate::DaemonGetRiskFreeRateReq {
349        c2s: risk_free_rate::daemon_get_risk_free_rate_req::C2s { rate_time: None },
350    };
351    let body = req.encode_to_vec();
352    let frame = client
353        .request(futu_core::proto_id::QOT_GET_RISK_FREE_RATE, body)
354        .await?;
355    let resp = risk_free_rate::DaemonGetRiskFreeRateRsp::decode(frame.body.as_ref())
356        .map_err(|e| anyhow!("decode risk_free_rate: {e}"))?;
357    if resp.ret_type != 0 {
358        bail!(
359            "risk_free_rate ret_type={} msg={:?}",
360            resp.ret_type,
361            resp.ret_msg
362        );
363    }
364    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
365    // Daemon returns percent value (4.5 means 4.5%). Internally option
366    // probability uses the C++ formula unit raw / 1e9 (0.045 for 4.5%).
367    let fmt_pct = |v: Option<f64>| -> String {
368        match v {
369            Some(p) => format!("{:.4}%", p),
370            None => "—".into(),
371        }
372    };
373    let rows = vec![
374        RiskFreeRateRow {
375            market: "HK".into(),
376            rate_pct: fmt_pct(s.hk_rate_pct),
377            raw: s.hk_rate_raw.unwrap_or(0),
378        },
379        RiskFreeRateRow {
380            market: "US".into(),
381            rate_pct: fmt_pct(s.us_rate_pct),
382            raw: s.us_rate_raw.unwrap_or(0),
383        },
384        RiskFreeRateRow {
385            market: "JP".into(),
386            rate_pct: fmt_pct(s.jp_rate_pct),
387            raw: s.jp_rate_raw.unwrap_or(0),
388        },
389    ];
390    let json = RiskFreeRateJson {
391        hk_rate_pct: s.hk_rate_pct,
392        us_rate_pct: s.us_rate_pct,
393        jp_rate_pct: s.jp_rate_pct,
394        update_time: s.update_time,
395        hk_rate_raw: s.hk_rate_raw,
396        us_rate_raw: s.us_rate_raw,
397        jp_rate_raw: s.jp_rate_raw,
398    };
399    format.print_rows(&rows, &[json])?;
400    Ok(())
401}
402
403// ===================================================================
404// v1.4.98 T2-1: SpreadTable cmd 6503
405// ===================================================================
406
407pub async fn run_spread_table(gateway: &str, format: OutputFormat) -> Result<()> {
408    use futu_backend::proto_internal::spread_table_6503;
409
410    let (client, _rx) = connect_gateway(gateway, "futucli-spread-table").await?;
411    let req = spread_table_6503::DaemonGetSpreadTableReq {
412        c2s: spread_table_6503::daemon_get_spread_table_req::C2s { reserved: None },
413    };
414    let body = req.encode_to_vec();
415    let frame = client
416        .request(futu_core::proto_id::QOT_GET_SPREAD_TABLE, body)
417        .await?;
418    let resp = spread_table_6503::DaemonGetSpreadTableRsp::decode(frame.body.as_ref())
419        .map_err(|e| anyhow!("decode spread_table: {e}"))?;
420    if resp.ret_type != 0 {
421        bail!(
422            "spread_table ret_type={} msg={:?}",
423            resp.ret_type,
424            resp.ret_msg
425        );
426    }
427    // 直接 JSON dump (table 格式不适合多层嵌套 list, 用 json/raw 输出)
428    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
429    match format {
430        // v1.4.99 codex F4 fix (P2, 2026-04-27): Jsonl 之前 fall through 到
431        // table branch (人类可读). 改为 Json/Jsonl 都走结构化 (jsonl =
432        // 紧凑单行, json = pretty multiline). pitfall #37 4-place sync.
433        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&s)?),
434        OutputFormat::Jsonl => println!("{}", serde_json::to_string(&s)?),
435        OutputFormat::Table => {
436            // table 格式: 摘要 (spread_code, item count)
437            println!(
438                "Spread tables: {} (use --output json for full data)",
439                s.spread_table_list.len()
440            );
441            for t in &s.spread_table_list {
442                println!(
443                    "  spread_code={:?}  items={}",
444                    t.spread_code,
445                    t.spread_item_list.len()
446                );
447            }
448        }
449    }
450    Ok(())
451}
452
453// ===================================================================
454// v1.4.98 T2-3: TickerStatistic cmd 6365
455// ===================================================================
456
457pub async fn run_ticker_statistic(
458    gateway: &str,
459    symbol: &str,
460    ticker_type: Option<i32>,
461    stat_type: Option<u32>,
462    format: OutputFormat,
463) -> Result<()> {
464    use futu_backend::proto_internal::ticker_statistic_daemon;
465
466    let (client, _rx) = connect_gateway(gateway, "futucli-ticker-statistic").await?;
467    // codex 2026-04-27 P3 fix: 复用 crate::common::parse_symbol (与 MCP /
468    // 其他 CLI quote 命令同 parser, 大小写不敏感 + HK_FUTURE 支持) 而非
469    // 本地手写 case-sensitive 4-market match.
470    let sec = crate::common::parse_symbol(symbol)?;
471    let req = ticker_statistic_daemon::DaemonGetTickerStatisticReq {
472        c2s: ticker_statistic_daemon::daemon_get_ticker_statistic_req::C2s {
473            security: ticker_statistic_daemon::Security {
474                market: sec.market as i32,
475                code: sec.code,
476            },
477            ticker_type,
478            ticker_time: None,
479            stat_type,
480        },
481    };
482    let body = req.encode_to_vec();
483    let frame = client
484        .request(futu_core::proto_id::QOT_GET_TICKER_STATISTIC, body)
485        .await?;
486    let resp = ticker_statistic_daemon::DaemonGetTickerStatisticRsp::decode(frame.body.as_ref())
487        .map_err(|e| anyhow!("decode ticker_statistic: {e}"))?;
488    if resp.ret_type != 0 {
489        bail!(
490            "ticker_statistic ret_type={} msg={:?}",
491            resp.ret_type,
492            resp.ret_msg
493        );
494    }
495    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
496    match format {
497        // v1.4.99 codex F4 fix (P2, 2026-04-27): same as SpreadTable —
498        // Jsonl 走结构化, 不 fall through 到 table.
499        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&s)?),
500        OutputFormat::Jsonl => println!("{}", serde_json::to_string(&s)?),
501        OutputFormat::Table => {
502            println!("symbol: {}", symbol);
503            if let Some(stid) = s.resolved_stock_id {
504                println!("resolved_stock_id: {}", stid);
505            }
506            if let Some(tt) = s.ticker_time {
507                println!("ticker_time: {}", tt);
508            }
509            if !s.date_list.is_empty() {
510                println!("date_list (returned when ticker_time=0): {:?}", s.date_list);
511            }
512            if let Some(stat) = &s.stat {
513                println!("avg_price: {:?}", stat.avg_price);
514                println!("trade_volume: {:?}", stat.trade_volume);
515                println!("trade_num: {:?}", stat.trade_num);
516                println!("buy_volume: {:?}", stat.buy_volume);
517                println!("sell_volume: {:?}", stat.sell_volume);
518                println!("neutral_volume: {:?}", stat.neutral_volume);
519                println!("last_close_price: {:?}", stat.last_close_price);
520            }
521        }
522    }
523    Ok(())
524}
525
526// ===================================================================
527// v1.4.106 codex 0500 ζ23-redo: TickerStatistic Detail cmd 6366
528// ===================================================================
529
530pub struct TickerStatisticDetailCommand<'a> {
531    pub gateway: &'a str,
532    pub symbol: &'a str,
533    pub ticker_type: Option<i32>,
534    pub ticker_time: Option<u64>,
535    pub select_num: Option<u32>,
536    pub data_from: Option<u32>,
537    pub data_max_count: Option<u32>,
538    pub stat_type: Option<u32>,
539    pub format: OutputFormat,
540}
541
542pub async fn run_ticker_statistic_detail(input: TickerStatisticDetailCommand<'_>) -> Result<()> {
543    use futu_backend::proto_internal::ticker_statistic_daemon;
544
545    let (client, _rx) = connect_gateway(input.gateway, "futucli-ticker-statistic-detail").await?;
546    let sec = crate::common::parse_symbol(input.symbol)?;
547    let req = ticker_statistic_daemon::DaemonGetTickerStatisticDetailReq {
548        c2s: ticker_statistic_daemon::daemon_get_ticker_statistic_detail_req::C2s {
549            security: ticker_statistic_daemon::Security {
550                market: sec.market as i32,
551                code: sec.code,
552            },
553            ticker_type: input.ticker_type,
554            ticker_time: input.ticker_time,
555            select_num: input.select_num,
556            data_from: input.data_from,
557            data_max_count: input.data_max_count,
558            stat_type: input.stat_type,
559        },
560    };
561    let body = req.encode_to_vec();
562    let frame = client
563        .request(futu_core::proto_id::QOT_GET_TICKER_STATISTIC_DETAIL, body)
564        .await?;
565    let resp =
566        ticker_statistic_daemon::DaemonGetTickerStatisticDetailRsp::decode(frame.body.as_ref())
567            .map_err(|e| anyhow!("decode ticker_statistic_detail: {e}"))?;
568    if resp.ret_type != 0 {
569        bail!(
570            "ticker_statistic_detail ret_type={} msg={:?}",
571            resp.ret_type,
572            resp.ret_msg
573        );
574    }
575    let s = resp.s2c.ok_or_else(|| anyhow!("missing s2c"))?;
576    match input.format {
577        OutputFormat::Json => println!("{}", serde_json::to_string_pretty(&s)?),
578        OutputFormat::Jsonl => println!("{}", serde_json::to_string(&s)?),
579        OutputFormat::Table => {
580            println!("symbol: {}", input.symbol);
581            if let Some(stid) = s.resolved_stock_id {
582                println!("resolved_stock_id: {}", stid);
583            }
584            if let Some(tt) = s.ticker_time {
585                println!("ticker_time: {}", tt);
586            }
587            if let Some(have_more) = s.have_more {
588                println!("have_more: {}", have_more);
589            }
590            if let Some(mr) = s.max_ratio {
591                println!("max_ratio: {:.5}", mr);
592            }
593            println!("items ({}):", s.items.len());
594            for (i, item) in s.items.iter().enumerate() {
595                println!(
596                    "  [{:2}] price={:?} buy={:?} sell={:?} vol={:?} ratio={:?} neutral={:?}",
597                    i,
598                    item.price,
599                    item.buy_volume,
600                    item.sell_volume,
601                    item.volume,
602                    item.ratio,
603                    item.neutral_volume
604                );
605            }
606        }
607    }
608    Ok(())
609}