Skip to main content

futu_qot/
symbol_list.rs

1//! 列表型行情 input 契约 helper(v1.4.106 codex 0641 整体重构落点)。
2//!
3//! 目的:把 "QOT 列表型 handler 在空列表 / 非法 symbol / unresolved cache miss
4//! 三种场景下整体 reject" 的逻辑集中到一个地方,避免 4-5 个 handler 各自
5//! 用不同 silent-fallback / partial-success 策略(pitfall #45 silent-success
6//! anti-pattern + pitfall #31 cache-read silent empty)。
7//!
8//! 三原则(codex 0641 audit):
9//! 1. **default ON 严格语义** — 空列表 / 任一非法 symbol / 任一 unresolved →
10//!    整体 reject(不 partial / 不 silent / 不 fallback)
11//! 2. **整体重构** — `ParsedSymbolList` + `parse_required_symbol_list` +
12//!    `resolve_required_stock_ids` 三件套
13//! 3. 保留 caller-side 真机 verify
14//!
15//! 适用 handler 清单(v1.4.106 起):
16//! - `Qot_GetMarketState`(CMD 3223)
17//! - `Qot_GetFutureInfo`(CMD 3218)
18//! - `Qot_GetSuspend`(CMD 3219)
19//! - `Qot_GetOwnerPlate`(CMD 3207)
20//! - 任何后续接受 `security_list: Vec<Security>` 的 handler
21//!
22//! C++ 对照:`APIServer_Qot_*::OnClientReq_*` 在 backend 侧对每个 sec_market /
23//! code 做基本 sanity check(market 必须在已知枚举内、code 不为空),返
24//! `kAPIErrCode_ParamErr`(不是 partial-success),与本 module 行为一致。
25
26use futu_proto::qot_common::Security;
27
28/// 已通过基本契约校验的 security 列表 — 至少 1 项,且每项 market / code 都非空。
29///
30/// 所有 list-type handler 必须用此类型(而不是裸 `Vec<Security>`)作为输入承载,
31/// 防止 silent-fallback / partial-success 反模式。
32#[derive(Debug, Clone)]
33pub struct ParsedSymbolList {
34    /// 已校验的 security 列表(保留原顺序,便于 handler 按序构造响应)。
35    pub securities: Vec<Security>,
36}
37
38impl ParsedSymbolList {
39    /// 列表长度(保证 ≥1,因为只有通过 [`parse_required_symbol_list`] 才能构造)。
40    pub fn len(&self) -> usize {
41        self.securities.len()
42    }
43
44    /// 永远 false(构造函数保证非空),仅为 clippy `len_without_is_empty` 提供配套。
45    pub fn is_empty(&self) -> bool {
46        self.securities.is_empty()
47    }
48
49    /// 借用底层切片。
50    pub fn as_slice(&self) -> &[Security] {
51        &self.securities
52    }
53}
54
55/// 校验列表型 input 必须非空 + 每个元素的 market / code 都基本合法。
56///
57/// 失败场景(任一即整体 reject,不 partial):
58/// - `securities.is_empty()` → "security_list empty"
59/// - 某个 `sec.market == 0`(FTAPI `QotMarket_Unknown`)→ "market=0 (未知)"
60/// - 某个 `sec.code` 为空字符串 → "code=\"\""
61///
62/// 注意:本函数**不**校验 market 是否在 `[1, 2, 11, 21, 22, 31, 41, 42, 51, 61, 71]`
63/// 等具体 enum 内 — 这是 daemon 与 backend 协商的"已知集合",handler 内部
64/// 用 `derive_quote_mkt_types_for_market` / cache lookup 等机制对未知 market
65/// 做下游决定(已经是 loud reject 行为)。本函数只挡 `market=0` 这个最明显
66/// 的"调用方根本没填" case,避免 silent-fallback 把空 market 当 default。
67pub fn parse_required_symbol_list(securities: &[Security]) -> Result<ParsedSymbolList, String> {
68    if securities.is_empty() {
69        return Err(
70            "security_list empty: 必须至少传入 1 个 (market, code) 才能查询列表型行情".to_string(),
71        );
72    }
73    for (i, sec) in securities.iter().enumerate() {
74        if sec.market == 0 {
75            return Err(format!(
76                "security_list[{i}] market=0 (QotMarket_Unknown): 必须传入有效 market enum (HK=1 / HK_Future=2 / US=11 / SH=21 / SZ=22 / SG=31 / JP=41 / AU=42 / SG_Future=43 / ...)"
77            ));
78        }
79        if sec.code.is_empty() {
80            return Err(format!(
81                "security_list[{i}] code=\"\": 必须非空 (market={})",
82                sec.market
83            ));
84        }
85    }
86    Ok(ParsedSymbolList {
87        securities: securities.to_vec(),
88    })
89}
90
91/// 把已校验的列表整体解析到 (Security, stock_id) tuple 列表。
92///
93/// **整体语义**:任一 symbol 在 resolver(通常是 `StaticDataCache`)里
94/// cache miss 或解析为 stock_id=0 → 整批 reject(不 partial,不 silent,
95/// 不 fallback)。caller 必须**先**调用 [`parse_required_symbol_list`]
96/// 拿到 [`ParsedSymbolList`],本函数不再校验空列表 / market=0 / code="".
97///
98/// resolver 闭包是抽象层 — caller 传入 `|sec| static_cache
99/// .get_security_info_trigger_refresh(&format!("{}_{}", sec.market, sec.code))
100/// .map(|info| info.stock_id)`. 这样本 helper 不依赖 futu-cache(保持
101/// futu-qot crate 边界清晰),同时 caller 可以注入 mock resolver 单测。
102///
103/// 失败信息含具体 missing symbol 列表,用户能立刻判断哪个 symbol 没在
104/// stock_list cache 里(典型场景:海外期货 cache 未刷新 / symbol 拼写错 /
105/// market 误传)。
106pub fn resolve_required_stock_ids<F>(
107    parsed: &ParsedSymbolList,
108    mut resolver: F,
109) -> Result<Vec<(Security, u64)>, String>
110where
111    F: FnMut(&Security) -> Option<u64>,
112{
113    let mut resolved: Vec<(Security, u64)> = Vec::with_capacity(parsed.securities.len());
114    let mut missing: Vec<String> = Vec::new();
115    for sec in &parsed.securities {
116        match resolver(sec) {
117            Some(sid) if sid > 0 => resolved.push((sec.clone(), sid)),
118            _ => missing.push(format!("(market={}, code={:?})", sec.market, sec.code)),
119        }
120    }
121    if !missing.is_empty() {
122        return Err(format!(
123            "无法解析以下 {} 个 symbol 到 stock_id (cache miss / 未知 symbol): [{}] — 请确认 stock_list cache 已刷新或 symbol 拼写正确",
124            missing.len(),
125            missing.join(", ")
126        ));
127    }
128    Ok(resolved)
129}
130
131#[cfg(test)]
132mod tests;