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_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}