futu_cache/security_resolver.rs
1//! v1.4.106 codex 1148 F5 (P2): 统一 stock_id ↔ Security 双向解析入口。
2//!
3//! C++ 对照:
4//! - `APIServer_Inner_API.cpp:604` `GetStockID(Security, &stock_id)` — Security → stock_id
5//! - `APIServer_Inner_API.cpp:669` `GetAPIStock(stock_id, &Security)` — stock_id → Security
6//! - 两者底层都查 `INNBiz_Qot_SecList::SearchSecBy{Code,ID}`, 失败语义清晰
7//! (`enReturnRet != Ok` 即 fail), 不 silent drop。
8//!
9//! Rust 既有实装把 `id_to_key` / `securities` 直接 `pub` 出去, push parser /
10//! stock-filter / warrant / reference 各自查 + 各自处理 miss, **静默 drop**
11//! (filter_map / continue / 空 list 返客户端) → 后端返合法 stock_id 时无法
12//! 区分 "本地 cache miss" vs "后端真返空", 难调试 + 用户感知差。
13//!
14//! 本 module 提供:
15//! 1. **bidir API** — `resolve_stock_id_by_security` / `resolve_security_by_stock_id`
16//! 2. **loud miss** — miss 时 bump counter (`resolver_miss_total`), 加 stale
17//! mark (复用 mkt_id refresh 路径)
18//! 3. **明确返 enum 区分 hit / miss / unsupported** — caller 必处理 miss
19//! (要么 reject, 要么 partial marker, 要么 trigger refresh)
20//!
21//! Caller 重构 (后续 PR):
22//! - `bridge/push_parser.rs` quote push → resolver miss 时 counter + 不 silent drop
23//! - `handlers/qot/stock_filter.rs` → resolver miss 时返 partial 而非空 list
24//! - `handlers/qot/warrant.rs` → reverse-lookup miss 时 reject (与 owner forward
25//! miss 一致)
26//! - `handlers/qot/misc.rs` → reverse-lookup miss 时 reject
27
28use std::sync::Arc;
29use std::sync::atomic::{AtomicU64, Ordering};
30
31use crate::static_data::{CachedSecurityInfo, StaticDataCache};
32
33/// stock_id ↔ Security 解析失败的原因。
34///
35/// 调用方据此选择 `Reject` (loud error) / `Refresh` (重 trigger) /
36/// `PartialResponse` (返 partial marker)。
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum ResolverErr {
39 /// `stock_id` 不在反向索引 (`id_to_key`)。可能是:
40 /// - stock-list 还没 sync 到 (启动早期 / SQLite 丢失)
41 /// - stock_id 是后端新枚举值, 本地 cache 没收到 push
42 StockIdNotInCache(u64),
43 /// `Security` (market+code) 不在正向索引 (`securities`)。
44 /// - Symbol 还没 subscribe (on-demand fetch 没触发)
45 /// - Code 拼写错 (`HK.00700` vs `1_00700`)
46 SecurityNotInCache { market: i32, code: String },
47 /// `Security.market` 不是 daemon 支持的 enum 值 (e.g. 0 / 99)。
48 UnsupportedMarket(i32),
49 /// `stock_id == 0` — backend 返空 ID, 这是 silent-success bug 信号。
50 StockIdZero,
51}
52
53impl std::fmt::Display for ResolverErr {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 match self {
56 ResolverErr::StockIdNotInCache(id) => {
57 write!(f, "stock_id={id} not in cache (id_to_key miss)")
58 }
59 ResolverErr::SecurityNotInCache { market, code } => {
60 write!(f, "Security {market}.{code} not in cache (securities miss)")
61 }
62 ResolverErr::UnsupportedMarket(m) => write!(f, "unsupported market={m}"),
63 ResolverErr::StockIdZero => write!(f, "stock_id=0 (backend returned empty id)"),
64 }
65 }
66}
67
68impl std::error::Error for ResolverErr {}
69
70/// v1.4.106 codex 1148 F5 (P2): 统一 stock_id ↔ Security 双向解析。
71///
72/// 使用 `Arc<StaticDataCache>` 作为底层存储, 不重复持有 cache state — 与现
73/// 有 cache 完全 inter-op。可在 handler 构造时 `Arc::new(SecurityResolver::new(
74/// cache.clone()))` 持有, handler 全程调 resolver method 而不直接 touch
75/// cache 字段。
76pub struct SecurityResolver {
77 cache: Arc<StaticDataCache>,
78 /// 累计 resolver miss 次数 (forward + reverse 合计)。给 metrics observability。
79 resolver_miss_total: AtomicU64,
80 /// 累计 resolver hit 次数。
81 resolver_hit_total: AtomicU64,
82}
83
84impl SecurityResolver {
85 pub fn new(cache: Arc<StaticDataCache>) -> Self {
86 Self {
87 cache,
88 resolver_miss_total: AtomicU64::new(0),
89 resolver_hit_total: AtomicU64::new(0),
90 }
91 }
92
93 /// 借用底层 cache (caller 仍能直接 query 其他 method, 比如 trade_dates)。
94 pub fn cache(&self) -> &Arc<StaticDataCache> {
95 &self.cache
96 }
97
98 /// 累计 miss 数。
99 pub fn miss_total(&self) -> u64 {
100 self.resolver_miss_total.load(Ordering::Relaxed)
101 }
102
103 /// 累计 hit 数。
104 pub fn hit_total(&self) -> u64 {
105 self.resolver_hit_total.load(Ordering::Relaxed)
106 }
107
108 /// **C++ GetStockID 等价**: `Security` → `stock_id`。
109 ///
110 /// Cache hit + `info.stock_id > 0` → `Ok(stock_id)`;
111 /// 其他情况返 `ResolverErr` (caller 必处理)。
112 ///
113 /// 同时**触发 mkt_id refresh mark** (若 hit 但 mkt_id stale)。
114 pub fn resolve_stock_id_by_security(
115 &self,
116 security: &SecurityRef<'_>,
117 ) -> Result<u64, ResolverErr> {
118 if security.market <= 0 {
119 self.bump_miss();
120 return Err(ResolverErr::UnsupportedMarket(security.market));
121 }
122 let key = format!("{}_{}", security.market, security.code);
123 match self.cache.get_security_info_trigger_refresh(&key) {
124 Some(info) if info.stock_id > 0 => {
125 self.bump_hit();
126 Ok(info.stock_id)
127 }
128 Some(_) => {
129 // info exists but stock_id=0 — 半索引行 / on-demand basic 写入失败
130 self.bump_miss();
131 Err(ResolverErr::StockIdZero)
132 }
133 None => {
134 self.bump_miss();
135 Err(ResolverErr::SecurityNotInCache {
136 market: security.market,
137 code: security.code.to_string(),
138 })
139 }
140 }
141 }
142
143 /// **C++ GetAPIStock 等价**: `stock_id` → `Security`。
144 ///
145 /// Cache hit (id_to_key 含 stock_id 且 securities 含对应 row) → `Ok((market, code))`;
146 /// 其他情况返 `ResolverErr`。
147 ///
148 /// 同时返 `info` 引用让 caller 复用 row 字段 (无需第二次 lookup)。
149 pub fn resolve_security_by_stock_id(
150 &self,
151 stock_id: u64,
152 ) -> Result<CachedSecurityInfo, ResolverErr> {
153 if stock_id == 0 {
154 self.bump_miss();
155 return Err(ResolverErr::StockIdZero);
156 }
157 match self.cache.get_security_info_by_stock_id(stock_id) {
158 Some(info) => {
159 // F4: market=0 不该出现在公开 API resolver 路径 (bridge 已 reject)
160 // 但 SQLite 历史 row 可能含, 这里防御
161 if info.market <= 0 {
162 self.bump_miss();
163 return Err(ResolverErr::UnsupportedMarket(info.market));
164 }
165 self.bump_hit();
166 Ok(info)
167 }
168 None => {
169 self.bump_miss();
170 Err(ResolverErr::StockIdNotInCache(stock_id))
171 }
172 }
173 }
174
175 fn bump_hit(&self) {
176 self.resolver_hit_total.fetch_add(1, Ordering::Relaxed);
177 }
178
179 fn bump_miss(&self) {
180 self.resolver_miss_total.fetch_add(1, Ordering::Relaxed);
181 }
182}
183
184/// 轻量 Security 引用 (避免 caller 必须用 prost-generated 类型)。
185#[derive(Debug, Clone, Copy)]
186pub struct SecurityRef<'a> {
187 pub market: i32,
188 pub code: &'a str,
189}
190
191impl<'a> SecurityRef<'a> {
192 pub fn new(market: i32, code: &'a str) -> Self {
193 Self { market, code }
194 }
195}
196
197#[cfg(test)]
198mod tests;