futu_cache/trd_cache.rs
1// 交易数据缓存
2
3mod backend_merge;
4mod cipher_exchange;
5mod freshness;
6mod jp_sub_account;
7mod order_broker;
8mod order_fill_upsert;
9mod order_list;
10mod order_state;
11mod order_upsert;
12mod snapshots;
13mod trade_write_lookup;
14mod types;
15
16#[cfg(test)]
17mod regression_tests;
18
19pub use cipher_exchange::{
20 CipherExchangeAccount, CipherExchangeError, CipherExchangeLease, CipherExchangePublishReport,
21};
22pub use freshness::{FundsSnapshotLookup, PositionsSnapshotLookup, StampedTradeSnapshot};
23pub use order_list::OrphanOrder;
24pub use types::*;
25
26use arc_swap::ArcSwap;
27use dashmap::DashMap;
28use futu_core::account_locator;
29use futu_domain_trade_account::{
30 GlobalStateTradeLoginAccountFacts, required_global_state_trade_login_broker_like_cpp,
31};
32use std::collections::{BTreeSet, HashSet};
33use std::sync::Arc;
34use std::sync::atomic::{AtomicBool, Ordering};
35
36/// 交易数据缓存
37pub struct TrdCache {
38 /// C++ `NNData_Trd_AccList::m_mapUserAccList` equivalent.
39 ///
40 /// This is the authoritative internal account index used by request
41 /// validation, broker routing, and funds/positions/order queries. It may
42 /// contain universal parent accounts that are excluded from the public
43 /// `Trd_GetAccList` projection.
44 pub accounts: DashMap<AccKey, CachedTrdAcc>,
45 /// C++ `NNData_Trd_AccList::m_mapIDRelation` equivalent:
46 /// `universal_or_self_acc_id -> public sub account ids`.
47 ///
48 /// `Trd_GetAccList` uses this relation via `get_accounts()` to expose the
49 /// same public projection as C++ `GetAllSubAccList`, while `lookup_account`
50 /// and direct `accounts.get()` still see the full internal map.
51 pub account_relations: DashMap<AccKey, Vec<AccKey>>,
52 /// Public account ids derived from `account_relations`.
53 pub public_account_ids: DashMap<AccKey, ()>,
54 /// Immutable C++ `IsTradeConnLogin()` required-broker snapshot.
55 ///
56 /// This is derived directly from one `accounts + relations` submission,
57 /// then published once after the account maps finish updating. Readers see
58 /// either the previous complete set or the new complete set, never a
59 /// clear/insert intermediate state.
60 required_trade_login_broker_ids: ArcSwap<BTreeSet<u32>>,
61 /// JP sub-account id -> public FTAPI `TrdSubAccType`.
62 ///
63 /// C++ `OrderData_NNToAPI` / `OrderFillData_NNToAPI` expose
64 /// `nnOrder.enSubAccType` as `Order.jpAccType` / `OrderFill.jpAccType`.
65 /// Rust backend order rows carry the account-protocol sub-account id, so
66 /// the bridge stores this side index from CMD2282 account metadata.
67 jp_sub_account_types: DashMap<(AccKey, u64), i32>,
68 public_projection_ready: AtomicBool,
69 /// 资金: `FundsCacheKey { acc_id, asset_category, currency }` → funds.
70 /// **v1.4.106 Finding A**: 之前 `DashMap<AccKey, CachedFunds>` 一 acc 一 snapshot,
71 /// Universal/Futures 多币种场景被覆盖 — 改 currency-aware key 对齐 C++
72 /// `m_mapAccFund: NN_AssetKey -> NN_TrdCurrency -> Ndt_Trd_AccFund`.
73 pub funds: DashMap<FundsCacheKey, StampedTradeSnapshot<CachedFunds>>,
74 /// 持仓: `PositionsCacheKey { acc_id, asset_category, currency }` → Vec<position>.
75 /// Category 0 preserves the legacy single-bucket path; JP margin and JP
76 /// derivative requests use scoped categories to avoid cross-bucket leakage.
77 pub positions: DashMap<PositionsCacheKey, StampedTradeSnapshot<Vec<CachedPosition>>>,
78 /// 组合持仓视图: `PositionsCacheKey { acc_id, asset_category, currency=None }`
79 /// → Vec<position>.
80 ///
81 /// C++ keeps ordinary and combo views in separate stores
82 /// (`SetPositionList` vs `SetComboPositionList`) and `optionStrategyView`
83 /// chooses which one to read. Keeping the caches separate prevents a combo
84 /// refresh from overwriting ordinary position-list output.
85 pub combo_positions: DashMap<PositionsCacheKey, StampedTradeSnapshot<Vec<CachedPosition>>>,
86 snapshot_freshness: freshness::TradeSnapshotFreshnessStore,
87 /// 当日订单: acc_id → Vec<order>
88 pub orders: DashMap<AccKey, Vec<CachedOrder>>,
89 /// 当日成交: acc_id → Vec<fill>.
90 ///
91 /// C++ `INNData_Trd_Deal` stores direct/query deal rows and suppresses
92 /// unchanged status notifications through `NeedUpdateDeal`.
93 pub order_fills: DashMap<AccKey, Vec<CachedOrderFill>>,
94 /// 交易 cipher、单账户 revision 与连接 epoch freshness。
95 ///
96 /// 记录保留空值 tombstone,确保 unlock / lock / reload / CMD2902 publish
97 /// 的 revision 在并发下单调递增,旧连接响应不能覆盖新 unlock。
98 cipher_records: DashMap<AccKey, cipher_exchange::CipherRecord>,
99 cipher_publication_lock: parking_lot::Mutex<()>,
100 cipher_broker_generations: DashMap<u32, u64>,
101 next_cipher_exchange_request_id: std::sync::atomic::AtomicU64,
102 /// v1.4.48 #1: 订单 broker 映射(order_id_ex → broker_id_used)
103 ///
104 /// 起源:v1.4.47 P0.1 修了 PlaceOrder 按 `sec_market` 选 broker,但 ModifyOrder /
105 /// CancelOrder 仍按 `account.security_firm` 选 broker,导致"在 broker 1007 (US)
106 /// 下的单,cancel 去 broker 1019 (CA) 拒" 的 cross-broker 故障。
107 ///
108 /// 修法:PlaceOrder 成功后把 `(order_id_ex, broker_id_used)` 缓存到这里。
109 /// ModifyOrder / CancelOrder 拿到 `c2s.order_id_ex` 后先查 broker_id;
110 /// 命中 → 路由到同 broker;未命中 → fallback account.firm 路由。
111 /// 订单快照更新后会按当前缓存订单 GC stale entry,避免 daemon 长跑时每单
112 /// 一个 string key 永久累积。
113 ///
114 /// 注:cipher 按 sub-account `acc_id` 存储(`ciphers` map)。对照 C++
115 /// `NNData_Trd_AccList::m_mapAccCipher`:不同 broker 的账户天然有不同
116 /// `nAccID`,存储已隔离(v1.4.49 清理了 v1.4.48 `cipher_brokers` workaround,
117 /// 该字段在 v1.4.48 #11 routing 对齐 C++ 后成 dead code)。
118 pub order_brokers: DashMap<String, u32>,
119 /// `order_brokers` 的账号归属索引:order_id_ex → acc_id。
120 ///
121 /// `order_brokers` 本身保持既有 `order_id_ex -> broker_id` 读取契约,供
122 /// ModifyOrder / CancelOrder O(1) 路由。这个索引只用于按单个 acc_id 做
123 /// stale broker mapping GC,避免每次订单刷新都扫描所有账户的订单快照。
124 order_broker_accounts: DashMap<String, AccKey>,
125 /// `order_broker_accounts` 的反向索引:acc_id → order_id_ex set。
126 ///
127 /// `update_orders` / `merge_preserving_stubs` 都是单账号刷新;用这个索引
128 /// 可以只遍历该账号曾记录过的 broker mappings,避免在每次账号刷新时
129 /// 扫描全量 `order_broker_accounts`。
130 order_broker_ids_by_acc: DashMap<AccKey, HashSet<String>>,
131
132 /// C++ `NNProto_Trd_OnPush::m_mapReqIDOrderID` equivalent:
133 /// backend write `MsgHeader.req_id` -> backend `order_id`.
134 ///
135 /// C++ stores this after successful place/modify/cancel ACK and consumes it
136 /// when `NOTICE_TYPE_ORDER_OP_RESULT` only carries `order_op_req_ids`. It is
137 /// a best-effort helper for an extra order-detail refresh, not the primary
138 /// order state source.
139 pub order_op_req_orders: DashMap<OrderOpReqKey, String>,
140
141 /// v1.4.73 A2 BUG-008 fix: per-account cipher state version counter。
142 ///
143 /// 外部 tester (v1.4.71) AI 报告 5 步 repro:
144 /// ```text
145 /// Step 1: unlock pwd → cache EXECUTED (idem_key=unlock-xxx)
146 /// Step 2: 同 body → cache HIT (正常幂等)
147 /// Step 3: EMPTY {} LOCK → v1.4.39 cipher 清
148 /// Step 4: 同 body → cache HIT 返 stale 成功! (真 bug)
149 /// Step 5: place-order → -401 "交易未解锁"
150 /// ```
151 ///
152 /// v1.4.72 Option C(空 body 不写 cache)只防 step 3 污染,未修 step 4 stale。
153 ///
154 /// Option A 真修:unlock `idem_key` 构造时纳入**当前 cipher_state_version**,
155 /// lock 清 cipher 时 `fetch_add(1, SeqCst)` → version 递增 → step 4 同 body
156 /// 得 idem_key **不同**(version=0 → version=1)→ cache miss → 真执行 unlock
157 /// 或 backend 校验失败返清晰错误。
158 ///
159 /// 为啥 SeqCst:unlock_trade handler 可能并发,确保 version 递增对所有
160 /// 后续 idem_key 构造 visible(`ciphers.remove()` + `fetch_add()` 顺序严格)。
161 ///
162 /// 注:version 不持久化 —— daemon restart 重新从 0 开始,等效于"新 cache",
163 /// 之前的 idem entries 也被 cache TTL 清光,零冲突。
164 pub cipher_state_versions: DashMap<AccKey, Arc<std::sync::atomic::AtomicU64>>,
165
166 /// v1.4.106 codex 0226 F1+F2: pending OrderConfirm context per
167 /// `(acc_id, ftapi_order_id)`.
168 ///
169 /// PlaceOrder ack 响应里若 `OrderNewRsp.action.type == ORDER_CONFIRM=5` 且
170 /// `action.order_confirm.is_some()`, daemon **必须** capture
171 /// `CltActionOrderConfirm` 字段, 用于后续 `Trd_ReconfirmOrder` 处理时构造
172 /// backend `OrderConfirmReq` (cmd 4728).
173 ///
174 /// **生命周期**:
175 /// - PlaceOrder ack 路径: capture 后 `insert(key, ctx)`
176 /// - ReconfirmOrder handler: lookup → 构造 backend req → 收到 `OrderConfirmRsp`
177 /// `result==0` 后 `remove(key)` (一次性消费, 防止重复 confirm)
178 /// - TTL: 5min (`ORDER_CONFIRM_CONTEXT_TTL_MS`), `now - inserted_at_ms` 检查;
179 /// stale entry handler 拒绝 + GC 清理
180 /// - daemon restart 全清 (内存 cache, backend 重新发 PlaceOrder 即可获新 context)
181 ///
182 /// 详见 `OrderConfirmContext` doc.
183 pub pending_order_confirms: DashMap<OrderConfirmKey, OrderConfirmContext>,
184}
185
186impl TrdCache {
187 pub fn new() -> Self {
188 Self {
189 accounts: DashMap::new(),
190 account_relations: DashMap::new(),
191 public_account_ids: DashMap::new(),
192 required_trade_login_broker_ids: ArcSwap::from_pointee(BTreeSet::new()),
193 jp_sub_account_types: DashMap::new(),
194 public_projection_ready: AtomicBool::new(false),
195 funds: DashMap::new(),
196 positions: DashMap::new(),
197 combo_positions: DashMap::new(),
198 snapshot_freshness: freshness::TradeSnapshotFreshnessStore::new(),
199 orders: DashMap::new(),
200 order_fills: DashMap::new(),
201 cipher_records: DashMap::new(),
202 cipher_publication_lock: parking_lot::Mutex::new(()),
203 cipher_broker_generations: DashMap::new(),
204 next_cipher_exchange_request_id: std::sync::atomic::AtomicU64::new(0),
205 order_brokers: DashMap::new(),
206 order_broker_accounts: DashMap::new(),
207 order_broker_ids_by_acc: DashMap::new(),
208 order_op_req_orders: DashMap::new(),
209 cipher_state_versions: DashMap::new(),
210 // v1.4.106 codex 0226 F1+F2: pending OrderConfirm context cache
211 pending_order_confirms: DashMap::new(),
212 }
213 }
214
215 pub fn set_accounts(&self, accounts: Vec<CachedTrdAcc>) {
216 let relations = accounts
217 .iter()
218 .map(|acc| (acc.acc_id, vec![acc.acc_id]))
219 .collect();
220 self.set_accounts_with_relations(accounts, relations);
221 }
222
223 /// Atomically replace the internal account map and the public projection.
224 ///
225 /// `relations` mirrors C++ `m_mapIDRelation`: standalone accounts map to
226 /// themselves, while universal parents map to their public sub accounts.
227 /// This lets `GetAccList` expose only C++ `GetAllSubAccList` output without
228 /// losing hidden parent accounts needed by `GetAccItem`-style request paths.
229 pub fn set_accounts_with_relations(
230 &self,
231 accounts: Vec<CachedTrdAcc>,
232 relations: Vec<(AccKey, Vec<AccKey>)>,
233 ) {
234 let relation_parent_ids = relations
235 .iter()
236 .map(|(parent_id, _)| *parent_id)
237 .collect::<HashSet<_>>();
238 let required_trade_login_broker_ids = accounts
239 .iter()
240 .filter_map(|account| {
241 required_global_state_trade_login_broker_like_cpp(
242 GlobalStateTradeLoginAccountFacts {
243 is_relation_parent: relation_parent_ids.contains(&account.acc_id),
244 trd_env: account.trd_env,
245 security_firm: account.security_firm,
246 sort_key: account.sort_key,
247 },
248 )
249 })
250 .collect::<BTreeSet<_>>();
251
252 self.accounts.clear();
253 self.account_relations.clear();
254 self.public_account_ids.clear();
255 self.jp_sub_account_types.clear();
256 for (idx, mut acc) in accounts.into_iter().enumerate() {
257 acc.order_index = idx;
258 self.accounts.insert(acc.acc_id, acc);
259 }
260 for (parent_id, sub_ids) in relations {
261 for sub_id in &sub_ids {
262 self.public_account_ids.insert(*sub_id, ());
263 }
264 self.account_relations.insert(parent_id, sub_ids);
265 }
266 self.public_projection_ready.store(true, Ordering::SeqCst);
267 self.required_trade_login_broker_ids
268 .store(Arc::new(required_trade_login_broker_ids));
269 }
270
271 /// Return the complete immutable broker set required by C++
272 /// `IsTradeConnLogin()` for the latest committed account snapshot.
273 #[must_use]
274 pub fn required_trade_login_broker_ids(&self) -> Arc<BTreeSet<u32>> {
275 self.required_trade_login_broker_ids.load_full()
276 }
277
278 #[must_use]
279 pub fn get_accounts(&self) -> Vec<CachedTrdAcc> {
280 if self.public_projection_ready.load(Ordering::SeqCst) {
281 self.public_account_ids
282 .iter()
283 .filter_map(|e| self.accounts.get(e.key()).map(|acc| acc.value().clone()))
284 .collect()
285 } else {
286 // Backward-compatible test path: many existing tests insert directly
287 // into `cache.accounts`. Until production calls set_accounts*, expose
288 // all entries, matching the old single-map behavior.
289 self.accounts.iter().map(|e| e.value().clone()).collect()
290 }
291 }
292
293 /// Resolve backend/mobile native account id for request bodies.
294 ///
295 /// `CachedTrdAcc::intra_acc_id` is the authoritative backend-native id
296 /// from CMD2282. The public FTAPI `acc_id` low 32 bits are only the legacy
297 /// fallback for old cache entries/tests that do not carry `intra_acc_id`.
298 #[must_use]
299 pub fn backend_native_account_id_or_public_low_bits(&self, acc_id: AccKey) -> u64 {
300 self.accounts
301 .get(&acc_id)
302 .and_then(|acc| acc.intra_acc_id)
303 .filter(|intra| *intra != 0)
304 .unwrap_or(acc_id & 0xFFFF_FFFF)
305 }
306
307 /// Resolve backend-native account id, but require the account to exist in cache.
308 #[must_use]
309 pub fn backend_native_account_id_for_existing_account(&self, acc_id: AccKey) -> Option<u64> {
310 self.accounts
311 .contains_key(&acc_id)
312 .then(|| self.backend_native_account_id_or_public_low_bits(acc_id))
313 }
314
315 /// v1.4.106 codex 0932 F2 [P1]: 单 acc_id O(1) 查询 (DashMap key 直查).
316 ///
317 /// 用途: push_builder 构造 Trd_UpdateOrder / Trd_UpdateOrderFill header
318 /// 之前 resolve `trd_env` + `trd_market`. 对齐 C++
319 /// `INNData_Trd_AllAccList::GetAccEnv(nAccID)` / `GetAccMkt(nAccID)`.
320 ///
321 /// 返 `None` = cache miss (账户不在交易 cache 中). caller **必须 loud
322 /// return** 不 fallback (sentinel 0 让 client filter reject =
323 /// silent-success 反模式).
324 #[must_use]
325 pub fn lookup_account(&self, acc_id: u64) -> Option<CachedTrdAcc> {
326 self.accounts.get(&acc_id).map(|e| e.value().clone())
327 }
328
329 /// v1.4.103 (B10): card_num → acc_id resolution helper.
330 ///
331 /// 接受输入:
332 /// - **16 位完整 card_num** (`"1001100100800000"`): 完全匹配 `card_num` 字段.
333 /// - **4 位末尾 suffix** (`"7680"`): 匹配 `card_num` 末 4 位 (App 显示格式).
334 ///
335 /// 返 `Vec<u64>` (matching acc_ids):
336 /// - 0 个 → cache 中无 match (caller 决定 warn / abort);
337 /// - 1 个 → unique resolution;
338 /// - >= 2 个 → ambiguous (caller 必须 reject + log 候选, 不能 silent 接受).
339 ///
340 /// **空字符串 / 非纯数字 / 长度非 4 / 非 16** → 返 empty Vec (不 panic).
341 /// 这是为了让 caller 输入校验 + resolution 双责权: 调用方应该已经校验过格式.
342 #[must_use]
343 pub fn find_acc_ids_by_card_num(&self, input: &str) -> Vec<u64> {
344 // v1.4.103 codex F2.3 (P2): 同时匹配 `card_num` 和 `uni_card_num`
345 // (综合账户卡号). 用户故事 B10 描述 App 显示的`保证金综合账户(7680)`末
346 // 4 位 — 综合账户的卡号通常 in `uni_card_num`, 普通账户在 `card_num`.
347 // 单独只看 `card_num` 会让综合账户用户写 `--allowed-card-nums 7680`
348 // 时所有 resolve 都失败 → fail-closed sentinel reject (虽然安全, 但
349 // UX 失效, 用户必须 fall back 用 acc_id). 双匹配后 fail-closed
350 // sentinel 只在真没账户 match 时触发.
351 // v1.4.111 P2-1 Tier 3 audit comment: fail-closed by-design — empty Vec
352 // 表示 "no card_num match", caller (e.g. allowed_card_nums whitelist
353 // resolver) 把 empty 当 sentinel reject (v1.4.103 codex F2.3 P2 沉淀),
354 // **不**是 silent accept. 非 silent-success risk (audit verified).
355 let Ok(query) = account_locator::validate_card_num_query(input) else {
356 return Vec::new();
357 };
358 let mut matches = Vec::new();
359 if self.public_projection_ready.load(Ordering::SeqCst) {
360 for public_id in self.public_account_ids.iter() {
361 if let Some(acc) = self.accounts.get(public_id.key())
362 && account_locator::account_matches_card_num(acc.value(), query)
363 {
364 matches.push(acc.value().acc_id);
365 }
366 }
367 } else {
368 for acc in self.accounts.iter() {
369 if account_locator::account_matches_card_num(acc.value(), query) {
370 matches.push(acc.value().acc_id);
371 }
372 }
373 }
374 matches.sort_unstable();
375 matches.dedup();
376 matches
377 }
378
379 pub fn update_orders(&self, acc_id: u64, orders: Vec<CachedOrder>) {
380 self.orders.insert(acc_id, orders);
381 self.prune_order_brokers_for_acc(acc_id);
382 }
383}
384
385impl Default for TrdCache {
386 fn default() -> Self {
387 Self::new()
388 }
389}