futu_core/qot_stock_key.rs
1//! `QotStockKey` / `QotSecurityKey` —— QOT 行情订阅 + cache + push broker-aware key.
2//!
3//! ## 背景 (v1.4.110 codex QOT C++ alignment Slice 2)
4//!
5//! C++ `NNProtoCenter/NNProtoCenter_Define_StockKey.h` 定义 `StockKey` 为:
6//!
7//! ```cpp
8//! StockKey(stockID) // 不区分 broker (m_hasBroker=false)
9//! StockKey(stockID, brokerID) // 仅 brokerID != NN_BrokerID_Unknown 时 m_hasBroker=true
10//! ```
11//!
12//! `NN_BrokerID_Unknown = 0` 不是一个独立 broker key, 等价 "no broker".
13//! Equality 比较: `nStockID + m_hasBroker + (m_hasBroker ? brokerID : ignored)`.
14//!
15//! QOT 全模块 (subscription, cache, push registry, quota, GetSubInfo response)
16//! 都围绕 `StockKey` 作 first-class identity. Rust 之前用 public string
17//! `"market_code"` 把不同 broker 合并 → crypto multi-broker 行为系统性偏差.
18//!
19//! ## 设计要点
20//!
21//! - **`broker_id: Option<NonZeroU32>`**: 用 `NonZeroU32` 类型层 enforce
22//! "Some(0) 不可能存在", 严格对齐 C++ `m_hasBroker` 语义 (codex 调研 12:18 增量).
23//! - **`QotSecurityKey`** 复合: `public_sec_key` ("market_code", 给 FTAPI
24//! `Security` 字段回显) + `stock_key` (cache/subscription 内部识别).
25//! - **Display**: broker-aware 编码用 `"market_code@b1007"` (仅内部使用,
26//! 不能让 public `Security.code` 泄漏 `@b1007` suffix).
27//!
28//! ## Hardcoded / Assumption Ledger
29//!
30//! - `NonZeroU32` 用作 `broker_id` enforcement, 不允许 `Some(0)` 出现 —
31//! C++ `NN_BrokerID_Unknown = 0` 永远走 no-broker 路径.
32//! - `display` 后缀 `@b{N}` 是 Rust 内部约定 (codex 调研 17:22 推荐), C++ 无
33//! 对应 string 格式 (它用 typed `StockKey` 比较), 仅内部 cache key encoding.
34
35use std::borrow::Cow;
36use std::fmt;
37use std::num::NonZeroU32;
38
39/// QOT broker-aware stock 唯一识别.
40///
41/// 对齐 C++ `NNProtoCenter_Define_StockKey.h::StockKey`:
42/// - `stock_id` = C++ `nStockID`
43/// - `broker_id = Some(N)` ⟺ C++ `m_hasBroker = true && enBrokerID = N`
44/// - `broker_id = None` ⟺ C++ `m_hasBroker = false`
45///
46/// 注意 `broker_id` 不接受 `0` (C++ 语义: 0 = `NN_BrokerID_Unknown` = no-broker).
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
48pub struct QotStockKey {
49 pub stock_id: u64,
50 pub broker_id: Option<NonZeroU32>,
51}
52
53impl QotStockKey {
54 /// 不带 broker 的 stock key (C++ `StockKey(stockID)`).
55 pub const fn no_broker(stock_id: u64) -> Self {
56 Self {
57 stock_id,
58 broker_id: None,
59 }
60 }
61
62 /// 带 broker 的 stock key (C++ `StockKey(stockID, brokerID)`).
63 ///
64 /// `broker_id = 0` 不接受 (C++ `NN_BrokerID_Unknown` 走 no-broker path).
65 /// 返 `None` 时, caller 应该改用 `no_broker(stock_id)`.
66 pub fn with_broker(stock_id: u64, broker_id: u32) -> Option<Self> {
67 NonZeroU32::new(broker_id).map(|nz| Self {
68 stock_id,
69 broker_id: Some(nz),
70 })
71 }
72
73 /// 从 u32 broker_id 安全构造 — `0` 自动降级到 no-broker.
74 ///
75 /// 等价 C++ `StockKey(stockID, brokerID == NN_BrokerID_Unknown ? no-broker : with broker)`.
76 pub fn from_broker_id_or_no_broker(stock_id: u64, broker_id: u32) -> Self {
77 match NonZeroU32::new(broker_id) {
78 Some(nz) => Self {
79 stock_id,
80 broker_id: Some(nz),
81 },
82 None => Self::no_broker(stock_id),
83 }
84 }
85
86 /// C++ `HasBroker()` 等价.
87 pub fn has_broker(&self) -> bool {
88 self.broker_id.is_some()
89 }
90
91 /// C++ `GetBrokerID()` 等价 (返 raw u32, no-broker 返 0).
92 pub fn broker_id_or_zero(&self) -> u32 {
93 self.broker_id.map(|nz| nz.get()).unwrap_or(0)
94 }
95}
96
97impl fmt::Display for QotStockKey {
98 /// 内部 cache key 编码: `"{stock_id}"` (no-broker) 或 `"{stock_id}@b{broker_id}"`.
99 ///
100 /// 仅供 cache key / log 使用; **绝不**直接暴露到 public `Security` 字段.
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 match self.broker_id {
103 Some(nz) => write!(f, "{}@b{}", self.stock_id, nz),
104 None => write!(f, "{}", self.stock_id),
105 }
106 }
107}
108
109/// QOT broker-aware security key (复合): public `"market_code"` + broker-aware `QotStockKey`.
110///
111/// - `public_sec_key`: FTAPI `Security` 字段回显形态 ("market_code"), 不带 broker.
112/// 给 handler response / first-push replay / display 用.
113/// - `stock_key`: cache / subscription manager / push registry 内部识别, 带 broker.
114///
115/// 同 stock_id 不同 broker 的 crypto 订阅:
116/// ```ignore
117/// QotSecurityKey { public_sec_key: "91_BTCUSDT", stock_key: QotStockKey { stock_id: 12345, broker_id: Some(1007) } }
118/// QotSecurityKey { public_sec_key: "91_BTCUSDT", stock_key: QotStockKey { stock_id: 12345, broker_id: Some(1008) } }
119/// ```
120/// 两者 public_sec_key 相同 (用户看到同一 symbol), 但 stock_key 不同 (内部隔离).
121#[derive(Debug, Clone, PartialEq, Eq, Hash)]
122pub struct QotSecurityKey {
123 pub public_sec_key: String,
124 pub stock_key: QotStockKey,
125}
126
127/// Borrowed parse result for public FTAPI sec_key envelope `"{market}_{code}"`.
128#[derive(Debug, Clone, Copy, PartialEq, Eq)]
129pub struct QotPublicSecKey<'a> {
130 pub market: i32,
131 pub code: &'a str,
132}
133
134impl QotSecurityKey {
135 /// 普通 no-broker security key.
136 pub fn no_broker(public_sec_key: String, stock_id: u64) -> Self {
137 Self {
138 public_sec_key,
139 stock_key: QotStockKey::no_broker(stock_id),
140 }
141 }
142
143 /// broker-aware security key. `broker_id = 0` 自动降级 no-broker.
144 pub fn from_broker_id(public_sec_key: String, stock_id: u64, broker_id: u32) -> Self {
145 Self {
146 public_sec_key,
147 stock_key: QotStockKey::from_broker_id_or_no_broker(stock_id, broker_id),
148 }
149 }
150
151 /// 内部 cache encoding: `"{market_code}@b{broker_id}"` (broker-aware) 或
152 /// `"{market_code}"` (no-broker).
153 pub fn cache_key(&self) -> String {
154 self.cache_key_cow().into_owned()
155 }
156
157 /// Borrow the public key for ordinary securities and allocate only when a
158 /// broker suffix is required.
159 pub fn cache_key_cow(&self) -> Cow<'_, str> {
160 match self.stock_key.broker_id {
161 Some(nz) => Cow::Owned(format!("{}@b{}", self.public_sec_key, nz)),
162 None => Cow::Borrowed(self.public_sec_key.as_str()),
163 }
164 }
165
166 /// Parse public FTAPI sec_key envelope `"{market}_{code}"`.
167 ///
168 /// Only the first underscore belongs to the envelope; the code suffix may
169 /// contain additional underscores (e.g. `11_BRK_B`).
170 pub fn parse_public_sec_key(sec_key: &str) -> Option<QotPublicSecKey<'_>> {
171 let (market, code) = sec_key.split_once('_')?;
172 if code.is_empty() {
173 return None;
174 }
175 let market = market.parse::<i32>().ok()?;
176 if market <= 0 {
177 return None;
178 }
179 Some(QotPublicSecKey { market, code })
180 }
181
182 /// **v1.4.110 codex Phase 3 Slice 6b**: parse `cache_key()` display string
183 /// back to `(public_sec_key, Option<broker_id>)`.
184 ///
185 /// 返 `(public_sec_key, Some(broker_id))` if 输入含 `@bN` suffix; 否则
186 /// `(input, None)`. 解析失败 (e.g. `@bNot_A_Number`) 返 `None`.
187 ///
188 /// 用于 SubscriptionManager.qot_global_desired_keys() 返 display string 时,
189 /// rebuild 路径要还原 broker dimension 才能 cache lookup.
190 pub fn parse_cache_key(cache_key: &str) -> Option<(String, Option<u32>)> {
191 if let Some(idx) = cache_key.rfind("@b") {
192 let public = &cache_key[..idx];
193 let broker_part = &cache_key[idx + 2..];
194 match broker_part.parse::<u32>() {
195 Ok(b) if b > 0 => Some((public.to_string(), Some(b))),
196 _ => None,
197 }
198 } else {
199 Some((cache_key.to_string(), None))
200 }
201 }
202}
203
204#[cfg(test)]
205mod tests;