Skip to main content

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}