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