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;