Skip to main content

futu_cache/
static_data.rs

1// 静态数据缓存:股票列表、经纪商、节假日、停牌
2
3use dashmap::DashMap;
4use std::collections::HashSet;
5use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
6use std::sync::{Arc, RwLock};
7
8mod readiness;
9mod types;
10
11pub use readiness::{StaticDataReadiness, StockListSyncStatus};
12pub use types::{
13    CachedPlateInfo, CachedSecurityInfo, CachedTradeDate, CryptoPairInfo, CryptoTradeConfig,
14    OptionContractInfo, SecurityInfoSource,
15};
16
17/// 静态数据缓存
18pub struct StaticDataCache {
19    /// 股票静态信息: "market_code" → info.
20    ///
21    /// 三索引写入只能通过 `upsert_full_security_info` /
22    /// `upsert_basic_security_info` / `delete_security_info`,避免 caller
23    /// 绕过 `id_to_key` / `owner_to_warrants` 维护。
24    securities: DashMap<String, Arc<CachedSecurityInfo>>,
25    /// stock_id → "market_code" key (反向映射,用于推送时查找)
26    ///
27    id_to_key: DashMap<u64, String>,
28    /// 期货主连/连续合约 push 路由别名: real/origin stock_id → main-link sec_key set.
29    ///
30    /// Backend 的实时 push 可能以真实月份合约 stock_id 下发,而客户端订阅的是
31    /// `HSImain` / `NQmain` 这类主连 symbol。C++ 的 QotSubscribe 用 stock_id
32    /// 级别的主连关系做回投;Rust 这里保留同样的数据驱动关系,来源仅限
33    /// stock-list 下发的 `origin_id` / `zhuli_id` 字段,不按 code 字符串特判。
34    future_main_link_aliases: DashMap<u64, HashSet<String>>,
35    /// option stock_id -> contract metadata from CMD20106 `OptionResultInfo`.
36    option_contracts: DashMap<u64, OptionContractInfo>,
37    /// Crypto sec_key -> 货币对元数据 (`cc_origin` / `cc_destination`)。
38    crypto_pairs: DashMap<String, CryptoPairInfo>,
39    /// `(broker_id, symbol, exchange)` -> crypto 交易配置。
40    crypto_trade_configs: DashMap<String, CryptoTradeConfig>,
41    /// 交易日: "market:year-month" → Vec<TradeDate>
42    pub trade_dates: DashMap<String, Vec<CachedTradeDate>>,
43    /// 板块: "market:plate_type" → Vec<PlateInfo>
44    pub plates: DashMap<String, Vec<CachedPlateInfo>>,
45    /// 窝轮正股 owner_id → 该正股对应的所有窝轮 stock_id 集合
46    ///
47    /// **v1.4.106 codex 1148 F6**: value 从 `Vec<u64>` 改为 `HashSet<u64>` 防
48    /// 重复 (SQLite reload + stock-list re-sync 同 warrant 多次 push 不会再重复).
49    /// stock-list `delete_flag` 时**反向索引清理**: 旧 owner 下移除旧 warrant
50    /// (`delete_security_info` 内部维护), update 时也维护。
51    owner_to_warrants: RwLock<std::collections::HashMap<u64, HashSet<u64>>>,
52
53    /// v1.4.89 P2-A: 需要 mkt_id refresh 的 cache key 集合.
54    ///
55    /// Callers `get_security_info_trigger_refresh` 在返 info 前检查
56    /// `info.needs_mkt_id_refresh()`, 是则 mark key 到这里. 背景 worker
57    /// (gateway bridge) 定期 `drain_stale_mkt_ids()` 批量 CMD 20106 refresh.
58    ///
59    /// 用 DashMap<String, ()> 替代 HashSet<String> 免 lock 竞争.
60    pub stale_mkt_ids: DashMap<String, ()>,
61
62    /// v1.4.89 P2-A: mkt_id refresh 统计计数, 用于 metrics 观察.
63    ///
64    /// - `mkt_id_refresh_marked_total`: 累积 mark stale 次数
65    /// - `mkt_id_refresh_done_total`: 累积 backend CMD 20106 成功 refresh 次数
66    /// - `mkt_id_refresh_failed_total`: 累积 refresh failure 次数
67    pub mkt_id_refresh_marked_total: AtomicU64,
68    pub mkt_id_refresh_done_total: AtomicU64,
69    pub mkt_id_refresh_failed_total: AtomicU64,
70
71    /// Stock-list SQLite bootstrap / backend sync readiness diagnostics.
72    ///
73    /// C++ OpenD opens and validates its SecList DB before APIServer becomes
74    /// ready; Rust also needs a concrete readiness signal so static-data
75    /// comparisons do not race the background CMD6746 worker on cold start.
76    stock_list_first_sync_done: AtomicBool,
77    stock_list_sync_started_total: AtomicU64,
78    stock_list_sync_finished_total: AtomicU64,
79    stock_list_sync_failed_total: AtomicU64,
80    stock_list_sync_recoverable_retry_total: AtomicU64,
81    stock_list_sync_zero_delta_total: AtomicU64,
82    stock_list_converged: AtomicBool,
83    stock_list_sync_last_version: AtomicU64,
84    stock_list_sync_last_total_stocks: AtomicU64,
85    stock_list_sync_last_cached_count: AtomicU64,
86    stock_list_sync_last_finished_ms: AtomicU64,
87}
88
89impl StaticDataCache {
90    pub fn new() -> Self {
91        Self {
92            securities: DashMap::new(),
93            id_to_key: DashMap::new(),
94            future_main_link_aliases: DashMap::new(),
95            option_contracts: DashMap::new(),
96            crypto_pairs: DashMap::new(),
97            crypto_trade_configs: DashMap::new(),
98            trade_dates: DashMap::new(),
99            plates: DashMap::new(),
100            owner_to_warrants: RwLock::new(std::collections::HashMap::new()),
101            stale_mkt_ids: DashMap::new(),
102            mkt_id_refresh_marked_total: AtomicU64::new(0),
103            mkt_id_refresh_done_total: AtomicU64::new(0),
104            mkt_id_refresh_failed_total: AtomicU64::new(0),
105            stock_list_first_sync_done: AtomicBool::new(false),
106            stock_list_sync_started_total: AtomicU64::new(0),
107            stock_list_sync_finished_total: AtomicU64::new(0),
108            stock_list_sync_failed_total: AtomicU64::new(0),
109            stock_list_sync_recoverable_retry_total: AtomicU64::new(0),
110            stock_list_sync_zero_delta_total: AtomicU64::new(0),
111            stock_list_converged: AtomicBool::new(false),
112            stock_list_sync_last_version: AtomicU64::new(0),
113            stock_list_sync_last_total_stocks: AtomicU64::new(0),
114            stock_list_sync_last_cached_count: AtomicU64::new(0),
115            stock_list_sync_last_finished_ms: AtomicU64::new(0),
116        }
117    }
118
119    pub fn record_stock_list_sync_started(&self) {
120        self.stock_list_sync_started_total
121            .fetch_add(1, Ordering::Relaxed);
122    }
123
124    pub fn record_stock_list_sync_finished(
125        &self,
126        version: u64,
127        total_stocks: u64,
128        cached_count: u64,
129        finished_ms: u64,
130    ) {
131        self.stock_list_first_sync_done
132            .store(true, Ordering::Release);
133        // `converged` is a sticky cold-start readiness signal: after the first
134        // zero-delta pass proves catch-up reached backend head, later normal
135        // tiny deltas must not move static queries back to startup loading.
136        if total_stocks == 0 {
137            self.stock_list_sync_zero_delta_total
138                .fetch_add(1, Ordering::Relaxed);
139            self.stock_list_converged.store(true, Ordering::Release);
140        }
141        self.stock_list_sync_finished_total
142            .fetch_add(1, Ordering::Relaxed);
143        self.stock_list_sync_last_version
144            .store(version, Ordering::Relaxed);
145        self.stock_list_sync_last_total_stocks
146            .store(total_stocks, Ordering::Relaxed);
147        self.stock_list_sync_last_cached_count
148            .store(cached_count, Ordering::Relaxed);
149        self.stock_list_sync_last_finished_ms
150            .store(finished_ms, Ordering::Relaxed);
151    }
152
153    pub fn record_stock_list_sync_failed(&self) {
154        self.stock_list_sync_failed_total
155            .fetch_add(1, Ordering::Relaxed);
156    }
157
158    pub fn record_stock_list_sync_recoverable_retry(&self) {
159        self.stock_list_sync_recoverable_retry_total
160            .fetch_add(1, Ordering::Relaxed);
161    }
162
163    pub fn stock_list_sync_status(&self) -> StockListSyncStatus {
164        let finished_ms = self
165            .stock_list_sync_last_finished_ms
166            .load(Ordering::Relaxed);
167        StockListSyncStatus {
168            first_sync_done: self.stock_list_first_sync_done.load(Ordering::Acquire),
169            started_total: self.stock_list_sync_started_total.load(Ordering::Relaxed),
170            finished_total: self.stock_list_sync_finished_total.load(Ordering::Relaxed),
171            failed_total: self.stock_list_sync_failed_total.load(Ordering::Relaxed),
172            recoverable_retry_total: self
173                .stock_list_sync_recoverable_retry_total
174                .load(Ordering::Relaxed),
175            zero_delta_total: self
176                .stock_list_sync_zero_delta_total
177                .load(Ordering::Relaxed),
178            converged: self.stock_list_converged.load(Ordering::Acquire),
179            last_version: self.stock_list_sync_last_version.load(Ordering::Relaxed),
180            last_total_stocks: self
181                .stock_list_sync_last_total_stocks
182                .load(Ordering::Relaxed),
183            last_cached_count: self
184                .stock_list_sync_last_cached_count
185                .load(Ordering::Relaxed),
186            last_finished_ms: (finished_ms > 0).then_some(finished_ms),
187        }
188    }
189
190    pub fn security_info_count(&self) -> usize {
191        self.securities.len()
192    }
193
194    pub fn stock_list_readiness(&self) -> StaticDataReadiness {
195        self.stock_list_sync_status()
196            .readiness_for_security_count(self.security_info_count())
197    }
198
199    /// v1.4.89 P2-A: 取 cache info 同时机会性 mark stale (若 mkt_id=0).
200    ///
201    /// 返 Some(info) 如果 cache hit (无论是否 stale). 返 None 如果 miss.
202    ///
203    /// 调 `info.needs_mkt_id_refresh()` 判 stale → mark `stale_mkt_ids`,
204    /// bump `mkt_id_refresh_marked_total` counter. 用 DashMap::insert 幂等
205    /// (同 key 重入不 double mark 但会 bump counter — 可接受).
206    ///
207    /// 替代 `get_security_info` 的推荐路径; 老 method 保留作 lookup-only 接口.
208    pub fn get_security_info_trigger_refresh(&self, key: &str) -> Option<CachedSecurityInfo> {
209        let info = self.get_security_info(key)?;
210        if info.needs_mkt_id_refresh() {
211            self.mark_stale_mkt_id(key);
212        }
213        Some(info)
214    }
215
216    /// v1.4.89 P2-A: 显式 mark key 需要 mkt_id refresh.
217    ///
218    /// 幂等: 同 key 可重入. Counter `mkt_id_refresh_marked_total` 每次都 bump
219    /// (用作 metrics 观察 heuristic fallback 触发频率).
220    pub fn mark_stale_mkt_id(&self, key: &str) {
221        self.stale_mkt_ids.insert(key.to_string(), ());
222        self.mkt_id_refresh_marked_total
223            .fetch_add(1, Ordering::Relaxed);
224    }
225
226    /// v1.4.89 P2-A: drain 所有 stale keys, 清空集合, 返 Vec (给 backend worker
227    /// 批量 CMD 20106 refresh).
228    ///
229    /// 背景 worker 用法 (伪码):
230    /// ```text
231    /// loop {
232    ///     sleep(Duration::from_secs(60)).await;
233    ///     let stale = cache.drain_stale_mkt_ids();
234    ///     if stale.is_empty() { continue; }
235    ///     for chunk in stale.chunks(50) {
236    ///         // CMD 20106 SecuritiesReq for chunk
237    ///         // on success: cache.update_mkt_id(key, new_mkt_id)
238    ///         //              + cache.record_mkt_id_refresh_done()
239    ///         // on failure: cache.record_mkt_id_refresh_failed()
240    ///     }
241    /// }
242    /// ```
243    pub fn drain_stale_mkt_ids(&self) -> Vec<String> {
244        let keys: Vec<String> = self.stale_mkt_ids.iter().map(|e| e.key().clone()).collect();
245        for k in &keys {
246            self.stale_mkt_ids.remove(k);
247        }
248        keys
249    }
250
251    /// v1.4.89 P2-A: 更新已 cache row 的 mkt_id (refresh success 时调).
252    ///
253    /// 只改 mkt_id 字段, 其他字段保留 (info 可能有 SQLite 里更精准的 lot_size /
254    /// list_time 等). 若 key 不在 cache (已被 evict), no-op.
255    pub fn update_mkt_id(&self, key: &str, new_mkt_id: u32) -> bool {
256        if let Some(mut entry) = self.securities.get_mut(key) {
257            Arc::make_mut(&mut entry).mkt_id = new_mkt_id;
258            self.mkt_id_refresh_done_total
259                .fetch_add(1, Ordering::Relaxed);
260            true
261        } else {
262            false
263        }
264    }
265
266    /// v1.4.89 P2-A: 记录 refresh failure (不改 cache row, 让下次 drain 重试).
267    pub fn record_mkt_id_refresh_failed(&self) {
268        self.mkt_id_refresh_failed_total
269            .fetch_add(1, Ordering::Relaxed);
270    }
271
272    /// v1.4.89 P2-A: 当前 stale keys 数 (给 observability / debug).
273    #[must_use]
274    pub fn stale_mkt_ids_count(&self) -> usize {
275        self.stale_mkt_ids.len()
276    }
277
278    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 完整静态行 (`StockListFull` /
279    /// `Bootstrap` source)。同步维护 `securities` + `id_to_key` + 若有 owner
280    /// 还更新 `owner_to_warrants`。**自动 dedup**: 已有同 key 但 `warrnt_stock_owner`
281    /// 变化时, 旧 owner 下移除该 warrant id, 新 owner 下添加。
282    ///
283    /// 替代生产代码里手动调 `securities.insert()` + `id_to_key.insert()` +
284    /// `add_warrant_owner()` 三步骤的 pattern。
285    ///
286    /// **不允许** caller 把不完整的 source 标 `StockListFull`(若 `info.source ==
287    /// OnDemandBasic`, 用 `upsert_basic_security_info` 而非本 fn)。
288    pub fn upsert_full_security_info(&self, key: &str, info: CachedSecurityInfo) {
289        debug_assert!(
290            info.source.is_complete(),
291            "upsert_full_security_info called with non-complete source ({:?})",
292            info.source
293        );
294        self.upsert_with_owner_index_maintenance(key, info);
295    }
296
297    /// 写入 stock-list 下发的 crypto 货币对元数据。
298    pub fn upsert_crypto_pair_info(&self, key: &str, pair: CryptoPairInfo) {
299        if pair.cc_origin.is_empty() && pair.cc_destination.is_empty() {
300            self.crypto_pairs.remove(key);
301        } else {
302            self.crypto_pairs.insert(key.to_string(), pair);
303        }
304    }
305
306    /// Cache option contract metadata from CMD20106 `OptionResultInfo`.
307    pub fn set_option_contract_info(&self, stock_id: u64, info: OptionContractInfo) {
308        if stock_id == 0 {
309            return;
310        }
311        self.option_contracts.insert(stock_id, info);
312    }
313
314    /// Read option contract metadata by option stock_id.
315    pub fn get_option_contract_info_by_stock_id(
316        &self,
317        stock_id: u64,
318    ) -> Option<OptionContractInfo> {
319        self.option_contracts
320            .get(&stock_id)
321            .map(|entry| *entry.value())
322    }
323
324    /// 读取 crypto 货币对元数据。
325    pub fn get_crypto_pair_info(&self, key: &str) -> Option<CryptoPairInfo> {
326        self.crypto_pairs.get(key).map(|v| v.clone())
327    }
328
329    fn crypto_trade_config_key(broker_id: u32, symbol: &str, exchange: &str) -> String {
330        format!(
331            "{broker_id}:{}:{}",
332            symbol.trim().to_ascii_uppercase(),
333            exchange.trim().to_ascii_uppercase()
334        )
335    }
336
337    /// 用 backend CMD20102 拉回的配置替换某个 broker 的 crypto trade config。
338    pub fn set_crypto_trade_configs_for_broker(
339        &self,
340        broker_id: u32,
341        configs: Vec<CryptoTradeConfig>,
342    ) {
343        let prefix = format!("{broker_id}:");
344        self.crypto_trade_configs
345            .retain(|key, _| !key.starts_with(&prefix));
346        for config in configs {
347            if config.symbol.trim().is_empty() || config.exchange.trim().is_empty() {
348                continue;
349            }
350            let key = Self::crypto_trade_config_key(broker_id, &config.symbol, &config.exchange);
351            self.crypto_trade_configs.insert(key, config);
352        }
353    }
354
355    /// 查询某个 crypto symbol 的交易配置。
356    pub fn get_crypto_trade_config(
357        &self,
358        broker_id: u32,
359        symbol: &str,
360        exchange: &str,
361    ) -> Option<CryptoTradeConfig> {
362        let key = Self::crypto_trade_config_key(broker_id, symbol, exchange);
363        self.crypto_trade_configs.get(&key).map(|v| v.clone())
364    }
365
366    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 部分静态行 (`OnDemandBasic`
367    /// source)。同步维护 `securities` + `id_to_key`, **不动** `owner_to_warrants`
368    /// (因为 OnDemandBasic 不含 `warrnt_stock_owner` 字段, value 必为 0)。
369    ///
370    /// `info.source` 必须是 `OnDemandBasic` (debug_assert)。
371    pub fn upsert_basic_security_info(&self, key: &str, info: CachedSecurityInfo) {
372        debug_assert!(
373            !info.source.is_complete(),
374            "upsert_basic_security_info called with complete source ({:?}); use upsert_full",
375            info.source
376        );
377        debug_assert_eq!(
378            info.warrnt_stock_owner, 0,
379            "OnDemandBasic must have warrnt_stock_owner=0 (caller didn't query the field)"
380        );
381        // 不维护 owner_to_warrants (basic 没这个字段).
382        // 但仍需检查既有完整行的 owner 是否会被 basic 错误覆盖.
383        // 策略: 如果 key 已有 StockListFull / Bootstrap 行, 不让 basic 行覆盖 (full
384        // 数据更完整). 这处理 case "subscribe on-demand 后, stock-list sync 来时
385        // 应该 prevail; 反过来不行".
386        let old_info = if let Some(existing) = self.securities.get(key) {
387            if existing.is_complete() {
388                tracing::debug!(
389                    key,
390                    "upsert_basic_security_info skipped: existing complete row prevails"
391                );
392                return;
393            }
394            Some(Arc::clone(existing.value()))
395        } else {
396            None
397        };
398        if let Some(old_info) = old_info {
399            self.remove_future_main_link_aliases(key, &old_info);
400        }
401        self.securities
402            .insert(key.to_string(), Arc::new(info.clone()));
403        self.id_to_key.insert(info.stock_id, key.to_string());
404        self.add_future_main_link_aliases(key, &info);
405    }
406
407    /// v1.4.106 codex 1148 F9 (P3): 删除 cache row + 同步清三个索引
408    /// (`securities`, `id_to_key`, `owner_to_warrants`)。
409    ///
410    /// 用于 stock-list `delete_flag = true` 场景。
411    /// 返 `true` 如果 row 存在并被删除, `false` 如果 stock_id 不在 `id_to_key`。
412    pub fn delete_security_info(&self, stock_id: u64) -> bool {
413        let Some((_, key)) = self.id_to_key.remove(&stock_id) else {
414            return false;
415        };
416        self.option_contracts.remove(&stock_id);
417        // 拿被删 row 的 owner (用于反向索引清理), 如果 key 已不在 securities, owner = 0
418        let old_info = self.securities.remove(&key).map(|(_, info)| info);
419        let old_owner = old_info.as_ref().map(|r| r.warrnt_stock_owner).unwrap_or(0);
420        if let Some(old_info) = old_info.as_ref() {
421            self.remove_future_main_link_aliases(&key, old_info);
422        }
423        self.crypto_pairs.remove(&key);
424        // F6: stock-list delete 时把 warrant 从 old_owner 反向索引里清掉
425        if old_owner != 0
426            && let Ok(mut map) = self.owner_to_warrants.write()
427            && let Some(set) = map.get_mut(&old_owner)
428        {
429            set.remove(&stock_id);
430            if set.is_empty() {
431                map.remove(&old_owner);
432            }
433        }
434        // F6: 该 stock_id 自己也可能是某 owner — 清掉它作为 owner 的 entry
435        if let Ok(mut map) = self.owner_to_warrants.write() {
436            map.remove(&stock_id);
437        }
438        true
439    }
440
441    /// 内部 helper: F9 unified upsert with owner-index maintenance (F6).
442    fn upsert_with_owner_index_maintenance(&self, key: &str, info: CachedSecurityInfo) {
443        // 先看老 row 是否存在 + 旧 owner 是什么 (F6: 如果 owner 变了要清旧索引)
444        let old_info = self.securities.get(key).map(|r| Arc::clone(r.value()));
445        let old_owner = old_info.as_ref().map(|r| r.warrnt_stock_owner).unwrap_or(0);
446        let new_owner = info.warrnt_stock_owner;
447
448        if let Some(old) = old_info.as_ref() {
449            self.remove_future_main_link_aliases(key, old);
450        }
451
452        // 写 securities + id_to_key
453        let stock_id = info.stock_id;
454        self.securities
455            .insert(key.to_string(), Arc::new(info.clone()));
456        self.id_to_key.insert(stock_id, key.to_string());
457        self.add_future_main_link_aliases(key, &info);
458
459        // F6: 维护反向索引
460        if old_owner != new_owner {
461            // owner 变了 (含 0→X / X→Y / X→0)
462            if let Ok(mut map) = self.owner_to_warrants.write() {
463                if old_owner != 0
464                    && let Some(set) = map.get_mut(&old_owner)
465                {
466                    set.remove(&stock_id);
467                    if set.is_empty() {
468                        map.remove(&old_owner);
469                    }
470                }
471                if new_owner != 0 {
472                    map.entry(new_owner).or_default().insert(stock_id);
473                }
474            }
475        } else if new_owner != 0 {
476            // owner 未变, 但同一 owner 下重 add (idempotent due to HashSet).
477            if let Ok(mut map) = self.owner_to_warrants.write() {
478                map.entry(new_owner).or_default().insert(stock_id);
479            }
480        }
481    }
482
483    fn future_main_link_target_ids(info: &CachedSecurityInfo) -> Vec<u64> {
484        let mut ids = Vec::with_capacity(2);
485        for target in [info.future_origin_id, info.zhuli_id] {
486            if target != 0 && target != info.stock_id && !ids.contains(&target) {
487                ids.push(target);
488            }
489        }
490        ids
491    }
492
493    fn add_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
494        for target in Self::future_main_link_target_ids(info) {
495            self.future_main_link_aliases
496                .entry(target)
497                .or_default()
498                .insert(key.to_string());
499        }
500    }
501
502    fn remove_future_main_link_aliases(&self, key: &str, info: &CachedSecurityInfo) {
503        for target in Self::future_main_link_target_ids(info) {
504            if let Some(mut aliases) = self.future_main_link_aliases.get_mut(&target) {
505                aliases.remove(key);
506                let empty = aliases.is_empty();
507                drop(aliases);
508                if empty {
509                    self.future_main_link_aliases.remove(&target);
510                }
511            }
512        }
513    }
514
515    /// 查询某个 backend push stock_id 对应的主连/连续合约 sec_key 别名。
516    ///
517    /// 只返回 stock-list 明确下发 `origin_id` / `zhuli_id` 关系的 key;不做
518    /// `HSImain` 等字符串启发式。调用方通常先按 `id_to_key` 处理真实合约,
519    /// 再把这里返回的 main-link key 一并投递。
520    #[must_use]
521    pub fn get_future_main_link_alias_keys(&self, stock_id: u64) -> Vec<String> {
522        let Some(aliases) = self.future_main_link_aliases.get(&stock_id) else {
523            return Vec::new();
524        };
525        let mut keys: Vec<String> = aliases.iter().cloned().collect();
526        keys.sort();
527        keys
528    }
529
530    /// 查询 backend push stock_id 的所有 quote 投递目标。
531    ///
532    /// 顺序保持为:真实 stock_id 对应 key(如有)优先,然后是 stock-list
533    /// `origin_id` / `zhuli_id` 下发的主连/连续合约别名 key。调用方不再直接
534    /// 读取 `id_to_key` 和 `future_main_link_aliases` 两个索引,避免 alias 逻辑
535    /// 分散在 push parser 里。
536    #[must_use]
537    pub fn quote_push_targets_for_stock_id(
538        &self,
539        stock_id: u64,
540    ) -> Vec<(String, Arc<CachedSecurityInfo>)> {
541        let mut targets = Vec::new();
542
543        if let Some(sec_key_ref) = self.id_to_key.get(&stock_id) {
544            let sec_key = sec_key_ref.clone();
545            drop(sec_key_ref);
546            if let Some(info) = self.get_security_info_arc(&sec_key) {
547                targets.push((sec_key, info));
548            }
549        }
550
551        for alias_key in self.get_future_main_link_alias_keys(stock_id) {
552            if targets.iter().any(|(key, _)| key == &alias_key) {
553                continue;
554            }
555            if let Some(info) = self.get_security_info_arc(&alias_key) {
556                targets.push((alias_key, info));
557            }
558        }
559
560        targets
561    }
562
563    /// **v1.4.110 Phase 2 Slice 5**: broker-aware 推送投递目标查询.
564    ///
565    /// 对应 push parser 从 `SecurityQuote.broker_id` 重建 broker-aware
566    /// `QotStockKey` 的路径 (对齐 C++ `NNBiz_Qot_PushQot.cpp:220-269`).
567    ///
568    /// 语义:
569    /// - `broker_id = None` (C++ `m_hasBroker=false`): 只查 no-broker key,
570    ///   返 `QotSecurityKey::no_broker(public_sec_key, stock_id)` 与
571    ///   `quote_push_targets_for_stock_id` 等价
572    /// - `broker_id = Some(N)` (C++ `m_hasBroker=true`): 沿 stock_id 反向
573    ///   找到 public_sec_key, 再用 `QotSecurityKey::from_broker_id(...)`
574    ///   构造 broker-aware key. 该 broker 下 cache 写入独立桶
575    ///   `"market_code@b{N}"`, 不污染同 stock_id 其他 broker 的 cache.
576    ///
577    /// **Phase 2 默认**: backend 当前对普通股 push 仍不下发 broker_id (=None),
578    /// 与升级前行为完全等价. crypto multi-broker push 会带 broker_id,
579    /// Phase 3 reader caller (handler `GetBasicQot` 等) 替换走 `_broker`
580    /// 版本后, broker-aware cache 才被消费.
581    #[must_use]
582    pub fn quote_push_targets_for_stock_key(
583        &self,
584        stock_id: u64,
585        broker_id: Option<std::num::NonZeroU32>,
586    ) -> Vec<(
587        futu_core::qot_stock_key::QotSecurityKey,
588        Arc<CachedSecurityInfo>,
589    )> {
590        // 先用 no-broker 路径查 stock_id → (public_sec_key, info) 列表
591        // (id_to_key + future_main_link_aliases). broker_id 注入到返 key
592        // 不改变 lookup 逻辑.
593        let bare = self.quote_push_targets_for_stock_id(stock_id);
594        bare.into_iter()
595            .map(|(public_sec_key, info)| {
596                let key = match broker_id {
597                    Some(nz) => futu_core::qot_stock_key::QotSecurityKey::from_broker_id(
598                        public_sec_key,
599                        stock_id,
600                        nz.get(),
601                    ),
602                    None => futu_core::qot_stock_key::QotSecurityKey::no_broker(
603                        public_sec_key,
604                        stock_id,
605                    ),
606                };
607                (key, info)
608            })
609            .collect()
610    }
611
612    /// **deprecated**: 改用 `upsert_full_security_info` /
613    /// `upsert_basic_security_info` 显式表达数据完整度。
614    ///
615    /// v1.4.111 codex legacy deep-dive follow-up: 保留此 public wrapper 兼容
616    /// 既有 tests/bench/下游辅助代码,但不再直接写 `securities` 造成半索引行。
617    /// `source.is_complete()` 走 full upsert, 否则走 basic upsert, 始终维护
618    /// `id_to_key` / `owner_to_warrants` 与主表一致。
619    ///
620    /// Removal trigger: repo 内 tests/benches 全部迁移到显式 upsert/delete API,
621    /// 且一个 minor release 内没有下游兼容反馈后删除。
622    #[deprecated(
623        since = "1.4.106",
624        note = "use upsert_full_security_info / upsert_basic_security_info / delete_security_info"
625    )]
626    pub fn set_security_info(&self, key: &str, info: CachedSecurityInfo) {
627        if info.source.is_complete() {
628            self.upsert_full_security_info(key, info);
629        } else {
630            self.upsert_basic_security_info(key, info);
631        }
632    }
633
634    pub fn get_security_info(&self, key: &str) -> Option<CachedSecurityInfo> {
635        self.get_security_info_arc(key)
636            .map(|info| info.as_ref().clone())
637    }
638
639    pub fn get_security_info_arc(&self, key: &str) -> Option<Arc<CachedSecurityInfo>> {
640        self.securities.get(key).map(|v| Arc::clone(v.value()))
641    }
642
643    pub fn security_id_for_key(&self, key: &str) -> Option<u64> {
644        self.get_security_info(key)
645            .map(|info| info.stock_id)
646            .filter(|stock_id| *stock_id > 0)
647    }
648
649    pub fn security_info_snapshot(&self) -> Vec<CachedSecurityInfo> {
650        self.security_info_snapshot_matching(|_| true)
651    }
652
653    pub fn security_info_snapshot_matching(
654        &self,
655        mut predicate: impl FnMut(&CachedSecurityInfo) -> bool,
656    ) -> Vec<CachedSecurityInfo> {
657        self.securities
658            .iter()
659            .filter_map(|entry| {
660                let info = entry.value();
661                predicate(info.as_ref()).then(|| info.as_ref().clone())
662            })
663            .collect()
664    }
665
666    pub fn security_key_by_stock_id(&self, stock_id: u64) -> Option<String> {
667        self.id_to_key.get(&stock_id).map(|key| key.value().clone())
668    }
669
670    /// 通过 stock_id 查找股票信息 (使用 id_to_key 反向映射)
671    pub fn get_security_info_by_stock_id(&self, stock_id: u64) -> Option<CachedSecurityInfo> {
672        let key = self.security_key_by_stock_id(stock_id)?;
673        self.get_security_info(&key)
674    }
675
676    pub fn get_security_info_by_stock_id_trigger_refresh(
677        &self,
678        stock_id: u64,
679    ) -> Option<CachedSecurityInfo> {
680        let key = self.security_key_by_stock_id(stock_id)?;
681        self.get_security_info_trigger_refresh(&key)
682    }
683
684    /// 添加窝轮→正股的映射关系
685    ///
686    /// **v1.4.106 codex 1148 F6**: HashSet 自动去重, 重复 add 同 (warrant, owner)
687    /// 是 idempotent — SQLite reload + stock-list sync 不会重复入。
688    pub fn add_warrant_owner(&self, warrant_stock_id: u64, owner_stock_id: u64) {
689        if owner_stock_id == 0 {
690            return;
691        }
692        if let Ok(mut map) = self.owner_to_warrants.write() {
693            map.entry(owner_stock_id)
694                .or_default()
695                .insert(warrant_stock_id);
696        }
697    }
698
699    /// 通过正股 ID 搜索该正股的所有窝轮
700    ///
701    /// **v1.4.106 codex 1148 F6**: 内部 HashSet, 返 Vec (call site backward
702    /// compatible)。返序非确定 (HashSet 不保留 insertion order); call site 若
703    /// 需要稳定序应自己 sort。
704    ///
705    /// v1.4.111 P2-1 Tier 3 audit comment: warrant lookup helper — empty Vec =
706    /// "no warrants for this owner_stock_id" (legit "正股无窝轮"), 跟 C++
707    /// `SearchWarrantsByOwner` empty 行为对齐. caller decide 怎么用 (display 0
708    /// warrants 是合理). 非 silent-success risk (audit verified, essentials/
709    /// 2026-05-27).
710    #[must_use]
711    pub fn search_warrants_by_owner(&self, owner_stock_id: u64) -> Vec<u64> {
712        match self.owner_to_warrants.read() {
713            Ok(map) => map
714                .get(&owner_stock_id)
715                .map(|set| set.iter().copied().collect())
716                .unwrap_or_default(),
717            _ => Vec::new(),
718        }
719    }
720}
721
722impl Default for StaticDataCache {
723    fn default() -> Self {
724        Self::new()
725    }
726}
727
728#[cfg(test)]
729mod tests;