futu_cache/static_data.rs
1// 静态数据缓存:股票列表、经纪商、节假日、停牌
2
3use dashmap::DashMap;
4use std::collections::HashSet;
5use std::sync::RwLock;
6use std::sync::atomic::{AtomicU64, Ordering};
7
8/// v1.4.106 codex 1148 F7 (P2): cache row "完整度 / 来源" 标记。
9///
10/// 此 enum 区分一个 `CachedSecurityInfo` 是从 stock-list 完整 sync 来的
11/// (字段全), 还是 subscribe on-demand CMD 20106 临时补的 (大量字段为空)。
12/// `GetStaticInfo` / `GetSecuritySnapshot` / `make_static_info` 等需要完整
13/// 静态字段的路径在收到 `OnDemandBasic` 行时**应**触发完整 stock-list /
14/// 20106 completed refresh, 或明确返 partial / unavailable, 不假装完整数据。
15///
16/// 来源 audit: `codex/2026-05-01-1148-v1.4.106-codex-qot-static-data-review.md`
17/// Finding 7。
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
19pub enum SecurityInfoSource {
20 /// 来自 backend stock-list full sync (含 list_time / warrnt_stock_owner
21 /// / exch_type / no_search 等所有字段, 是权威数据)。
22 StockListFull,
23 /// 来自 subscribe / GetStaticInfo / GetSecuritySnapshot 的 on-demand CMD 20106
24 /// 临时补行 — 仅含 stock_id / market / mkt_id / code / name / lot_size /
25 /// sec_type, 其余字段缺省 (list_time="", warrnt_stock_owner=0,
26 /// exch_type=0, no_search=false)。**不**应当作权威静态数据源。
27 ///
28 /// **作 Default**: 保守策略 — 历史 caller 漏标 source 时, 当 partial 处理
29 /// (is_complete()=false), 后续 stock_list_sync / Bootstrap 自动覆写为权威
30 /// 来源。这样 cache miss → on-demand fetch → 默认 source = OnDemandBasic
31 /// 不会被误当 StockListFull 权威数据消费。
32 #[default]
33 OnDemandBasic,
34 /// SQLite bootstrap reload (启动时从本地数据库恢复)。字段完整度同
35 /// `StockListFull` (因为 SQLite 是上次 full sync 的快照), 但**生命期更长**
36 /// (跨 daemon 重启)。一般 caller 当 `StockListFull` 处理。
37 Bootstrap,
38}
39
40impl SecurityInfoSource {
41 /// 是否含完整静态字段 (list_time / warrnt_stock_owner / exch_type /
42 /// no_search 等)。`OnDemandBasic` 缺这些, 其他 source 都齐。
43 #[must_use]
44 pub fn is_complete(self) -> bool {
45 matches!(self, Self::StockListFull | Self::Bootstrap)
46 }
47}
48
49/// 股票静态信息
50///
51/// **v1.4.106 codex F5**: 加 `Default` impl 让 caller 可用
52/// `CachedSecurityInfo { stock_id: ..., market: ..., ..Default::default() }`.
53/// 现有显式 callsite 不动, 新加 callsite 可省冗余字段.
54#[derive(Debug, Clone, Default)]
55pub struct CachedSecurityInfo {
56 pub stock_id: u64,
57 /// FTAPI QotMarket 值 (1=HK_Security, 11=US_Security, 21=CNSH, 22=CNSZ 等)
58 ///
59 /// 由 bridge.rs `market_code_to_qot_market()` 从 backend `market_code`
60 /// (NN_QuoteMktID) 映射而来; 用于构造 cache key `"{market}_{code}"`
61 pub market: i32,
62 /// Backend 原始 NN_QuoteMktID (market_code), **保留子交易所精度**
63 ///
64 /// 用于 cache-first 路由决策: 比如 CME Group 子交易所 (60=NYMEX, 70=COMEX,
65 /// 80=CBOT, 90=CME, 100=CBOE) 都被 `market_code_to_qot_market()` 统一压到
66 /// FTAPI QotMarket=11 (US_Security), 但 `mkt_id` 保留原始 range, 使
67 /// `derive_security_type` / `us_futures_sub_exchange` 等能做 O(1) 范围
68 /// 判别, 替代 CLAUDE.md 坑 #35 的 code-pattern 启发式. SQLite 历史行无此
69 /// 字段时读为 0 (默认 Unknown), 下次 upsert 自动修正.
70 pub mkt_id: u32,
71 pub code: String,
72 pub name: String,
73 pub lot_size: i32,
74 pub sec_type: i32,
75 pub list_time: String,
76 /// 窝轮所属正股 ID, 0 表示无
77 pub warrnt_stock_owner: u64,
78 /// 是否已退市
79 pub delisting: bool,
80 /// 交易所类型 (ExchType)
81 pub exch_type: i32,
82 /// 不可搜索标记 (对齐 C++ no_search 字段)
83 pub no_search: bool,
84 /// v1.4.106 codex F5: 期货主连合约关联 stock_id.
85 ///
86 /// 主连合约 (e.g. `HSImain`, `NQmain`) 此字段非 0, 指向真实月份合约
87 /// stock_id (CMD 6747 拉真实 code 用); 普通合约此字段为 0.
88 ///
89 /// 来源: backend stock_list_sync `CSStockItem.origin_id` (proto field 41).
90 /// PlaceOrder F5 用 `info.future_origin_id != 0` 判 NeedPullFutureContractInfo
91 /// (对齐 C++ `Ndt_Qot_SecInfo::nFutureOriginID` + `APIServer_Trd_PlaceOrder
92 /// .cpp:36-42` `NeedPullFutureContractInfo`).
93 ///
94 /// SQLite 历史行无此列时默认 0, 下次 stock_list sync (CMD 6746) 重新写入.
95 pub future_origin_id: u64,
96 /// v1.4.106 codex F5: 期货主力合约 stock_id.
97 ///
98 /// 主连合约持有此字段, 指向当前主力合约 stock_id (流动性最大). PlaceOrder
99 /// F5 在收到 CMD 6747 响应后用此字段反查真实 code 再下单.
100 ///
101 /// 来源: backend `CSStockItem.zhuli_id` (proto field 40). 普通合约 0.
102 pub zhuli_id: u64,
103 /// v1.4.106 codex 1148 F7 (P2): 数据来源 / 完整度标记。详见
104 /// `SecurityInfoSource` doc。**默认 `OnDemandBasic`** 以保守对待历史 caller
105 /// 漏标场景 (能漏标的就是没填全字段, 当 partial 处理最安全)。
106 pub source: SecurityInfoSource,
107 /// v1.4.110 final E.5 P2#6: 交易所字符串 (proto `CSStockItem.exchange` field 57).
108 ///
109 /// 数据驱动 (pitfall #35 C++ 数据驱动 vs Rust 启发式): trade handler
110 /// `derive_exchange_str` 优先用此字段, 缺失时 fallback 到 code prefix /
111 /// pattern match heuristic. 取值例: "SEHK", "NASDAQ", "NYSE", "SSE", "SZSE", "HKEX".
112 pub exchange: String,
113 /// v1.4.110 final E.5 P2#6: 上市交易所 (proto `CSStockItem.listed_exchange` field 74).
114 ///
115 /// 通常等同 `exchange`, dual-listed ADR / 双重主要上市等场景下可能不同.
116 pub listed_exchange: String,
117}
118
119/// Crypto 交易币对元数据。
120///
121/// C++ `NNProto_Trd_MaxQtyCrypto.cpp` 通过 `INNBiz_Qot_SecList::SearchSecByID`
122/// 读取 `Ndt_Qot_SecInfo::szCCOrigin/szCCDestination`,并把它们分别写入
123/// backend `GetMaxBuySellReq.coin/currency`。Rust 从 stock-list field 60/61
124/// 接入同一份数据,避免在交易路径按 symbol 字符串拆币种。
125#[derive(Debug, Clone, Default, PartialEq, Eq)]
126pub struct CryptoPairInfo {
127 pub cc_origin: String,
128 pub cc_destination: String,
129}
130
131/// Crypto 交易配置缓存。
132///
133/// C++ `INNData_Trd_CryptoTradeConfig` 以 `(broker_id, symbol, exchange)` 作为
134/// lookup key,MaxBuySellQty 投影用 `minimum_qty` 和 `base_tick_size` 做数量
135/// 步进对齐。这里保存解析后的 nano 整数,避免每个 surface 自己解析 decimal。
136#[derive(Debug, Clone, Default, PartialEq, Eq)]
137pub struct CryptoTradeConfig {
138 pub symbol: String,
139 pub exchange: String,
140 pub pi_only: bool,
141 pub tick_size_nano: i64,
142 pub min_trade_qty_nano: i64,
143 pub max_trade_qty_nano: i64,
144 pub qty_increment_nano: i64,
145}
146
147impl CachedSecurityInfo {
148 /// v1.4.89 P2-A: 判断 cache row 是否需要后台 mkt_id refresh.
149 ///
150 /// **场景**: SQLite 历史行 (v1.4.77 Phase D 之前 upsert) 没有 `mkt_id` 字段,
151 /// 读取时默认为 0. 这导致 cache-first 路由走 heuristic fallback (坑 #35).
152 ///
153 /// 返 `true` 即需要 refresh; 如果 `mkt_id == 0 && market != 0`, 是历史行
154 /// (有基本 market 但缺 sub-exchange 精度).
155 ///
156 /// **使用**: `StaticDataCache::mark_stale_mkt_id` 在 get 时 opportunistic
157 /// mark stale, 背景 worker 批量 CMD 20106 refresh.
158 #[must_use]
159 pub fn needs_mkt_id_refresh(&self) -> bool {
160 self.mkt_id == 0 && self.market != 0
161 }
162
163 /// v1.4.106 codex 1148 F7: 是否含完整静态字段 (基于 source). 调用方需要
164 /// `list_time` / `warrnt_stock_owner` / `exch_type` 等字段时, 应先 check
165 /// 此 flag, false 时触发 backend 完整 refresh 或返 partial marker。
166 #[must_use]
167 pub fn is_complete(&self) -> bool {
168 self.source.is_complete()
169 }
170}
171
172/// 交易日
173#[derive(Debug, Clone)]
174pub struct CachedTradeDate {
175 pub time: String,
176 pub timestamp: f64,
177}
178
179/// 板块信息
180#[derive(Debug, Clone)]
181pub struct CachedPlateInfo {
182 pub market: i32,
183 pub code: String,
184 pub name: String,
185 pub plate_type: i32,
186}
187
188/// 静态数据缓存
189pub struct StaticDataCache {
190 /// 股票静态信息: "market_code" → info
191 ///
192 /// **v1.4.106 codex 1148 F9**: 对外 read-only。生产代码不应直接 `.insert()`
193 /// 这个 DashMap (会导致 `id_to_key` / `owner_to_warrants` 三索引不一致),
194 /// 必须走 `upsert_full_security_info` / `upsert_basic_security_info` /
195 /// `delete_security_info` 统一写入口。`pub` 仅为 backwards-compat:
196 /// 既有迭代 caller (`snapshot.rs` for-iter `.iter()`) 仍能 read-only 遍历。
197 pub securities: DashMap<String, CachedSecurityInfo>,
198 /// stock_id → "market_code" key (反向映射,用于推送时查找)
199 ///
200 /// **v1.4.106 codex 1148 F9**: 对外 read-only (同 `securities`)。
201 pub id_to_key: DashMap<u64, String>,
202 /// 期货主连/连续合约 push 路由别名: real/origin stock_id → main-link sec_key set.
203 ///
204 /// Backend 的实时 push 可能以真实月份合约 stock_id 下发,而客户端订阅的是
205 /// `HSImain` / `NQmain` 这类主连 symbol。C++ 的 QotSubscribe 用 stock_id
206 /// 级别的主连关系做回投;Rust 这里保留同样的数据驱动关系,来源仅限
207 /// stock-list 下发的 `origin_id` / `zhuli_id` 字段,不按 code 字符串特判。
208 future_main_link_aliases: DashMap<u64, HashSet<String>>,
209 /// Crypto sec_key -> 货币对元数据 (`cc_origin` / `cc_destination`)。
210 crypto_pairs: DashMap<String, CryptoPairInfo>,
211 /// `(broker_id, symbol, exchange)` -> crypto 交易配置。
212 crypto_trade_configs: DashMap<String, CryptoTradeConfig>,
213 /// 交易日: "market:year-month" → Vec<TradeDate>
214 pub trade_dates: DashMap<String, Vec<CachedTradeDate>>,
215 /// 板块: "market:plate_type" → Vec<PlateInfo>
216 pub plates: DashMap<String, Vec<CachedPlateInfo>>,
217 /// 窝轮正股 owner_id → 该正股对应的所有窝轮 stock_id 集合
218 ///
219 /// **v1.4.106 codex 1148 F6**: value 从 `Vec<u64>` 改为 `HashSet<u64>` 防
220 /// 重复 (SQLite reload + stock-list re-sync 同 warrant 多次 push 不会再重复).
221 /// stock-list `delete_flag` 时**反向索引清理**: 旧 owner 下移除旧 warrant
222 /// (`delete_security_info` 内部维护), update 时也维护。
223 pub owner_to_warrants: RwLock<std::collections::HashMap<u64, HashSet<u64>>>,
224
225 /// v1.4.89 P2-A: 需要 mkt_id refresh 的 cache key 集合.
226 ///
227 /// Callers `get_security_info_trigger_refresh` 在返 info 前检查
228 /// `info.needs_mkt_id_refresh()`, 是则 mark key 到这里. 背景 worker
229 /// (gateway bridge) 定期 `drain_stale_mkt_ids()` 批量 CMD 20106 refresh.
230 ///
231 /// 用 DashMap<String, ()> 替代 HashSet<String> 免 lock 竞争.
232 pub stale_mkt_ids: DashMap<String, ()>,
233
234 /// v1.4.89 P2-A: mkt_id refresh 统计计数, 用于 metrics 观察.
235 ///
236 /// - `mkt_id_refresh_marked_total`: 累积 mark stale 次数
237 /// - `mkt_id_refresh_done_total`: 累积 backend CMD 20106 成功 refresh 次数
238 /// - `mkt_id_refresh_failed_total`: 累积 refresh failure 次数
239 pub mkt_id_refresh_marked_total: AtomicU64,
240 pub mkt_id_refresh_done_total: AtomicU64,
241 pub mkt_id_refresh_failed_total: AtomicU64,
242}
243
244impl StaticDataCache {
245 pub fn new() -> Self {
246 Self {
247 securities: DashMap::new(),
248 id_to_key: DashMap::new(),
249 future_main_link_aliases: DashMap::new(),
250 crypto_pairs: DashMap::new(),
251 crypto_trade_configs: DashMap::new(),
252 trade_dates: DashMap::new(),
253 plates: DashMap::new(),
254 owner_to_warrants: RwLock::new(std::collections::HashMap::new()),
255 stale_mkt_ids: DashMap::new(),
256 mkt_id_refresh_marked_total: AtomicU64::new(0),
257 mkt_id_refresh_done_total: AtomicU64::new(0),
258 mkt_id_refresh_failed_total: AtomicU64::new(0),
259 }
260 }
261
262 /// v1.4.89 P2-A: 取 cache info 同时机会性 mark stale (若 mkt_id=0).
263 ///
264 /// 返 Some(info) 如果 cache hit (无论是否 stale). 返 None 如果 miss.
265 ///
266 /// 调 `info.needs_mkt_id_refresh()` 判 stale → mark `stale_mkt_ids`,
267 /// bump `mkt_id_refresh_marked_total` counter. 用 DashMap::insert 幂等
268 /// (同 key 重入不 double mark 但会 bump counter — 可接受).
269 ///
270 /// 替代 `get_security_info` 的推荐路径; 老 method 保留作 lookup-only 接口.
271 pub fn get_security_info_trigger_refresh(&self, key: &str) -> Option<CachedSecurityInfo> {
272 let info = self.get_security_info(key)?;
273 if info.needs_mkt_id_refresh() {
274 self.mark_stale_mkt_id(key);
275 }
276 Some(info)
277 }
278
279 /// v1.4.89 P2-A: 显式 mark key 需要 mkt_id refresh.
280 ///
281 /// 幂等: 同 key 可重入. Counter `mkt_id_refresh_marked_total` 每次都 bump
282 /// (用作 metrics 观察 heuristic fallback 触发频率).
283 pub fn mark_stale_mkt_id(&self, key: &str) {
284 self.stale_mkt_ids.insert(key.to_string(), ());
285 self.mkt_id_refresh_marked_total
286 .fetch_add(1, Ordering::Relaxed);
287 }
288
289 /// v1.4.89 P2-A: drain 所有 stale keys, 清空集合, 返 Vec (给 backend worker
290 /// 批量 CMD 20106 refresh).
291 ///
292 /// 背景 worker 用法 (伪码):
293 /// ```text
294 /// loop {
295 /// sleep(Duration::from_secs(60)).await;
296 /// let stale = cache.drain_stale_mkt_ids();
297 /// if stale.is_empty() { continue; }
298 /// for chunk in stale.chunks(50) {
299 /// // CMD 20106 SecuritiesReq for chunk
300 /// // on success: cache.update_mkt_id(key, new_mkt_id)
301 /// // + cache.record_mkt_id_refresh_done()
302 /// // on failure: cache.record_mkt_id_refresh_failed()
303 /// }
304 /// }
305 /// ```
306 pub fn drain_stale_mkt_ids(&self) -> Vec<String> {
307 let keys: Vec<String> = self.stale_mkt_ids.iter().map(|e| e.key().clone()).collect();
308 for k in &keys {
309 self.stale_mkt_ids.remove(k);
310 }
311 keys
312 }
313
314 /// v1.4.89 P2-A: 更新已 cache row 的 mkt_id (refresh success 时调).
315 ///
316 /// 只改 mkt_id 字段, 其他字段保留 (info 可能有 SQLite 里更精准的 lot_size /
317 /// list_time 等). 若 key 不在 cache (已被 evict), no-op.
318 pub fn update_mkt_id(&self, key: &str, new_mkt_id: u32) -> bool {
319 if let Some(mut entry) = self.securities.get_mut(key) {
320 entry.mkt_id = new_mkt_id;
321 self.mkt_id_refresh_done_total
322 .fetch_add(1, Ordering::Relaxed);
323 true
324 } else {
325 false
326 }
327 }
328
329 /// v1.4.89 P2-A: 记录 refresh failure (不改 cache row, 让下次 drain 重试).
330 pub fn record_mkt_id_refresh_failed(&self) {
331 self.mkt_id_refresh_failed_total
332 .fetch_add(1, Ordering::Relaxed);
333 }
334
335 /// v1.4.89 P2-A: 当前 stale keys 数 (给 observability / debug).
336 #[must_use]
337 pub fn stale_mkt_ids_count(&self) -> usize {
338 self.stale_mkt_ids.len()
339 }
340
341 /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 完整静态行 (`StockListFull` /
342 /// `Bootstrap` source)。同步维护 `securities` + `id_to_key` + 若有 owner
343 /// 还更新 `owner_to_warrants`。**自动 dedup**: 已有同 key 但 `warrnt_stock_owner`
344 /// 变化时, 旧 owner 下移除该 warrant id, 新 owner 下添加。
345 ///
346 /// 替代生产代码里手动调 `securities.insert()` + `id_to_key.insert()` +
347 /// `add_warrant_owner()` 三步骤的 pattern。
348 ///
349 /// **不允许** caller 把不完整的 source 标 `StockListFull`(若 `info.source ==
350 /// OnDemandBasic`, 用 `upsert_basic_security_info` 而非本 fn)。
351 pub fn upsert_full_security_info(&self, key: &str, info: CachedSecurityInfo) {
352 debug_assert!(
353 info.source.is_complete(),
354 "upsert_full_security_info called with non-complete source ({:?})",
355 info.source
356 );
357 self.upsert_with_owner_index_maintenance(key, info);
358 }
359
360 /// 写入 stock-list 下发的 crypto 货币对元数据。
361 pub fn upsert_crypto_pair_info(&self, key: &str, pair: CryptoPairInfo) {
362 if pair.cc_origin.is_empty() && pair.cc_destination.is_empty() {
363 self.crypto_pairs.remove(key);
364 } else {
365 self.crypto_pairs.insert(key.to_string(), pair);
366 }
367 }
368
369 /// 读取 crypto 货币对元数据。
370 pub fn get_crypto_pair_info(&self, key: &str) -> Option<CryptoPairInfo> {
371 self.crypto_pairs.get(key).map(|v| v.clone())
372 }
373
374 fn crypto_trade_config_key(broker_id: u32, symbol: &str, exchange: &str) -> String {
375 format!(
376 "{broker_id}:{}:{}",
377 symbol.trim().to_ascii_uppercase(),
378 exchange.trim().to_ascii_uppercase()
379 )
380 }
381
382 /// 用 backend CMD20102 拉回的配置替换某个 broker 的 crypto trade config。
383 pub fn set_crypto_trade_configs_for_broker(
384 &self,
385 broker_id: u32,
386 configs: Vec<CryptoTradeConfig>,
387 ) {
388 let prefix = format!("{broker_id}:");
389 self.crypto_trade_configs
390 .retain(|key, _| !key.starts_with(&prefix));
391 for config in configs {
392 if config.symbol.trim().is_empty() || config.exchange.trim().is_empty() {
393 continue;
394 }
395 let key = Self::crypto_trade_config_key(broker_id, &config.symbol, &config.exchange);
396 self.crypto_trade_configs.insert(key, config);
397 }
398 }
399
400 /// 查询某个 crypto symbol 的交易配置。
401 pub fn get_crypto_trade_config(
402 &self,
403 broker_id: u32,
404 symbol: &str,
405 exchange: &str,
406 ) -> Option<CryptoTradeConfig> {
407 let key = Self::crypto_trade_config_key(broker_id, symbol, exchange);
408 self.crypto_trade_configs.get(&key).map(|v| v.clone())
409 }
410
411 /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 部分静态行 (`OnDemandBasic`
412 /// source)。同步维护 `securities` + `id_to_key`, **不动** `owner_to_warrants`
413 /// (因为 OnDemandBasic 不含 `warrnt_stock_owner` 字段, value 必为 0)。
414 ///
415 /// `info.source` 必须是 `OnDemandBasic` (debug_assert)。
416 pub fn upsert_basic_security_info(&self, key: &str, info: CachedSecurityInfo) {
417 debug_assert!(
418 !info.source.is_complete(),
419 "upsert_basic_security_info called with complete source ({:?}); use upsert_full",
420 info.source
421 );
422 debug_assert_eq!(
423 info.warrnt_stock_owner, 0,
424 "OnDemandBasic must have warrnt_stock_owner=0 (caller didn't query the field)"
425 );
426 // 不维护 owner_to_warrants (basic 没这个字段).
427 // 但仍需检查既有完整行的 owner 是否会被 basic 错误覆盖.
428 // 策略: 如果 key 已有 StockListFull / Bootstrap 行, 不让 basic 行覆盖 (full
429 // 数据更完整). 这处理 case "subscribe on-demand 后, stock-list sync 来时
430 // 应该 prevail; 反过来不行".
431 if let Some(existing) = self.securities.get(key) {
432 if existing.is_complete() {
433 tracing::debug!(
434 key,
435 "upsert_basic_security_info skipped: existing complete row prevails"
436 );
437 drop(existing);
438 return;
439 }
440 drop(existing);
441 }
442 if let Some(old_info) = self.securities.get(key).map(|r| r.clone()) {
443 self.remove_future_main_link_aliases(key, &old_info);
444 }
445 self.securities.insert(key.to_string(), info.clone());
446 self.id_to_key.insert(info.stock_id, key.to_string());
447 self.add_future_main_link_aliases(key, &info);
448 }
449
450 /// v1.4.106 codex 1148 F9 (P3): 删除 cache row + 同步清三个索引
451 /// (`securities`, `id_to_key`, `owner_to_warrants`)。
452 ///
453 /// 用于 stock-list `delete_flag = true` 场景。
454 /// 返 `true` 如果 row 存在并被删除, `false` 如果 stock_id 不在 `id_to_key`。
455 pub fn delete_security_info(&self, stock_id: u64) -> bool {
456 let Some((_, key)) = self.id_to_key.remove(&stock_id) else {
457 return false;
458 };
459 // 拿被删 row 的 owner (用于反向索引清理), 如果 key 已不在 securities, owner = 0
460 let old_owner = self
461 .securities
462 .get(&key)
463 .map(|r| r.warrnt_stock_owner)
464 .unwrap_or(0);
465 if let Some(old_info) = self.securities.get(&key).map(|r| r.clone()) {
466 self.remove_future_main_link_aliases(&key, &old_info);
467 }
468 self.securities.remove(&key);
469 self.crypto_pairs.remove(&key);
470 // F6: stock-list delete 时把 warrant 从 old_owner 反向索引里清掉
471 if old_owner != 0
472 && let Ok(mut map) = self.owner_to_warrants.write()
473 && let Some(set) = map.get_mut(&old_owner)
474 {
475 set.remove(&stock_id);
476 if set.is_empty() {
477 map.remove(&old_owner);
478 }
479 }
480 // F6: 该 stock_id 自己也可能是某 owner — 清掉它作为 owner 的 entry
481 if let Ok(mut map) = self.owner_to_warrants.write() {
482 map.remove(&stock_id);
483 }
484 true
485 }
486
487 /// 内部 helper: F9 unified upsert with owner-index maintenance (F6).
488 fn upsert_with_owner_index_maintenance(&self, key: &str, info: CachedSecurityInfo) {
489 // 先看老 row 是否存在 + 旧 owner 是什么 (F6: 如果 owner 变了要清旧索引)
490 let old_info = self.securities.get(key).map(|r| r.clone());
491 let old_owner = old_info.as_ref().map(|r| r.warrnt_stock_owner).unwrap_or(0);
492 let new_owner = info.warrnt_stock_owner;
493
494 if let Some(old) = old_info.as_ref() {
495 self.remove_future_main_link_aliases(key, old);
496 }
497
498 // 写 securities + id_to_key
499 let stock_id = info.stock_id;
500 self.securities.insert(key.to_string(), info.clone());
501 self.id_to_key.insert(stock_id, key.to_string());
502 self.add_future_main_link_aliases(key, &info);
503
504 // F6: 维护反向索引
505 if old_owner != new_owner {
506 // owner 变了 (含 0→X / X→Y / X→0)
507 if let Ok(mut map) = self.owner_to_warrants.write() {
508 if old_owner != 0
509 && let Some(set) = map.get_mut(&old_owner)
510 {
511 set.remove(&stock_id);
512 if set.is_empty() {
513 map.remove(&old_owner);
514 }
515 }
516 if new_owner != 0 {
517 map.entry(new_owner).or_default().insert(stock_id);
518 }
519 }
520 } else if new_owner != 0 {
521 // owner 未变, 但同一 owner 下重 add (idempotent due to HashSet).
522 if let Ok(mut map) = self.owner_to_warrants.write() {
523 map.entry(new_owner).or_default().insert(stock_id);
524 }
525 }
526 }
527
528 fn future_main_link_target_ids(info: &CachedSecurityInfo) -> Vec<u64> {
529 let mut ids = Vec::with_capacity(2);
530 for target in [info.future_origin_id, info.zhuli_id] {
531 if target != 0 && target != info.stock_id && !ids.contains(&target) {
532 ids.push(target);
533 }
534 }
535 ids
536 }
537
538 fn add_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
539 for target in Self::future_main_link_target_ids(info) {
540 self.future_main_link_aliases
541 .entry(target)
542 .or_default()
543 .insert(key.to_string());
544 }
545 }
546
547 fn remove_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
548 for target in Self::future_main_link_target_ids(info) {
549 if let Some(mut aliases) = self.future_main_link_aliases.get_mut(&target) {
550 aliases.remove(key);
551 let empty = aliases.is_empty();
552 drop(aliases);
553 if empty {
554 self.future_main_link_aliases.remove(&target);
555 }
556 }
557 }
558 }
559
560 /// 查询某个 backend push stock_id 对应的主连/连续合约 sec_key 别名。
561 ///
562 /// 只返回 stock-list 明确下发 `origin_id` / `zhuli_id` 关系的 key;不做
563 /// `HSImain` 等字符串启发式。调用方通常先按 `id_to_key` 处理真实合约,
564 /// 再把这里返回的 main-link key 一并投递。
565 #[must_use]
566 pub fn get_future_main_link_alias_keys(&self, stock_id: u64) -> Vec<String> {
567 let Some(aliases) = self.future_main_link_aliases.get(&stock_id) else {
568 return Vec::new();
569 };
570 let mut keys: Vec<String> = aliases.iter().cloned().collect();
571 keys.sort();
572 keys
573 }
574
575 /// 查询 backend push stock_id 的所有 quote 投递目标。
576 ///
577 /// 顺序保持为:真实 stock_id 对应 key(如有)优先,然后是 stock-list
578 /// `origin_id` / `zhuli_id` 下发的主连/连续合约别名 key。调用方不再直接
579 /// 读取 `id_to_key` 和 `future_main_link_aliases` 两个索引,避免 alias 逻辑
580 /// 分散在 push parser 里。
581 #[must_use]
582 pub fn quote_push_targets_for_stock_id(
583 &self,
584 stock_id: u64,
585 ) -> Vec<(String, CachedSecurityInfo)> {
586 let mut targets = Vec::new();
587
588 if let Some(sec_key_ref) = self.id_to_key.get(&stock_id) {
589 let sec_key = sec_key_ref.clone();
590 drop(sec_key_ref);
591 if let Some(info) = self.get_security_info(&sec_key) {
592 targets.push((sec_key, info));
593 }
594 }
595
596 for alias_key in self.get_future_main_link_alias_keys(stock_id) {
597 if targets.iter().any(|(key, _)| key == &alias_key) {
598 continue;
599 }
600 if let Some(info) = self.get_security_info(&alias_key) {
601 targets.push((alias_key, info));
602 }
603 }
604
605 targets
606 }
607
608 /// **v1.4.110 Phase 2 Slice 5**: broker-aware 推送投递目标查询.
609 ///
610 /// 对应 push parser 从 `SecurityQuote.broker_id` 重建 broker-aware
611 /// `QotStockKey` 的路径 (对齐 C++ `NNBiz_Qot_PushQot.cpp:220-269`).
612 ///
613 /// 语义:
614 /// - `broker_id = None` (C++ `m_hasBroker=false`): 只查 no-broker key,
615 /// 返 `QotSecurityKey::no_broker(public_sec_key, stock_id)` 与
616 /// `quote_push_targets_for_stock_id` 等价
617 /// - `broker_id = Some(N)` (C++ `m_hasBroker=true`): 沿 stock_id 反向
618 /// 找到 public_sec_key, 再用 `QotSecurityKey::from_broker_id(...)`
619 /// 构造 broker-aware key. 该 broker 下 cache 写入独立桶
620 /// `"market_code@b{N}"`, 不污染同 stock_id 其他 broker 的 cache.
621 ///
622 /// **Phase 2 默认**: backend 当前对普通股 push 仍不下发 broker_id (=None),
623 /// 与升级前行为完全等价. crypto multi-broker push 会带 broker_id,
624 /// Phase 3 reader caller (handler `GetBasicQot` 等) 替换走 `_broker`
625 /// 版本后, broker-aware cache 才被消费.
626 #[must_use]
627 pub fn quote_push_targets_for_stock_key(
628 &self,
629 stock_id: u64,
630 broker_id: Option<std::num::NonZeroU32>,
631 ) -> Vec<(futu_core::qot_stock_key::QotSecurityKey, CachedSecurityInfo)> {
632 // 先用 no-broker 路径查 stock_id → (public_sec_key, info) 列表
633 // (id_to_key + future_main_link_aliases). broker_id 注入到返 key
634 // 不改变 lookup 逻辑.
635 let bare = self.quote_push_targets_for_stock_id(stock_id);
636 bare.into_iter()
637 .map(|(public_sec_key, info)| {
638 let key = match broker_id {
639 Some(nz) => futu_core::qot_stock_key::QotSecurityKey::from_broker_id(
640 public_sec_key,
641 stock_id,
642 nz.get(),
643 ),
644 None => futu_core::qot_stock_key::QotSecurityKey::no_broker(
645 public_sec_key,
646 stock_id,
647 ),
648 };
649 (key, info)
650 })
651 .collect()
652 }
653
654 /// **deprecated**: 改用 `upsert_full_security_info` /
655 /// `upsert_basic_security_info` 维护三索引一致性。本 method 仅写
656 /// `securities`, **不**更新 `id_to_key` / `owner_to_warrants` — 直接调
657 /// 会产生半索引行 (按 code 查得到, 按 stock_id 反向查不到)。
658 ///
659 /// v1.4.106 codex 1148 F9 起仅保留作 unit test / bench 兼容。生产代码
660 /// 禁用 (review-time grep `set_security_info` 须 0 命中 in `crates/futu-*/src/`)。
661 #[deprecated(
662 since = "1.4.106",
663 note = "use upsert_full_security_info / upsert_basic_security_info / delete_security_info"
664 )]
665 pub fn set_security_info(&self, key: &str, info: CachedSecurityInfo) {
666 self.securities.insert(key.to_string(), info);
667 }
668
669 pub fn get_security_info(&self, key: &str) -> Option<CachedSecurityInfo> {
670 self.securities.get(key).map(|v| v.clone())
671 }
672
673 /// 通过 stock_id 查找股票信息 (使用 id_to_key 反向映射)
674 pub fn get_security_info_by_stock_id(&self, stock_id: u64) -> Option<CachedSecurityInfo> {
675 let key = self.id_to_key.get(&stock_id)?;
676 self.securities.get(key.value().as_str()).map(|v| v.clone())
677 }
678
679 /// 添加窝轮→正股的映射关系
680 ///
681 /// **v1.4.106 codex 1148 F6**: HashSet 自动去重, 重复 add 同 (warrant, owner)
682 /// 是 idempotent — SQLite reload + stock-list sync 不会重复入。
683 pub fn add_warrant_owner(&self, warrant_stock_id: u64, owner_stock_id: u64) {
684 if owner_stock_id == 0 {
685 return;
686 }
687 if let Ok(mut map) = self.owner_to_warrants.write() {
688 map.entry(owner_stock_id)
689 .or_default()
690 .insert(warrant_stock_id);
691 }
692 }
693
694 /// 通过正股 ID 搜索该正股的所有窝轮
695 ///
696 /// **v1.4.106 codex 1148 F6**: 内部 HashSet, 返 Vec (call site backward
697 /// compatible)。返序非确定 (HashSet 不保留 insertion order); call site 若
698 /// 需要稳定序应自己 sort。
699 #[must_use]
700 pub fn search_warrants_by_owner(&self, owner_stock_id: u64) -> Vec<u64> {
701 match self.owner_to_warrants.read() {
702 Ok(map) => map
703 .get(&owner_stock_id)
704 .map(|set| set.iter().copied().collect())
705 .unwrap_or_default(),
706 _ => Vec::new(),
707 }
708 }
709}
710
711impl Default for StaticDataCache {
712 fn default() -> Self {
713 Self::new()
714 }
715}
716
717#[cfg(test)]
718mod tests;