Skip to main content

futu_cache/
price_reminder_cooldown.rs

1//! v1.4.106 codex 0450 F3 (P2): PriceReminder 数量上限 cooldown cache.
2//!
3//! 抽自 `crates/futu-gateway-qot/src/handlers/qot/price_reminder.rs` (audit 0450
4//! 整体重构落点) — 不允许 inline integration, 必须独立 module + bridge.rs
5//! field 注入.
6//!
7//! ## C++ 来源
8//!
9//! `o-src/FutuOpenD/Src/NNDataCenter/Quote/NNData_PriceReminder.cpp:101-164`:
10//!
11//! - `SetTotalPriceReminderNumExceed(enLimitMkt)` — 在 backend ack
12//!   `RSP_CODE_NUM_LIMIT (=2)` 时, 把 `(enLimitMkt → svr_now)` 写进
13//!   `m_mapNumExceedTime`.
14//! - `IsTotalPriceReminderNumExceed(enLimitMkt)` — Add 前检查 cooldown 是否
15//!   仍在 10 min 内 (`PriceReminderRestrictTime = 60*10`); 是则 short-circuit
16//!   不再 call backend.
17//! - `SetStockPriceReminderNumExceed(stock_id, type)` / `IsStockPriceReminderNumExceed`
18//!   — 同样语义但按 (stock_id, reminder_type) 维度.
19//! - `ResetTotalPriceReminderNumExceed` — 在 Del / DelAll 成功 ack 后清 cooldown.
20//! - `ResetStockPriceReminderNumExceed(stock_id, type)` — Del 时按 type 清,
21//!   DelAll 时 (type=None) 清整个 stock 下所有.
22//!
23//! ## ack-then-commit 语义 (F1)
24//!
25//! cooldown 仅在 backend ack 后写入 — 不能在 client 请求 in-flight 期间
26//! 提前 commit. 这与 C++ `OnOMEvent_Reply_SetPriceReminder` (line 1007-1014)
27//! 完全对齐.
28//!
29//! ## 全部限流市场分类 (来自 C++ APIServer_Qot_PriceReminder.cpp:235-279
30//! `GetPriceReminderLimitMarket`)
31//!
32//! - HK 股 / HK 期权 (按 IsOptionCode 分两类)
33//! - US 股 / US 期权
34//! - CN (沪深)
35//! - SG / JP
36//!
37//! ## 线程模型
38//!
39//! `dashmap::DashMap` 作 lock-free concurrent — 多 handler 并发 Add/Del 安全.
40
41use std::sync::Arc;
42
43use dashmap::DashMap;
44
45/// C++ `PriceReminderRestrictTime = 60 * 10` 秒 = 10 分钟.
46///
47/// 对齐 `o-src/FutuOpenD/Src/NNDataCenter/Quote/NNData_PriceReminder.cpp:6`.
48pub const PRICE_REMINDER_RESTRICT_SECS: i64 = 600;
49
50/// `NN_PriceReminderLimitMarket` 枚举 — 来自 C++
51/// `o-src/FutuOpenD/Src/NNData/Quote/Define/NNData_PriceReminder_Define.h`
52/// (推断, C++ 端是常规 enum class).
53///
54/// ## 映射表 (来自 C++ `GetPriceReminderLimitMarket`)
55///
56/// | FTAPI QotMarket | symbol 是否 IsOptionCode | LimitMarket |
57/// |---|---|---|
58/// | HK_Security (1) | true | HK_OPT |
59/// | HK_Security (1) | false | HK |
60/// | US_Security (11) | true | US_OPT |
61/// | US_Security (11) | false | US |
62/// | CNSH (21) / CNSZ (22) | * | CN |
63/// | SG_Security (31) | * | SG |
64/// | JP_Security (41) | * | JP |
65/// | 其他 | * | None |
66#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
67pub enum LimitMarket {
68    /// HK 普通股
69    Hk,
70    /// HK 期权
71    HkOpt,
72    /// US 普通股
73    Us,
74    /// US 期权
75    UsOpt,
76    /// CN (沪深)
77    Cn,
78    /// SG
79    Sg,
80    /// JP
81    Jp,
82}
83
84impl LimitMarket {
85    /// 从 FTAPI `Qot_Common.QotMarket` + symbol 计算 LimitMarket.
86    ///
87    /// 不支持的市场返 `None` — caller 应跳过 cooldown check (后端会拒, 与 C++
88    /// `NN_PriceReminderLimitMarket_None` 行为一致).
89    pub fn from_qot_market_and_code(qot_market: i32, code: &str) -> Option<Self> {
90        let is_option = is_option_code(code);
91        match qot_market {
92            1 => Some(if is_option {
93                LimitMarket::HkOpt
94            } else {
95                LimitMarket::Hk
96            }),
97            11 => Some(if is_option {
98                LimitMarket::UsOpt
99            } else {
100                LimitMarket::Us
101            }),
102            21 | 22 => Some(LimitMarket::Cn),
103            31 => Some(LimitMarket::Sg),
104            41 => Some(LimitMarket::Jp),
105            _ => None,
106        }
107    }
108
109    pub fn as_str(self) -> &'static str {
110        match self {
111            LimitMarket::Hk => "HK",
112            LimitMarket::HkOpt => "HK_OPT",
113            LimitMarket::Us => "US",
114            LimitMarket::UsOpt => "US_OPT",
115            LimitMarket::Cn => "CN",
116            LimitMarket::Sg => "SG",
117            LimitMarket::Jp => "JP",
118        }
119    }
120}
121
122/// 简化版 IsOptionCode — 对齐 C++ `IsOptionCode` (FutuOpenD 多处使用),
123/// 期权 symbol 形如 `"AAPL  240119C00150000"` (US, 含空格 OCC) 或
124/// `"BABA   2401190000023456"` (HK 八位月).
125///
126/// 真正的判定语义在 backend, daemon 这里只用最保守的规则:
127/// - 含空格 (US OCC 格式) → option
128/// - 长度 ≥ 12 (期权 OCC 至少 12 char)
129///
130/// 注意: 这是用来分桶 cooldown 的辅助函数; 如果误判会让 cooldown 落到错的
131/// LimitMarket, 但 backend ack 仍然权威.
132pub(crate) fn is_option_code(code: &str) -> bool {
133    // C++ IsOptionCode 检查的是 "包含 OCC 风格" — 简化为含空格 + 长度 ≥ 12
134    code.len() >= 12 && code.contains(' ')
135}
136
137/// PriceReminder cooldown cache.
138///
139/// 双层结构对齐 C++ `m_mapNumExceedTime` + `m_mapStockExceedTime`:
140/// - `total_exceed`: `LimitMarket → svr_secs` (NUM_LIMIT cooldown)
141/// - `stock_exceed`: `(stock_id, reminder_type) → svr_secs` (TYPE_NUM_LIMIT cooldown)
142///
143/// 所有时间戳是 server clock seconds (与 C++ `GetSvrTimeStamp()` 对齐) —
144/// caller 必须传 server clock derived now, 不能传本机 wall clock (避免
145/// daemon 长跑 + svr time skew 导致 cooldown 提前 / 延迟过期).
146#[derive(Debug, Default)]
147pub struct PriceReminderCooldownCache {
148    total_exceed: DashMap<LimitMarket, i64>,
149    stock_exceed: DashMap<(u64, u32), i64>,
150}
151
152impl PriceReminderCooldownCache {
153    pub fn new() -> Arc<Self> {
154        Arc::new(Self::default())
155    }
156
157    /// Add op 前检查: 该 LimitMarket 是否仍在 10min cooldown 内.
158    ///
159    /// 对齐 C++ `IsTotalPriceReminderNumExceed(enLimitMkt)`.
160    pub fn is_total_exceeded(&self, market: LimitMarket, now_secs: i64) -> bool {
161        if let Some(entry) = self.total_exceed.get(&market) {
162            now_secs - *entry <= PRICE_REMINDER_RESTRICT_SECS
163        } else {
164            false
165        }
166    }
167
168    /// Add op 前检查: 该 (stock_id, reminder_type) 是否仍在 10min cooldown 内.
169    ///
170    /// 对齐 C++ `IsStockPriceReminderNumExceed(nSecID, enRemindType)`.
171    pub fn is_stock_exceeded(&self, stock_id: u64, reminder_type: u32, now_secs: i64) -> bool {
172        if let Some(entry) = self.stock_exceed.get(&(stock_id, reminder_type)) {
173            now_secs - *entry <= PRICE_REMINDER_RESTRICT_SECS
174        } else {
175            false
176        }
177    }
178
179    /// backend ack `RSP_CODE_NUM_LIMIT (=2)` 后 commit cooldown — F1
180    /// ack-then-commit 语义.
181    ///
182    /// 对齐 C++ `SetTotalPriceReminderNumExceed`.
183    pub fn commit_total_exceeded(&self, market: LimitMarket, svr_now_secs: i64) {
184        self.total_exceed.insert(market, svr_now_secs);
185    }
186
187    /// backend ack `RSP_CODE_TYPE_NUM_LIMIT (=4)` 后 commit cooldown — F1
188    /// ack-then-commit 语义.
189    ///
190    /// 对齐 C++ `SetStockPriceReminderNumExceed`.
191    pub fn commit_stock_exceeded(&self, stock_id: u64, reminder_type: u32, svr_now_secs: i64) {
192        self.stock_exceed
193            .insert((stock_id, reminder_type), svr_now_secs);
194    }
195
196    /// Del / DelAll 成功后 reset 该 LimitMarket cooldown.
197    ///
198    /// 对齐 C++ `ResetTotalPriceReminderNumExceed`.
199    pub fn reset_total_exceeded(&self, market: LimitMarket) {
200        self.total_exceed.remove(&market);
201    }
202
203    /// Del 成功后 reset 该 (stock_id, type) cooldown.
204    ///
205    /// 对齐 C++ `ResetStockPriceReminderNumExceed(nSecID, enRemindType)`
206    /// (非 None 分支).
207    pub fn reset_stock_exceeded(&self, stock_id: u64, reminder_type: u32) {
208        self.stock_exceed.remove(&(stock_id, reminder_type));
209    }
210
211    /// DelAll 成功后 reset 该 stock_id 下所有 reminder_type cooldown.
212    ///
213    /// 对齐 C++ `ResetStockPriceReminderNumExceed(nSecID, NN_PriceReminderType_None)`
214    /// 分支 — 整 stock 一起 erase.
215    pub fn reset_stock_all(&self, stock_id: u64) {
216        // dashmap retain → keep 不属于该 stock 的 entries
217        self.stock_exceed.retain(|(sid, _), _| *sid != stock_id);
218    }
219
220    /// 测试用: 看缓存里有几条 entries.
221    #[cfg(test)]
222    pub fn total_len(&self) -> usize {
223        self.total_exceed.len()
224    }
225
226    /// 测试用: 看缓存里有几条 entries.
227    #[cfg(test)]
228    pub fn stock_len(&self) -> usize {
229        self.stock_exceed.len()
230    }
231}
232
233#[cfg(test)]
234mod tests;