Skip to main content

futu_core/
qot_symbol_list.rs

1//! Proto-free QOT list input validation and stock-id resolution contract.
2//!
3//! This module owns the fail-closed semantics for list-style QOT requests:
4//! empty list, invalid `(market, code)`, or any unresolved stock id rejects the
5//! entire request. Runtime crates adapt their protocol/cache types into these
6//! plain facts before applying the rule.
7
8use crate::qot_symbol::validate_symbol_code_len;
9
10/// Plain QOT security fact used by list validation without depending on
11/// `futu-proto`.
12#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct QotSymbolListSecurity {
14    pub market: i32,
15    pub code: String,
16}
17
18impl QotSymbolListSecurity {
19    #[must_use]
20    pub fn new(market: i32, code: impl Into<String>) -> Self {
21        Self {
22            market,
23            code: code.into(),
24        }
25    }
26}
27
28/// A non-empty list of QOT securities that passed basic input validation.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct ParsedQotSymbolList {
31    pub securities: Vec<QotSymbolListSecurity>,
32}
33
34impl ParsedQotSymbolList {
35    #[must_use]
36    pub fn len(&self) -> usize {
37        self.securities.len()
38    }
39
40    #[must_use]
41    pub fn is_empty(&self) -> bool {
42        self.securities.is_empty()
43    }
44
45    #[must_use]
46    pub fn as_slice(&self) -> &[QotSymbolListSecurity] {
47        &self.securities
48    }
49}
50
51/// Validate a list-style QOT input. The rule is intentionally shallow and
52/// proto-free: non-empty list, `market != 0`, non-empty code, and bounded code
53/// length. Market support and stock-id resolution stay in later runtime/cache
54/// phases.
55pub fn parse_required_symbol_list(
56    securities: &[QotSymbolListSecurity],
57) -> Result<ParsedQotSymbolList, String> {
58    if securities.is_empty() {
59        return Err(
60            "security_list empty: 必须至少传入 1 个 (market, code) 才能查询列表型行情".to_string(),
61        );
62    }
63
64    for (i, sec) in securities.iter().enumerate() {
65        if sec.market == 0 {
66            return Err(format!(
67                "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 / ...)"
68            ));
69        }
70        if sec.code.is_empty() {
71            return Err(format!(
72                "security_list[{i}] code=\"\": 必须非空 (market={})",
73                sec.market
74            ));
75        }
76        validate_symbol_code_len(&sec.code).map_err(|err| {
77            format!(
78                "security_list[{i}] code length invalid (market={}): {err}",
79                sec.market
80            )
81        })?;
82    }
83
84    Ok(ParsedQotSymbolList {
85        securities: securities.to_vec(),
86    })
87}
88
89/// Resolve all validated securities to stock ids. Any missing or zero stock id
90/// rejects the entire request, matching the audited fail-closed behavior for
91/// list-style QOT handlers.
92pub fn resolve_required_stock_ids<F>(
93    parsed: &ParsedQotSymbolList,
94    mut resolver: F,
95) -> Result<Vec<(QotSymbolListSecurity, u64)>, String>
96where
97    F: FnMut(&QotSymbolListSecurity) -> Option<u64>,
98{
99    let mut resolved: Vec<(QotSymbolListSecurity, u64)> =
100        Vec::with_capacity(parsed.securities.len());
101    let mut missing: Vec<String> = Vec::new();
102
103    for sec in &parsed.securities {
104        match resolver(sec) {
105            Some(stock_id) if stock_id > 0 => resolved.push((sec.clone(), stock_id)),
106            _ => missing.push(format!("(market={}, code={:?})", sec.market, sec.code)),
107        }
108    }
109
110    if !missing.is_empty() {
111        return Err(format!(
112            "无法解析以下 {} 个 symbol 到 stock_id (cache miss / 未知 symbol): [{}] — 请确认 stock_list cache 已刷新或 symbol 拼写正确",
113            missing.len(),
114            missing.join(", ")
115        ));
116    }
117
118    Ok(resolved)
119}