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::fmt;
36use std::num::NonZeroU32;
37
38/// QOT broker-aware stock 唯一识别.
39///
40/// 对齐 C++ `NNProtoCenter_Define_StockKey.h::StockKey`:
41/// - `stock_id` = C++ `nStockID`
42/// - `broker_id = Some(N)` ⟺ C++ `m_hasBroker = true && enBrokerID = N`
43/// - `broker_id = None` ⟺ C++ `m_hasBroker = false`
44///
45/// 注意 `broker_id` 不接受 `0` (C++ 语义: 0 = `NN_BrokerID_Unknown` = no-broker).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
47pub struct QotStockKey {
48 pub stock_id: u64,
49 pub broker_id: Option<NonZeroU32>,
50}
51
52impl QotStockKey {
53 /// 不带 broker 的 stock key (C++ `StockKey(stockID)`).
54 pub const fn no_broker(stock_id: u64) -> Self {
55 Self {
56 stock_id,
57 broker_id: None,
58 }
59 }
60
61 /// 带 broker 的 stock key (C++ `StockKey(stockID, brokerID)`).
62 ///
63 /// `broker_id = 0` 不接受 (C++ `NN_BrokerID_Unknown` 走 no-broker path).
64 /// 返 `None` 时, caller 应该改用 `no_broker(stock_id)`.
65 pub fn with_broker(stock_id: u64, broker_id: u32) -> Option<Self> {
66 NonZeroU32::new(broker_id).map(|nz| Self {
67 stock_id,
68 broker_id: Some(nz),
69 })
70 }
71
72 /// 从 u32 broker_id 安全构造 — `0` 自动降级到 no-broker.
73 ///
74 /// 等价 C++ `StockKey(stockID, brokerID == NN_BrokerID_Unknown ? no-broker : with broker)`.
75 pub fn from_broker_id_or_no_broker(stock_id: u64, broker_id: u32) -> Self {
76 match NonZeroU32::new(broker_id) {
77 Some(nz) => Self {
78 stock_id,
79 broker_id: Some(nz),
80 },
81 None => Self::no_broker(stock_id),
82 }
83 }
84
85 /// C++ `HasBroker()` 等价.
86 pub fn has_broker(&self) -> bool {
87 self.broker_id.is_some()
88 }
89
90 /// C++ `GetBrokerID()` 等价 (返 raw u32, no-broker 返 0).
91 pub fn broker_id_or_zero(&self) -> u32 {
92 self.broker_id.map(|nz| nz.get()).unwrap_or(0)
93 }
94}
95
96impl fmt::Display for QotStockKey {
97 /// 内部 cache key 编码: `"{stock_id}"` (no-broker) 或 `"{stock_id}@b{broker_id}"`.
98 ///
99 /// 仅供 cache key / log 使用; **绝不**直接暴露到 public `Security` 字段.
100 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101 match self.broker_id {
102 Some(nz) => write!(f, "{}@b{}", self.stock_id, nz),
103 None => write!(f, "{}", self.stock_id),
104 }
105 }
106}
107
108/// QOT broker-aware security key (复合): public `"market_code"` + broker-aware `QotStockKey`.
109///
110/// - `public_sec_key`: FTAPI `Security` 字段回显形态 ("market_code"), 不带 broker.
111/// 给 handler response / first-push replay / display 用.
112/// - `stock_key`: cache / subscription manager / push registry 内部识别, 带 broker.
113///
114/// 同 stock_id 不同 broker 的 crypto 订阅:
115/// ```ignore
116/// QotSecurityKey { public_sec_key: "91_BTCUSDT", stock_key: QotStockKey { stock_id: 12345, broker_id: Some(1007) } }
117/// QotSecurityKey { public_sec_key: "91_BTCUSDT", stock_key: QotStockKey { stock_id: 12345, broker_id: Some(1008) } }
118/// ```
119/// 两者 public_sec_key 相同 (用户看到同一 symbol), 但 stock_key 不同 (内部隔离).
120#[derive(Debug, Clone, PartialEq, Eq, Hash)]
121pub struct QotSecurityKey {
122 pub public_sec_key: String,
123 pub stock_key: QotStockKey,
124}
125
126impl QotSecurityKey {
127 /// 普通 no-broker security key.
128 pub fn no_broker(public_sec_key: String, stock_id: u64) -> Self {
129 Self {
130 public_sec_key,
131 stock_key: QotStockKey::no_broker(stock_id),
132 }
133 }
134
135 /// broker-aware security key. `broker_id = 0` 自动降级 no-broker.
136 pub fn from_broker_id(public_sec_key: String, stock_id: u64, broker_id: u32) -> Self {
137 Self {
138 public_sec_key,
139 stock_key: QotStockKey::from_broker_id_or_no_broker(stock_id, broker_id),
140 }
141 }
142
143 /// 内部 cache encoding: `"{market_code}@b{broker_id}"` (broker-aware) 或
144 /// `"{market_code}"` (no-broker).
145 pub fn cache_key(&self) -> String {
146 match self.stock_key.broker_id {
147 Some(nz) => format!("{}@b{}", self.public_sec_key, nz),
148 None => self.public_sec_key.clone(),
149 }
150 }
151
152 /// **v1.4.110 codex Phase 3 Slice 6b**: parse `cache_key()` display string
153 /// back to `(public_sec_key, Option<broker_id>)`.
154 ///
155 /// 返 `(public_sec_key, Some(broker_id))` if 输入含 `@bN` suffix; 否则
156 /// `(input, None)`. 解析失败 (e.g. `@bNot_A_Number`) 返 `None`.
157 ///
158 /// 用于 SubscriptionManager.qot_global_desired_keys() 返 display string 时,
159 /// rebuild 路径要还原 broker dimension 才能 cache lookup.
160 pub fn parse_cache_key(cache_key: &str) -> Option<(String, Option<u32>)> {
161 if let Some(idx) = cache_key.rfind("@b") {
162 let public = &cache_key[..idx];
163 let broker_part = &cache_key[idx + 2..];
164 match broker_part.parse::<u32>() {
165 Ok(b) if b > 0 => Some((public.to_string(), Some(b))),
166 _ => None,
167 }
168 } else {
169 Some((cache_key.to_string(), None))
170 }
171 }
172}
173
174#[cfg(test)]
175mod tests;