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