Skip to main content

futu_cache/static_data/
security_identity.rs

1use super::{
2    CachedSecurityInfo, CryptoPairInfo, CryptoTradeConfig, OnDemandSecurityPublishOutcome,
3    OptionContractInfo, StaticDataCache,
4};
5use std::collections::HashSet;
6use std::sync::Arc;
7
8impl StaticDataCache {
9    #[must_use]
10    pub fn stock_list_publication_generation(&self) -> u64 {
11        self.stock_list_publication_generation
12            .load(std::sync::atomic::Ordering::Acquire)
13    }
14
15    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 完整静态行 (`StockListFull` /
16    /// `Bootstrap` source)。同步维护 `securities` + `id_to_key` + 若有 owner
17    /// 还更新 `owner_to_warrants`。**自动 dedup**: 已有同 key 但 `warrnt_stock_owner`
18    /// 变化时, 旧 owner 下移除该 warrant id, 新 owner 下添加。
19    ///
20    /// 替代生产代码里手动调 `securities.insert()` + `id_to_key.insert()` +
21    /// `add_warrant_owner()` 三步骤的 pattern。
22    ///
23    /// **不允许** caller 把不完整的 source 标 `StockListFull`(若 `info.source ==
24    /// OnDemandBasic`, 用 `upsert_basic_security_info` 而非本 fn)。
25    pub fn upsert_full_security_info(&self, key: &str, info: CachedSecurityInfo) {
26        debug_assert!(
27            info.source.is_complete(),
28            "upsert_full_security_info called with non-complete source ({:?})",
29            info.source
30        );
31        let _identity_write = match self.zero_id_repair.lock() {
32            Ok(guard) => guard,
33            Err(_) => return,
34        };
35        self.upsert_with_owner_index_maintenance(key, info);
36    }
37
38    /// Atomically publish a complete stock-list row and its canonical crypto
39    /// pair metadata under the same cache-owned identity transaction.
40    pub fn upsert_full_security_info_with_crypto_pair(
41        &self,
42        key: &str,
43        info: CachedSecurityInfo,
44        pair: CryptoPairInfo,
45    ) {
46        debug_assert!(
47            info.source.is_complete(),
48            "upsert_full_security_info_with_crypto_pair called with non-complete source ({:?})",
49            info.source
50        );
51        let _identity_write = match self.zero_id_repair.lock() {
52            Ok(guard) => guard,
53            Err(_) => return,
54        };
55        let stock_id = info.stock_id;
56        if stock_id > 0 {
57            self.upsert_crypto_pair_by_stock_id_locked(stock_id, pair);
58            self.upsert_with_owner_index_maintenance(key, info);
59        } else {
60            self.upsert_with_owner_index_maintenance(key, info);
61            self.upsert_crypto_pair_info_locked(key, pair);
62        }
63        self.stock_list_publication_generation
64            .fetch_add(1, std::sync::atomic::Ordering::Release);
65    }
66
67    /// Publish a SQLite Bootstrap row only if no authoritative stock-list
68    /// update/delete/clear linearized after the caller's DB-read snapshot.
69    ///
70    /// Existing complete exact rows always prevail. A canonical public key
71    /// already owned by a different positive stock id is also rejected.
72    pub fn publish_bootstrap_security_if_stock_list_generation(
73        &self,
74        expected_generation: u64,
75        key: &str,
76        info: CachedSecurityInfo,
77    ) -> bool {
78        if info.stock_id == 0
79            || info.market <= 0
80            || info.code.is_empty()
81            || !info.source.is_complete()
82        {
83            return false;
84        }
85        let _identity_write = match self.zero_id_repair.lock() {
86            Ok(guard) => guard,
87            Err(_) => return false,
88        };
89        if self
90            .stock_list_publication_generation
91            .load(std::sync::atomic::Ordering::Acquire)
92            != expected_generation
93        {
94            return false;
95        }
96        if self
97            .securities_by_stock_id
98            .get(&info.stock_id)
99            .is_some_and(|existing| existing.is_complete())
100        {
101            return false;
102        }
103        if self
104            .securities
105            .get(key)
106            .is_some_and(|existing| existing.stock_id > 0 && existing.stock_id != info.stock_id)
107        {
108            return false;
109        }
110        self.upsert_with_owner_index_maintenance(key, info);
111        true
112    }
113
114    /// 写入 stock-list 下发的 crypto 货币对元数据。
115    pub fn upsert_crypto_pair_info(&self, key: &str, pair: CryptoPairInfo) {
116        let _identity_write = match self.zero_id_repair.lock() {
117            Ok(guard) => guard,
118            Err(_) => return,
119        };
120        self.upsert_crypto_pair_info_locked(key, pair);
121    }
122
123    fn upsert_crypto_pair_info_locked(&self, key: &str, pair: CryptoPairInfo) {
124        if let Some(stock_id) = self
125            .securities
126            .get(key)
127            .map(|entry| entry.stock_id)
128            .filter(|stock_id| *stock_id > 0)
129        {
130            self.upsert_crypto_pair_by_stock_id_locked(stock_id, pair.clone());
131        }
132        if pair.cc_origin.is_empty() && pair.cc_destination.is_empty() {
133            self.crypto_pairs.remove(key);
134        } else {
135            self.crypto_pairs.insert(key.to_string(), pair);
136        }
137    }
138
139    fn upsert_crypto_pair_by_stock_id_locked(&self, stock_id: u64, pair: CryptoPairInfo) {
140        if stock_id == 0 {
141            return;
142        }
143        if pair.cc_origin.is_empty() && pair.cc_destination.is_empty() {
144            self.crypto_pairs_by_stock_id.remove(&stock_id);
145        } else {
146            self.crypto_pairs_by_stock_id.insert(stock_id, pair);
147        }
148    }
149
150    fn refresh_public_crypto_pair_locked(&self, key: &str) {
151        let pair = self.securities.get(key).and_then(|security| {
152            (security.stock_id > 0)
153                .then_some(security.stock_id)
154                .and_then(|stock_id| {
155                    self.crypto_pairs_by_stock_id
156                        .get(&stock_id)
157                        .map(|pair| pair.clone())
158                })
159        });
160        if let Some(pair) = pair {
161            self.crypto_pairs.insert(key.to_string(), pair);
162        } else {
163            self.crypto_pairs.remove(key);
164        }
165    }
166
167    /// Cache option contract metadata from CMD20106 `OptionResultInfo`.
168    pub fn set_option_contract_info(&self, stock_id: u64, info: OptionContractInfo) {
169        if stock_id == 0 {
170            return;
171        }
172        self.option_contracts.insert(stock_id, info);
173    }
174
175    /// Read option contract metadata by option stock_id.
176    pub fn get_option_contract_info_by_stock_id(
177        &self,
178        stock_id: u64,
179    ) -> Option<OptionContractInfo> {
180        self.option_contracts
181            .get(&stock_id)
182            .map(|entry| *entry.value())
183    }
184
185    /// 读取 crypto 货币对元数据。
186    pub fn get_crypto_pair_info(&self, key: &str) -> Option<CryptoPairInfo> {
187        self.crypto_pairs.get(key).map(|v| v.clone())
188    }
189
190    fn crypto_trade_config_key(broker_id: u32, symbol: &str, exchange: &str) -> String {
191        format!(
192            "{broker_id}:{}:{}",
193            symbol.trim().to_ascii_uppercase(),
194            exchange.trim().to_ascii_uppercase()
195        )
196    }
197
198    /// 用 backend CMD20102 拉回的配置替换某个 broker 的 crypto trade config。
199    pub fn set_crypto_trade_configs_for_broker(
200        &self,
201        broker_id: u32,
202        configs: Vec<CryptoTradeConfig>,
203    ) {
204        let prefix = format!("{broker_id}:");
205        self.crypto_trade_configs
206            .retain(|key, _| !key.starts_with(&prefix));
207        for config in configs {
208            if config.symbol.trim().is_empty() || config.exchange.trim().is_empty() {
209                continue;
210            }
211            let key = Self::crypto_trade_config_key(broker_id, &config.symbol, &config.exchange);
212            self.crypto_trade_configs.insert(key, config);
213        }
214    }
215
216    /// 查询某个 crypto symbol 的交易配置。
217    pub fn get_crypto_trade_config(
218        &self,
219        broker_id: u32,
220        symbol: &str,
221        exchange: &str,
222    ) -> Option<CryptoTradeConfig> {
223        let key = Self::crypto_trade_config_key(broker_id, symbol, exchange);
224        self.crypto_trade_configs.get(&key).map(|v| v.clone())
225    }
226
227    /// v1.4.106 codex 1148 F9 (P3): 统一写入口 — 部分静态行 (`OnDemandBasic`
228    /// source)。同步维护 `securities` + `id_to_key`, **不动** `owner_to_warrants`
229    /// (因为 OnDemandBasic 不含 `warrnt_stock_owner` 字段, value 必为 0)。
230    ///
231    /// `info.source` 必须是 `OnDemandBasic` (debug_assert)。
232    pub fn upsert_basic_security_info(&self, key: &str, info: CachedSecurityInfo) {
233        debug_assert!(
234            !info.source.is_complete(),
235            "upsert_basic_security_info called with complete source ({:?}); use upsert_full",
236            info.source
237        );
238        debug_assert_eq!(
239            info.warrnt_stock_owner, 0,
240            "OnDemandBasic must have warrnt_stock_owner=0 (caller didn't query the field)"
241        );
242        let _identity_write = match self.zero_id_repair.lock() {
243            Ok(guard) => guard,
244            Err(_) => return,
245        };
246        self.upsert_basic_security_info_locked(key, info);
247    }
248
249    /// Publish an on-demand row only into the exact stock-id view.
250    ///
251    /// C++ `SetOptInfo` always updates its option ID map for a valid option ID,
252    /// but updates the code map only when the option code is non-empty. This
253    /// boundary preserves that distinction without manufacturing a public
254    /// empty-code key in the Rust cache.
255    pub fn upsert_basic_security_info_by_stock_id_only(&self, info: CachedSecurityInfo) {
256        debug_assert!(
257            !info.source.is_complete(),
258            "ID-only on-demand rows must not replace complete stock-list rows"
259        );
260        if info.stock_id == 0 {
261            return;
262        }
263        let _identity_write = match self.zero_id_repair.lock() {
264            Ok(guard) => guard,
265            Err(_) => return,
266        };
267        if self
268            .securities_by_stock_id
269            .get(&info.stock_id)
270            .is_some_and(|existing| existing.is_complete() || !existing.code.is_empty())
271        {
272            return;
273        }
274        self.securities_by_stock_id
275            .insert(info.stock_id, Arc::new(info));
276    }
277
278    fn upsert_basic_security_info_locked(&self, key: &str, info: CachedSecurityInfo) {
279        // 不维护 owner_to_warrants (basic 没这个字段).
280        // 但仍需检查既有完整行的 owner 是否会被 basic 错误覆盖.
281        // 策略: 如果 key 已有 StockListFull / Bootstrap 行, 不让 basic 行覆盖 (full
282        // 数据更完整). 这处理 case "subscribe on-demand 后, stock-list sync 来时
283        // 应该 prevail; 反过来不行".
284        if let Some(existing) = self.securities.get(key)
285            && existing.is_complete()
286        {
287            tracing::debug!(
288                key,
289                "upsert_basic_security_info skipped: existing complete row prevails"
290            );
291            return;
292        }
293        if info.stock_id > 0
294            && self
295                .securities_by_stock_id
296                .get(&info.stock_id)
297                .is_some_and(|existing| existing.is_complete())
298        {
299            tracing::debug!(
300                key,
301                stock_id = info.stock_id,
302                "upsert_basic_security_info skipped: exact complete row prevails"
303            );
304            return;
305        }
306        self.upsert_with_owner_index_maintenance(key, info);
307    }
308
309    /// Publish one validated CMD20106 on-demand row without allowing the
310    /// primary and reverse identity indexes to diverge.
311    ///
312    /// The caller may update partial metadata only after `BasicPublished`.
313    /// Complete rows and zero-id repairs preserve their authoritative metadata.
314    pub fn publish_on_demand_security_info(
315        &self,
316        key: &str,
317        refreshed: CachedSecurityInfo,
318        crypto_pair: Option<CryptoPairInfo>,
319    ) -> OnDemandSecurityPublishOutcome {
320        self.publish_on_demand_security_info_with_pair_hook(key, refreshed, crypto_pair, || {})
321    }
322
323    pub(super) fn publish_on_demand_security_info_with_pair_hook(
324        &self,
325        key: &str,
326        refreshed: CachedSecurityInfo,
327        crypto_pair: Option<CryptoPairInfo>,
328        before_pair_publish: impl FnOnce(),
329    ) -> OnDemandSecurityPublishOutcome {
330        let _identity_write = match self.zero_id_repair.lock() {
331            Ok(guard) => guard,
332            Err(_) => return OnDemandSecurityPublishOutcome::Rejected,
333        };
334        if refreshed.stock_id == 0
335            || refreshed.source.is_complete()
336            || refreshed.warrnt_stock_owner != 0
337        {
338            return OnDemandSecurityPublishOutcome::Rejected;
339        }
340
341        let stock_id = refreshed.stock_id;
342        if self
343            .id_to_key
344            .get(&stock_id)
345            .is_some_and(|mapped| mapped.as_str() != key)
346        {
347            return OnDemandSecurityPublishOutcome::Rejected;
348        }
349
350        let existing = self
351            .securities
352            .get(key)
353            .map(|entry| Arc::clone(entry.value()));
354        match existing {
355            Some(existing) if existing.stock_id == 0 => {
356                let stock_id = refreshed.stock_id;
357                if self.repair_zero_stock_id_from_basic_locked(key, refreshed, || {}) {
358                    if let Some(pair) = crypto_pair {
359                        self.upsert_crypto_pair_by_stock_id_locked(stock_id, pair);
360                        self.refresh_public_crypto_pair_locked(key);
361                    }
362                    OnDemandSecurityPublishOutcome::ZeroIdRepaired
363                } else {
364                    OnDemandSecurityPublishOutcome::Rejected
365                }
366            }
367            Some(existing) if existing.stock_id != stock_id => {
368                OnDemandSecurityPublishOutcome::Rejected
369            }
370            Some(existing) if existing.is_complete() => {
371                if self.update_mkt_id_locked(key, refreshed.mkt_id) {
372                    OnDemandSecurityPublishOutcome::CompleteMktIdUpdated
373                } else {
374                    OnDemandSecurityPublishOutcome::Rejected
375                }
376            }
377            Some(_) | None => {
378                let stock_id = refreshed.stock_id;
379                self.upsert_basic_security_info_locked(key, refreshed);
380                before_pair_publish();
381                if let Some(pair) = crypto_pair {
382                    self.upsert_crypto_pair_by_stock_id_locked(stock_id, pair);
383                    self.refresh_public_crypto_pair_locked(key);
384                }
385                OnDemandSecurityPublishOutcome::BasicPublished
386            }
387        }
388    }
389
390    /// Repair an existing zero-id row from a validated on-demand CMD20106 row.
391    ///
392    /// This is deliberately narrower than either upsert path: the primary key
393    /// must still identify the same public market/code, the existing row must
394    /// still have `stock_id == 0`, and the positive stock id must not belong to
395    /// another key. Complete stock-list/bootstrap fields remain authoritative;
396    /// only their missing identity and backend market id are repaired.
397    pub fn repair_zero_stock_id_from_basic(
398        &self,
399        key: &str,
400        refreshed: CachedSecurityInfo,
401    ) -> bool {
402        self.repair_zero_stock_id_from_basic_with_security_hook(key, refreshed, || {})
403    }
404
405    pub(super) fn repair_zero_stock_id_from_basic_with_security_hook(
406        &self,
407        key: &str,
408        refreshed: CachedSecurityInfo,
409        after_security_locked: impl FnOnce(),
410    ) -> bool {
411        // One repair may need to update primary, reverse, owner, and future
412        // alias indices as one bounded cache transaction. Serializing this
413        // rare path prevents one repair from holding a security shard while
414        // another holds the target positive-id shard and scans securities.
415        let _repair = match self.zero_id_repair.lock() {
416            Ok(guard) => guard,
417            Err(_) => return false,
418        };
419
420        self.repair_zero_stock_id_from_basic_locked(key, refreshed, after_security_locked)
421    }
422
423    fn repair_zero_stock_id_from_basic_locked(
424        &self,
425        key: &str,
426        refreshed: CachedSecurityInfo,
427        after_security_locked: impl FnOnce(),
428    ) -> bool {
429        if refreshed.stock_id == 0
430            || refreshed.source.is_complete()
431            || refreshed.warrnt_stock_owner != 0
432        {
433            return false;
434        }
435
436        let stock_id = refreshed.stock_id;
437        let old_public_keys = self.public_keys_for_stock_id_locked(stock_id);
438        if self
439            .id_to_key
440            .get(&stock_id)
441            .is_some_and(|mapped| mapped.as_str() != key)
442        {
443            return false;
444        }
445
446        let Some(preliminary_ref) = self.securities.get(key) else {
447            return false;
448        };
449        let preliminary = Arc::clone(preliminary_ref.value());
450        drop(preliminary_ref);
451        if preliminary.stock_id != 0
452            || preliminary.market != refreshed.market
453            || preliminary.code != refreshed.code
454        {
455            return false;
456        }
457
458        // Owner index publication is serialized before taking a mutable
459        // security shard. This lets the winner inspect remaining zero-id rows
460        // without forming security-shard -> owner-lock cycles with another
461        // repair.
462        let preliminary_owner = preliminary.warrnt_stock_owner;
463        let mut owner_index = if preliminary_owner != 0 {
464            match self.owner_to_warrants.write() {
465                Ok(index) => Some(index),
466                Err(_) => return false,
467            }
468        } else {
469            None
470        };
471
472        let Some(mut entry) = self.securities.get_mut(key) else {
473            return false;
474        };
475        after_security_locked();
476        let old = Arc::clone(entry.value());
477        if old.stock_id != 0
478            || old.market != refreshed.market
479            || old.code != refreshed.code
480            || old.warrnt_stock_owner != preliminary_owner
481        {
482            return false;
483        }
484
485        let mut updated = if old.is_complete() {
486            old.as_ref().clone()
487        } else {
488            refreshed.clone()
489        };
490        updated.stock_id = stock_id;
491        updated.mkt_id = refreshed.mkt_id;
492
493        let old_owner = old.warrnt_stock_owner;
494        let new_owner = updated.warrnt_stock_owner;
495        debug_assert_eq!(old_owner, preliminary_owner);
496        debug_assert!(new_owner == 0 || owner_index.is_some());
497
498        // Hold the positive-id shard reservation through the complete primary
499        // and derived-index commit. Competing zero-id repairs and existing
500        // upsert writers for the same stock id cannot pass this ownership gate
501        // concurrently.
502        let stock_id_reservation = match self.id_to_key.entry(stock_id) {
503            dashmap::mapref::entry::Entry::Occupied(occupied) => {
504                if occupied.get() != key {
505                    return false;
506                }
507                occupied.into_ref()
508            }
509            dashmap::mapref::entry::Entry::Vacant(vacant) => vacant.insert(key.to_string()),
510        };
511
512        self.remove_future_main_link_aliases(key, &old);
513        let updated = Arc::new(updated);
514        *entry = Arc::clone(&updated);
515        self.securities_by_stock_id
516            .insert(stock_id, Arc::clone(&updated));
517        if let Some(pair) = self.crypto_pairs.get(key).map(|pair| pair.clone()) {
518            self.crypto_pairs_by_stock_id.insert(stock_id, pair);
519        }
520        self.add_future_main_link_aliases(key, &updated);
521        drop(entry);
522
523        if let Some(index) = owner_index.as_mut() {
524            if old_owner != 0
525                && !self.securities.iter().any(|candidate| {
526                    candidate.stock_id == 0 && candidate.warrnt_stock_owner == old_owner
527                })
528                && let Some(warrants) = index.get_mut(&old_owner)
529            {
530                warrants.remove(&0);
531                if warrants.is_empty() {
532                    index.remove(&old_owner);
533                }
534            }
535            if new_owner != 0 {
536                index.entry(new_owner).or_default().insert(stock_id);
537            }
538        }
539
540        drop(stock_id_reservation);
541        self.id_to_key.remove_if(&0, |_, mapped| mapped == key);
542        let new_public_keys = self.public_keys_for_stock_id_locked(stock_id);
543        self.reconcile_public_stock_id_keys_locked(stock_id, &old_public_keys, &new_public_keys);
544        true
545    }
546
547    /// v1.4.106 codex 1148 F9 (P3): 删除 cache row + 同步清三个索引
548    /// (`securities`, `id_to_key`, `owner_to_warrants`)。
549    ///
550    /// 用于 stock-list `delete_flag = true` 场景。
551    /// 返 `true` 如果 row 存在并被删除, `false` 如果 stock_id 不在 `id_to_key`。
552    pub fn delete_security_info(&self, stock_id: u64) -> bool {
553        let _identity_write = match self.zero_id_repair.lock() {
554            Ok(guard) => guard,
555            Err(_) => return false,
556        };
557        self.stock_list_publication_generation
558            .fetch_add(1, std::sync::atomic::Ordering::Release);
559        let old_public_keys = self.public_keys_for_stock_id_locked(stock_id);
560        let Some((_, key)) = self.id_to_key.remove(&stock_id) else {
561            return false;
562        };
563        self.option_contracts.remove(&stock_id);
564        if stock_id == 0 {
565            if let Some((_, old)) = self
566                .securities
567                .remove_if(&key, |_, info| info.stock_id == 0)
568            {
569                self.remove_future_main_link_aliases(&key, &old);
570                self.remove_owner_relation(&old);
571            }
572            self.crypto_pairs.remove(&key);
573            self.stale_mkt_ids.remove(&key);
574            return true;
575        }
576
577        self.security_request_aliases.remove(&stock_id);
578
579        if let Some((_, old)) = self.securities_by_stock_id.remove(&stock_id) {
580            self.remove_owner_relation(&old);
581            self.remove_future_main_link_aliases_if_unreferenced(&key, &old);
582        }
583        self.crypto_pairs_by_stock_id.remove(&stock_id);
584        self.reconcile_public_stock_id_keys_locked(stock_id, &old_public_keys, &HashSet::new());
585        for public_key in old_public_keys {
586            self.refresh_public_security_locked(&public_key);
587        }
588
589        // F6: 该 stock_id 自己也可能是某 owner — 清掉它作为 owner 的 entry
590        if let Ok(mut map) = self.owner_to_warrants.write() {
591            map.remove(&stock_id);
592        }
593        true
594    }
595
596    /// 内部 helper: F9 unified upsert with owner-index maintenance (F6).
597    fn upsert_with_owner_index_maintenance(&self, key: &str, info: CachedSecurityInfo) {
598        let stock_id = info.stock_id;
599        if stock_id == 0 {
600            self.upsert_zero_id_security_locked(key, info);
601            return;
602        }
603
604        let old_public_keys = self.public_keys_for_stock_id_locked(stock_id);
605        let old_key = self.id_to_key.get(&stock_id).map(|mapped| mapped.clone());
606        let old_exact = self
607            .securities_by_stock_id
608            .remove(&stock_id)
609            .map(|(_, old)| old);
610        self.id_to_key.remove(&stock_id);
611
612        if let (Some(old_key), Some(old)) = (old_key.as_deref(), old_exact.as_ref())
613            && !old.is_complete()
614            && old_key != key
615        {
616            self.security_request_aliases
617                .entry(stock_id)
618                .or_default()
619                .insert(old_key.to_string());
620        }
621        self.remove_security_request_alias_locked(stock_id, key);
622
623        if let (Some(old_key), Some(old)) = (old_key.as_deref(), old_exact.as_ref()) {
624            self.remove_owner_relation(old);
625            self.remove_future_main_link_aliases_if_unreferenced(old_key, old);
626        }
627
628        self.remove_zero_id_public_row_locked(key);
629
630        let info = Arc::new(info);
631        self.securities_by_stock_id
632            .insert(stock_id, Arc::clone(&info));
633        self.id_to_key.insert(stock_id, key.to_string());
634        self.add_owner_relation(&info);
635        self.add_future_main_link_aliases(key, &info);
636
637        let new_public_keys = self.public_keys_for_stock_id_locked(stock_id);
638        self.reconcile_public_stock_id_keys_locked(stock_id, &old_public_keys, &new_public_keys);
639        let mut public_keys_to_refresh = old_public_keys;
640        public_keys_to_refresh.extend(new_public_keys);
641        for public_key in public_keys_to_refresh {
642            self.refresh_public_security_locked(&public_key);
643        }
644    }
645
646    fn upsert_zero_id_security_locked(&self, key: &str, info: CachedSecurityInfo) {
647        if self.public_security_candidate_locked(key).is_some() {
648            return;
649        }
650        self.remove_zero_id_public_row_locked(key);
651        let info = Arc::new(info);
652        self.securities.insert(key.to_string(), Arc::clone(&info));
653        self.id_to_key.insert(0, key.to_string());
654        self.add_owner_relation(&info);
655        self.add_future_main_link_aliases(key, &info);
656    }
657
658    fn remove_zero_id_public_row_locked(&self, key: &str) {
659        let Some((_, old)) = self.securities.remove_if(key, |_, info| info.stock_id == 0) else {
660            return;
661        };
662        self.id_to_key.remove_if(&0, |_, mapped| mapped == key);
663        self.remove_future_main_link_aliases(key, &old);
664        self.remove_owner_relation(&old);
665    }
666
667    /// C++ `security.id` is an INTEGER PRIMARY KEY and `index_code` yields
668    /// equal-code rows in rowid order. After `SearchSecByCode` filters
669    /// `no_search`, `GetStockID` takes the first result, hence the lowest
670    /// searchable stock id is the stable public identity.
671    fn public_security_candidate_locked(&self, key: &str) -> Option<Arc<CachedSecurityInfo>> {
672        let stock_ids = self.public_stock_ids_by_key.get(key)?;
673        stock_ids.iter().find_map(|stock_id| {
674            #[cfg(test)]
675            self.public_security_candidate_inspections
676                .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
677            self.securities_by_stock_id
678                .get(stock_id)
679                .map(|info| Arc::clone(info.value()))
680                .filter(|info| !info.no_search)
681        })
682    }
683
684    fn public_keys_for_stock_id_locked(&self, stock_id: u64) -> HashSet<String> {
685        if stock_id == 0 {
686            return HashSet::new();
687        }
688        let mut keys = self.security_request_alias_keys_locked(stock_id);
689        if let Some(canonical) = self.id_to_key.get(&stock_id) {
690            keys.insert(canonical.clone());
691        }
692        keys
693    }
694
695    fn reconcile_public_stock_id_keys_locked(
696        &self,
697        stock_id: u64,
698        old_keys: &HashSet<String>,
699        new_keys: &HashSet<String>,
700    ) {
701        debug_assert!(stock_id > 0);
702        for key in old_keys.difference(new_keys) {
703            let remove_entry =
704                if let Some(mut stock_ids) = self.public_stock_ids_by_key.get_mut(key) {
705                    stock_ids.remove(&stock_id);
706                    stock_ids.is_empty()
707                } else {
708                    false
709                };
710            if remove_entry {
711                self.public_stock_ids_by_key.remove(key);
712            }
713        }
714        for key in new_keys.difference(old_keys) {
715            self.public_stock_ids_by_key
716                .entry(key.clone())
717                .or_default()
718                .insert(stock_id);
719        }
720    }
721
722    #[cfg(test)]
723    pub(super) fn public_identity_index_matches_sources(&self) -> bool {
724        let mut expected =
725            std::collections::HashMap::<String, std::collections::BTreeSet<u64>>::new();
726        for mapped in &self.id_to_key {
727            if *mapped.key() > 0 {
728                expected
729                    .entry(mapped.value().clone())
730                    .or_default()
731                    .insert(*mapped.key());
732            }
733        }
734        for aliases in &self.security_request_aliases {
735            for alias in aliases.value() {
736                expected
737                    .entry(alias.clone())
738                    .or_default()
739                    .insert(*aliases.key());
740            }
741        }
742        let actual = self
743            .public_stock_ids_by_key
744            .iter()
745            .map(|entry| (entry.key().clone(), entry.value().clone()))
746            .collect::<std::collections::HashMap<_, _>>();
747        actual == expected
748    }
749
750    #[cfg(test)]
751    pub(super) fn reset_public_security_candidate_inspections(&self) {
752        self.public_security_candidate_inspections
753            .store(0, std::sync::atomic::Ordering::Relaxed);
754    }
755
756    #[cfg(test)]
757    pub(super) fn public_security_candidate_inspections(&self) -> u64 {
758        self.public_security_candidate_inspections
759            .load(std::sync::atomic::Ordering::Relaxed)
760    }
761
762    fn security_request_alias_keys_locked(&self, stock_id: u64) -> HashSet<String> {
763        self.security_request_aliases
764            .get(&stock_id)
765            .map(|aliases| aliases.clone())
766            .unwrap_or_default()
767    }
768
769    fn remove_security_request_alias_locked(&self, stock_id: u64, key: &str) {
770        let remove_entry =
771            if let Some(mut aliases) = self.security_request_aliases.get_mut(&stock_id) {
772                aliases.remove(key);
773                aliases.is_empty()
774            } else {
775                false
776            };
777        if remove_entry {
778            self.security_request_aliases.remove(&stock_id);
779        }
780    }
781
782    pub(super) fn refresh_public_keys_for_stock_id_locked(&self, stock_id: u64) {
783        for key in self.public_keys_for_stock_id_locked(stock_id) {
784            self.refresh_public_security_locked(&key);
785        }
786    }
787
788    fn refresh_public_security_locked(&self, key: &str) {
789        if let Some(candidate) = self.public_security_candidate_locked(key) {
790            if !candidate.needs_mkt_id_refresh() {
791                self.stale_mkt_ids.remove(key);
792            }
793            self.securities.insert(key.to_string(), candidate);
794        } else if self
795            .securities
796            .get(key)
797            .is_some_and(|current| current.stock_id > 0)
798        {
799            self.securities.remove(key);
800            self.stale_mkt_ids.remove(key);
801        }
802        self.refresh_public_crypto_pair_locked(key);
803    }
804}