Skip to main content

futu_cache/
trd_cache.rs

1// 交易数据缓存
2
3mod jp_sub_account;
4mod order_list;
5mod order_state;
6mod types;
7
8#[cfg(test)]
9mod regression_tests;
10
11pub use order_list::OrphanOrder;
12pub use types::*;
13
14use dashmap::DashMap;
15use futu_core::account_locator;
16use std::collections::HashSet;
17use std::sync::Arc;
18use std::sync::atomic::{AtomicBool, Ordering};
19
20/// 交易数据缓存
21pub struct TrdCache {
22    /// C++ `NNData_Trd_AccList::m_mapUserAccList` equivalent.
23    ///
24    /// This is the authoritative internal account index used by request
25    /// validation, broker routing, and funds/positions/order queries. It may
26    /// contain universal parent accounts that are excluded from the public
27    /// `Trd_GetAccList` projection.
28    pub accounts: DashMap<AccKey, CachedTrdAcc>,
29    /// C++ `NNData_Trd_AccList::m_mapIDRelation` equivalent:
30    /// `universal_or_self_acc_id -> public sub account ids`.
31    ///
32    /// `Trd_GetAccList` uses this relation via `get_accounts()` to expose the
33    /// same public projection as C++ `GetAllSubAccList`, while `lookup_account`
34    /// and direct `accounts.get()` still see the full internal map.
35    pub account_relations: DashMap<AccKey, Vec<AccKey>>,
36    /// Public account ids derived from `account_relations`.
37    pub public_account_ids: DashMap<AccKey, ()>,
38    /// JP sub-account id -> public FTAPI `TrdSubAccType`.
39    ///
40    /// C++ `OrderData_NNToAPI` / `OrderFillData_NNToAPI` expose
41    /// `nnOrder.enSubAccType` as `Order.jpAccType` / `OrderFill.jpAccType`.
42    /// Rust backend order rows carry the account-protocol sub-account id, so
43    /// the bridge stores this side index from CMD2282 account metadata.
44    jp_sub_account_types: DashMap<(AccKey, u64), i32>,
45    public_projection_ready: AtomicBool,
46    /// 资金: `FundsCacheKey { acc_id, asset_category, currency }` → funds.
47    /// **v1.4.106 Finding A**: 之前 `DashMap<AccKey, CachedFunds>` 一 acc 一 snapshot,
48    /// Universal/Futures 多币种场景被覆盖 — 改 currency-aware key 对齐 C++
49    /// `m_mapAccFund: NN_AssetKey -> NN_TrdCurrency -> Ndt_Trd_AccFund`.
50    pub funds: DashMap<FundsCacheKey, CachedFunds>,
51    /// 持仓: `PositionsCacheKey { acc_id, asset_category }` → Vec<position>.
52    /// Category 0 preserves the legacy single-bucket path; JP margin and JP
53    /// derivative requests use scoped categories to avoid cross-bucket leakage.
54    pub positions: DashMap<PositionsCacheKey, Vec<CachedPosition>>,
55    /// 组合持仓视图: `PositionsCacheKey { acc_id, asset_category }`
56    /// → Vec<position>.
57    ///
58    /// C++ keeps ordinary and combo views in separate stores
59    /// (`SetPositionList` vs `SetComboPositionList`) and `optionStrategyView`
60    /// chooses which one to read. Keeping the caches separate prevents a combo
61    /// refresh from overwriting ordinary position-list output.
62    pub combo_positions: DashMap<PositionsCacheKey, Vec<CachedPosition>>,
63    /// 当日订单: acc_id → Vec<order>
64    pub orders: DashMap<AccKey, Vec<CachedOrder>>,
65    /// 交易 cipher: acc_id → cipher bytes (解锁后获得)
66    pub ciphers: DashMap<AccKey, Vec<u8>>,
67    /// v1.4.48 #1: 订单 broker 映射(order_id_ex → broker_id_used)
68    ///
69    /// 起源:v1.4.47 P0.1 修了 PlaceOrder 按 `sec_market` 选 broker,但 ModifyOrder /
70    /// CancelOrder 仍按 `account.security_firm` 选 broker,导致"在 broker 1007 (US)
71    /// 下的单,cancel 去 broker 1019 (CA) 拒" 的 cross-broker 故障。
72    ///
73    /// 修法:PlaceOrder 成功后把 `(order_id_ex, broker_id_used)` 缓存到这里。
74    /// ModifyOrder / CancelOrder 拿到 `c2s.order_id_ex` 后先查 broker_id;
75    /// 命中 → 路由到同 broker;未命中 → fallback account.firm 路由。
76    /// 订单快照更新后会按当前缓存订单 GC stale entry,避免 daemon 长跑时每单
77    /// 一个 string key 永久累积。
78    ///
79    /// 注:cipher 按 sub-account `acc_id` 存储(`ciphers` map)。对照 C++
80    /// `NNData_Trd_AccList::m_mapAccCipher`:不同 broker 的账户天然有不同
81    /// `nAccID`,存储已隔离(v1.4.49 清理了 v1.4.48 `cipher_brokers` workaround,
82    /// 该字段在 v1.4.48 #11 routing 对齐 C++ 后成 dead code)。
83    pub order_brokers: DashMap<String, u32>,
84    /// `order_brokers` 的账号归属索引:order_id_ex → acc_id。
85    ///
86    /// `order_brokers` 本身保持既有 `order_id_ex -> broker_id` 读取契约,供
87    /// ModifyOrder / CancelOrder O(1) 路由。这个索引只用于按单个 acc_id 做
88    /// stale broker mapping GC,避免每次订单刷新都扫描所有账户的订单快照。
89    order_broker_accounts: DashMap<String, AccKey>,
90    /// `order_broker_accounts` 的反向索引:acc_id → order_id_ex set。
91    ///
92    /// `update_orders` / `merge_preserving_stubs` 都是单账号刷新;用这个索引
93    /// 可以只遍历该账号曾记录过的 broker mappings,避免在每次账号刷新时
94    /// 扫描全量 `order_broker_accounts`。
95    order_broker_ids_by_acc: DashMap<AccKey, HashSet<String>>,
96
97    /// C++ `NNProto_Trd_OnPush::m_mapReqIDOrderID` equivalent:
98    /// backend write `MsgHeader.req_id` -> backend `order_id`.
99    ///
100    /// C++ stores this after successful place/modify/cancel ACK and consumes it
101    /// when `NOTICE_TYPE_ORDER_OP_RESULT` only carries `order_op_req_ids`. It is
102    /// a best-effort helper for an extra order-detail refresh, not the primary
103    /// order state source.
104    pub order_op_req_orders: DashMap<OrderOpReqKey, String>,
105
106    /// v1.4.73 A2 BUG-008 fix: per-account cipher state version counter。
107    ///
108    /// 外部 tester (v1.4.71) AI 报告 5 步 repro:
109    /// ```text
110    /// Step 1: unlock pwd       → cache EXECUTED (idem_key=unlock-xxx)
111    /// Step 2: 同 body          → cache HIT (正常幂等)
112    /// Step 3: EMPTY {} LOCK    → v1.4.39 cipher 清
113    /// Step 4: 同 body          → cache HIT 返 stale 成功! (真 bug)
114    /// Step 5: place-order      → -401 "交易未解锁"
115    /// ```
116    ///
117    /// v1.4.72 Option C(空 body 不写 cache)只防 step 3 污染,未修 step 4 stale。
118    ///
119    /// Option A 真修:unlock `idem_key` 构造时纳入**当前 cipher_state_version**,
120    /// lock 清 cipher 时 `fetch_add(1, SeqCst)` → version 递增 → step 4 同 body
121    /// 得 idem_key **不同**(version=0 → version=1)→ cache miss → 真执行 unlock
122    /// 或 backend 校验失败返清晰错误。
123    ///
124    /// 为啥 SeqCst:unlock_trade handler 可能并发,确保 version 递增对所有
125    /// 后续 idem_key 构造 visible(`ciphers.remove()` + `fetch_add()` 顺序严格)。
126    ///
127    /// 注:version 不持久化 —— daemon restart 重新从 0 开始,等效于"新 cache",
128    /// 之前的 idem entries 也被 cache TTL 清光,零冲突。
129    pub cipher_state_versions: DashMap<AccKey, Arc<std::sync::atomic::AtomicU64>>,
130
131    /// v1.4.106 codex 0226 F1+F2: pending OrderConfirm context per
132    /// `(acc_id, ftapi_order_id)`.
133    ///
134    /// PlaceOrder ack 响应里若 `OrderNewRsp.action.type == ORDER_CONFIRM=5` 且
135    /// `action.order_confirm.is_some()`, daemon **必须** capture
136    /// `CltActionOrderConfirm` 字段, 用于后续 `Trd_ReconfirmOrder` 处理时构造
137    /// backend `OrderConfirmReq` (cmd 4728).
138    ///
139    /// **生命周期**:
140    /// - PlaceOrder ack 路径: capture 后 `insert(key, ctx)`
141    /// - ReconfirmOrder handler: lookup → 构造 backend req → 收到 `OrderConfirmRsp`
142    ///   `result==0` 后 `remove(key)` (一次性消费, 防止重复 confirm)
143    /// - TTL: 5min (`ORDER_CONFIRM_CONTEXT_TTL_MS`), `now - inserted_at_ms` 检查;
144    ///   stale entry handler 拒绝 + GC 清理
145    /// - daemon restart 全清 (内存 cache, backend 重新发 PlaceOrder 即可获新 context)
146    ///
147    /// 详见 `OrderConfirmContext` doc.
148    pub pending_order_confirms: DashMap<OrderConfirmKey, OrderConfirmContext>,
149}
150
151impl TrdCache {
152    pub fn new() -> Self {
153        Self {
154            accounts: DashMap::new(),
155            account_relations: DashMap::new(),
156            public_account_ids: DashMap::new(),
157            jp_sub_account_types: DashMap::new(),
158            public_projection_ready: AtomicBool::new(false),
159            funds: DashMap::new(),
160            positions: DashMap::new(),
161            combo_positions: DashMap::new(),
162            orders: DashMap::new(),
163            ciphers: DashMap::new(),
164            order_brokers: DashMap::new(),
165            order_broker_accounts: DashMap::new(),
166            order_broker_ids_by_acc: DashMap::new(),
167            order_op_req_orders: DashMap::new(),
168            cipher_state_versions: DashMap::new(),
169            // v1.4.106 codex 0226 F1+F2: pending OrderConfirm context cache
170            pending_order_confirms: DashMap::new(),
171        }
172    }
173
174    pub fn set_accounts(&self, accounts: Vec<CachedTrdAcc>) {
175        let relations = accounts
176            .iter()
177            .map(|acc| (acc.acc_id, vec![acc.acc_id]))
178            .collect();
179        self.set_accounts_with_relations(accounts, relations);
180    }
181
182    /// Atomically replace the internal account map and the public projection.
183    ///
184    /// `relations` mirrors C++ `m_mapIDRelation`: standalone accounts map to
185    /// themselves, while universal parents map to their public sub accounts.
186    /// This lets `GetAccList` expose only C++ `GetAllSubAccList` output without
187    /// losing hidden parent accounts needed by `GetAccItem`-style request paths.
188    pub fn set_accounts_with_relations(
189        &self,
190        accounts: Vec<CachedTrdAcc>,
191        relations: Vec<(AccKey, Vec<AccKey>)>,
192    ) {
193        self.accounts.clear();
194        self.account_relations.clear();
195        self.public_account_ids.clear();
196        self.jp_sub_account_types.clear();
197        for (idx, mut acc) in accounts.into_iter().enumerate() {
198            acc.order_index = idx;
199            self.accounts.insert(acc.acc_id, acc);
200        }
201        for (parent_id, sub_ids) in relations {
202            for sub_id in &sub_ids {
203                self.public_account_ids.insert(*sub_id, ());
204            }
205            self.account_relations.insert(parent_id, sub_ids);
206        }
207        self.public_projection_ready.store(true, Ordering::SeqCst);
208    }
209
210    #[must_use]
211    pub fn get_accounts(&self) -> Vec<CachedTrdAcc> {
212        if self.public_projection_ready.load(Ordering::SeqCst) {
213            self.public_account_ids
214                .iter()
215                .filter_map(|e| self.accounts.get(e.key()).map(|acc| acc.value().clone()))
216                .collect()
217        } else {
218            // Backward-compatible test path: many existing tests insert directly
219            // into `cache.accounts`. Until production calls set_accounts*, expose
220            // all entries, matching the old single-map behavior.
221            self.accounts.iter().map(|e| e.value().clone()).collect()
222        }
223    }
224
225    /// v1.4.106 codex 0932 F2 [P1]: 单 acc_id O(1) 查询 (DashMap key 直查).
226    ///
227    /// 用途: push_builder 构造 Trd_UpdateOrder / Trd_UpdateOrderFill header
228    /// 之前 resolve `trd_env` + `trd_market`. 对齐 C++
229    /// `INNData_Trd_AllAccList::GetAccEnv(nAccID)` / `GetAccMkt(nAccID)`.
230    ///
231    /// 返 `None` = cache miss (账户不在交易 cache 中). caller **必须 loud
232    /// return** 不 fallback (sentinel 0 让 client filter reject =
233    /// silent-success 反模式).
234    #[must_use]
235    pub fn lookup_account(&self, acc_id: u64) -> Option<CachedTrdAcc> {
236        self.accounts.get(&acc_id).map(|e| e.value().clone())
237    }
238
239    /// v1.4.103 (B10): card_num → acc_id resolution helper.
240    ///
241    /// 接受输入:
242    /// - **16 位完整 card_num** (`"1001100100800000"`): 完全匹配 `card_num` 字段.
243    /// - **4 位末尾 suffix** (`"7680"`): 匹配 `card_num` 末 4 位 (App 显示格式).
244    ///
245    /// 返 `Vec<u64>` (matching acc_ids):
246    /// - 0 个 → cache 中无 match (caller 决定 warn / abort);
247    /// - 1 个 → unique resolution;
248    /// - >= 2 个 → ambiguous (caller 必须 reject + log 候选, 不能 silent 接受).
249    ///
250    /// **空字符串 / 非纯数字 / 长度非 4 / 非 16** → 返 empty Vec (不 panic).
251    /// 这是为了让 caller 输入校验 + resolution 双责权: 调用方应该已经校验过格式.
252    #[must_use]
253    pub fn find_acc_ids_by_card_num(&self, input: &str) -> Vec<u64> {
254        // v1.4.103 codex F2.3 (P2): 同时匹配 `card_num` 和 `uni_card_num`
255        // (综合账户卡号). 用户故事 B10 描述 App 显示的`保证金综合账户(7680)`末
256        // 4 位 — 综合账户的卡号通常 in `uni_card_num`, 普通账户在 `card_num`.
257        // 单独只看 `card_num` 会让综合账户用户写 `--allowed-card-nums 7680`
258        // 时所有 resolve 都失败 → fail-closed sentinel reject (虽然安全, 但
259        // UX 失效, 用户必须 fall back 用 acc_id). 双匹配后 fail-closed
260        // sentinel 只在真没账户 match 时触发.
261        // v1.4.111 P2-1 Tier 3 audit comment: fail-closed by-design — empty Vec
262        // 表示 "no card_num match", caller (e.g. allowed_card_nums whitelist
263        // resolver) 把 empty 当 sentinel reject (v1.4.103 codex F2.3 P2 沉淀),
264        // **不**是 silent accept. 非 silent-success risk (audit verified).
265        let Ok(query) = account_locator::validate_card_num_query(input) else {
266            return Vec::new();
267        };
268        let mut matches = Vec::new();
269        if self.public_projection_ready.load(Ordering::SeqCst) {
270            for public_id in self.public_account_ids.iter() {
271                if let Some(acc) = self.accounts.get(public_id.key())
272                    && account_locator::account_matches_card_num(acc.value(), query)
273                {
274                    matches.push(acc.value().acc_id);
275                }
276            }
277        } else {
278            for acc in self.accounts.iter() {
279                if account_locator::account_matches_card_num(acc.value(), query) {
280                    matches.push(acc.value().acc_id);
281                }
282            }
283        }
284        matches.sort_unstable();
285        matches.dedup();
286        matches
287    }
288
289    /// **v1.4.106 Finding A** (legacy compat): 不带 currency 维度的 update.
290    /// 用 `FundsCacheKey::legacy(acc_id)` 作 key. 适用于:
291    /// - 现有 caller 还没改 signature 的 (背景: backend push 不一定知 currency)
292    /// - SingleCurrency / sim / Crypto / Forex 账户 (本来就单币种)
293    ///
294    /// **新 caller 应优先用 [`Self::update_funds_per_currency`]** 显式标
295    /// currency 维度, 让 Universal/Futures 账户能存独立 snapshot per currency.
296    pub fn update_funds(&self, acc_id: u64, funds: CachedFunds) {
297        self.funds.insert(FundsCacheKey::legacy(acc_id), funds);
298    }
299
300    /// **v1.4.106 Finding A** (preferred for Universal/Futures): 带 currency
301    /// 维度的 update. backend push 时若知 funds 的实际 currency (从 `f.currency`
302    /// 字段或 push context 派生), 应该用这个 helper 让多币种 snapshot 不互相覆盖.
303    ///
304    /// 对齐 C++ `INNData_Trd_Acc::SetAccFund(stKey, enCurrency, ...)`.
305    pub fn update_funds_per_currency(
306        &self,
307        acc_id: u64,
308        currency: Option<i32>,
309        funds: CachedFunds,
310    ) {
311        let key = match currency {
312            Some(c) => FundsCacheKey::per_currency(acc_id, c),
313            None => FundsCacheKey::legacy(acc_id),
314        };
315        self.funds.insert(key, funds);
316    }
317
318    /// Currency + asset-category aware funds update.
319    ///
320    /// JP derivative accounts use `asset_category` as part of the C++ asset key.
321    /// Non-JP/legacy callers should pass `asset_category=0`, which preserves the
322    /// existing legacy/per-currency key shape.
323    pub fn update_funds_scoped(
324        &self,
325        acc_id: u64,
326        asset_category: i32,
327        currency: Option<i32>,
328        funds: CachedFunds,
329    ) {
330        let key = if asset_category != 0 {
331            FundsCacheKey::full(acc_id, asset_category, currency)
332        } else {
333            match currency {
334                Some(c) => FundsCacheKey::per_currency(acc_id, c),
335                None => FundsCacheKey::legacy(acc_id),
336            }
337        };
338        self.funds.insert(key, funds);
339    }
340
341    /// Update the requested funds bucket and also mirror the returned backend
342    /// currency bucket when it is known.
343    ///
344    /// C++ stores `Ndt_Trd_AccFund` under `accFund.enCurrency`
345    /// (`INNData_Trd_Acc.cpp::SetAccFund`). A Rust caller may request CMD3020
346    /// with `currency=None` because the daemon derived the backend default, but
347    /// REST/CLI later read the same account through an explicit effective
348    /// currency bucket. Mirroring prevents an older per-currency snapshot from
349    /// masking a fresher default refresh.
350    pub fn update_funds_scoped_with_returned_currency(
351        &self,
352        acc_id: u64,
353        asset_category: i32,
354        requested_currency: Option<i32>,
355        funds: CachedFunds,
356    ) {
357        let returned_currency = funds.currency;
358        self.update_funds_scoped(acc_id, asset_category, requested_currency, funds.clone());
359
360        if let Some(returned_currency) = returned_currency
361            && requested_currency != Some(returned_currency)
362        {
363            self.update_funds_scoped(acc_id, asset_category, Some(returned_currency), funds);
364        }
365    }
366
367    /// **v1.4.106 Finding A**: cache lookup with C++-equivalent fallback.
368    ///
369    /// 对齐 C++ `INNData_Trd_Acc::GetAccFund(stKey, enCurrency, pAccFund)`:
370    /// 先试 requested currency, 找不到则 fallback 到 latest/first available
371    /// currency, **返 false** (caller 应看 boolean 决定是否 trust).
372    ///
373    /// 输入 `currency`:
374    /// - `Some(c)`: Universal/Futures 路径, 优先 match per-currency snapshot
375    /// - `None`: SingleCurrency 路径, 直接 match `legacy(acc_id)` snapshot
376    ///
377    /// 输出 `(funds, currency_match)`:
378    /// - `(Some(funds), true)`: 精确命中 requested currency snapshot
379    /// - `(Some(funds), false)`: 命中 fallback (legacy 或不同 currency 的
380    ///   snapshot — caller 应**不要 silent trust**, 至少 log warn 或 surface
381    ///   currency mismatch)
382    /// - `(None, _)`: 完全 cache miss
383    #[must_use]
384    pub fn get_funds(&self, acc_id: u64, currency: Option<i32>) -> (Option<CachedFunds>, bool) {
385        self.get_funds_scoped(acc_id, 0, currency)
386    }
387
388    /// Funds lookup using the same `(acc_id, asset_category, currency)` dimensions
389    /// as [`Self::update_funds_scoped`].
390    ///
391    /// For `asset_category != 0` we require an exact scoped hit. Falling back to a
392    /// legacy or another asset-category snapshot would mix JP derivative asset
393    /// buckets and silently return the wrong funds.
394    #[must_use]
395    pub fn get_funds_scoped(
396        &self,
397        acc_id: u64,
398        asset_category: i32,
399        currency: Option<i32>,
400    ) -> (Option<CachedFunds>, bool) {
401        // Step 1: 精确 match
402        let exact_key = if asset_category != 0 {
403            FundsCacheKey::full(acc_id, asset_category, currency)
404        } else {
405            match currency {
406                Some(c) => FundsCacheKey::per_currency(acc_id, c),
407                None => FundsCacheKey::legacy(acc_id),
408            }
409        };
410        if let Some(f) = self.funds.get(&exact_key) {
411            return (Some(f.value().clone()), true);
412        }
413        if asset_category != 0 {
414            return (None, false);
415        }
416        // Step 2: fallback to legacy(acc_id) — backend 不带 currency context
417        // push 时落进 legacy key
418        if currency.is_some()
419            && let Some(f) = self.funds.get(&FundsCacheKey::legacy(acc_id))
420        {
421            return (Some(f.value().clone()), false);
422        }
423        // Step 3: fallback to ANY snapshot for this acc_id (latest available
424        // currency, 等价于 C++ "first available")
425        for entry in self.funds.iter() {
426            if entry.key().acc_id == acc_id {
427                return (Some(entry.value().clone()), false);
428            }
429        }
430        (None, false)
431    }
432
433    pub fn update_positions(&self, acc_id: u64, positions: Vec<CachedPosition>) {
434        self.update_positions_scoped(acc_id, 0, positions);
435    }
436
437    pub fn update_positions_scoped(
438        &self,
439        acc_id: u64,
440        asset_category: i32,
441        positions: Vec<CachedPosition>,
442    ) {
443        let key = positions_cache_key(acc_id, asset_category);
444        self.positions.insert(key, positions);
445    }
446
447    pub fn update_combo_positions_scoped(
448        &self,
449        acc_id: u64,
450        asset_category: i32,
451        positions: Vec<CachedPosition>,
452    ) {
453        let key = positions_cache_key(acc_id, asset_category);
454        self.combo_positions.insert(key, positions);
455    }
456
457    #[must_use]
458    pub fn get_positions_scoped(
459        &self,
460        acc_id: u64,
461        asset_category: i32,
462    ) -> Option<Vec<CachedPosition>> {
463        let key = positions_cache_key(acc_id, asset_category);
464        self.positions.get(&key).map(|p| p.value().clone())
465    }
466
467    #[must_use]
468    pub fn get_combo_positions_scoped(
469        &self,
470        acc_id: u64,
471        asset_category: i32,
472    ) -> Option<Vec<CachedPosition>> {
473        let key = positions_cache_key(acc_id, asset_category);
474        self.combo_positions.get(&key).map(|p| p.value().clone())
475    }
476
477    #[must_use]
478    pub fn has_positions_scoped(&self, acc_id: u64, asset_category: i32) -> bool {
479        let key = positions_cache_key(acc_id, asset_category);
480        self.positions.contains_key(&key)
481    }
482
483    #[must_use]
484    pub fn has_combo_positions_scoped(&self, acc_id: u64, asset_category: i32) -> bool {
485        let key = positions_cache_key(acc_id, asset_category);
486        self.combo_positions.contains_key(&key)
487    }
488
489    /// C++ `INNData_Trd_Acc::GetComboPositionItem(nAccID, Unknown, nPositionID)`.
490    ///
491    /// Combo trade-write paths receive public FTAPI `positionID` (a hash). The
492    /// backend requires the original `business_position_id` string plus the
493    /// position's long account/sub-account ids. Search the combo-position view
494    /// for the account and return the cached row instead of guessing.
495    #[must_use]
496    pub fn find_combo_position_item(
497        &self,
498        acc_id: u64,
499        position_id: u64,
500    ) -> Option<CachedPosition> {
501        if position_id == 0 {
502            return None;
503        }
504        let legacy_key = positions_cache_key(acc_id, 0);
505        if let Some(positions) = self.combo_positions.get(&legacy_key)
506            && let Some(position) = positions
507                .value()
508                .iter()
509                .find(|position| position.position_id == position_id)
510        {
511            return Some(position.clone());
512        }
513        for entry in self.combo_positions.iter() {
514            if entry.key().acc_id != acc_id || *entry.key() == legacy_key {
515                continue;
516            }
517            if let Some(position) = entry
518                .value()
519                .iter()
520                .find(|position| position.position_id == position_id)
521            {
522                return Some(position.clone());
523            }
524        }
525        None
526    }
527
528    pub fn update_orders(&self, acc_id: u64, orders: Vec<CachedOrder>) {
529        self.orders.insert(acc_id, orders);
530        self.prune_order_brokers_for_acc(acc_id);
531    }
532
533    fn order_backend_ids(order: &CachedOrder) -> impl Iterator<Item = &str> + '_ {
534        [order.backend_order_id.trim(), order.order_id_ex.trim()]
535            .into_iter()
536            .filter(|id| !id.is_empty())
537    }
538
539    fn order_identity_matches(existing: &CachedOrder, incoming: &CachedOrder) -> bool {
540        // C++ `Ndt_Trd_Order::operator==` matches by either `nOrderIDHash`
541        // or backend `szOrderID`.
542        //
543        // Ref: FutuOpenD/Src/NNDataCenter/Trade/INNData_Trd_Order.h:55-58.
544        //
545        // Rust cache may temporarily contain local stubs/legacy rows with
546        // missing ids, so `0`/empty strings are not treated as valid identity.
547        if incoming.order_id != 0 && existing.order_id == incoming.order_id {
548            return true;
549        }
550        Self::order_backend_ids(existing)
551            .any(|existing_id| Self::order_backend_ids(incoming).any(|id| id == existing_id))
552    }
553
554    fn order_needs_update_like_cpp(existing: &CachedOrder, incoming: &CachedOrder) -> bool {
555        // C++ `NeedUpdateOrder` checks status/price/qty/dealt qty/aux/trailing
556        // fields only. Rust also includes local cache lifecycle flags and
557        // backend id fields so a backend authoritative row can replace a local
558        // stub even when visible trading fields are unchanged.
559        //
560        // Ref: FutuOpenD/Src/NNProtoCenter/Trade/_NNProto_Trd_Comm.cpp:297-307.
561        existing.order_status != incoming.order_status
562            || existing.price != incoming.price
563            || existing.qty != incoming.qty
564            || existing.fill_qty != incoming.fill_qty
565            || existing.aux_price != incoming.aux_price
566            || existing.trail_type != incoming.trail_type
567            || existing.trail_value != incoming.trail_value
568            || existing.trail_spread != incoming.trail_spread
569            || existing.is_stub != incoming.is_stub
570            || existing.is_local_order != incoming.is_local_order
571            || existing.is_pending_broker_confirm != incoming.is_pending_broker_confirm
572            || existing.backend_order_id != incoming.backend_order_id
573            || existing.order_id_ex != incoming.order_id_ex
574    }
575
576    /// 更新单个订单(推送场景)
577    pub fn upsert_order(&self, acc_id: u64, order: CachedOrder) -> bool {
578        let mut entry = self.orders.entry(acc_id).or_default();
579        if let Some(existing) = entry
580            .iter_mut()
581            .find(|existing| Self::order_identity_matches(existing, &order))
582        {
583            // C++ `UpdateAndNotifyOneOrder` refuses out-of-order old versions:
584            // `_NNProto_Trd_Comm.cpp:326-330`.
585            if order.order_version < existing.order_version {
586                return false;
587            }
588            if !Self::order_needs_update_like_cpp(existing, &order) {
589                return false;
590            }
591            *existing = order;
592            true
593        } else {
594            entry.push(order);
595            true
596        }
597    }
598
599    /// v1.4.106 codex 0219 Finding 1: resolve cached order context for trade-write
600    /// (modify / cancel) handlers.
601    ///
602    /// 对齐 C++ `APIServer_Trd_ModifyOrder.cpp:251-256` + `:270-271`:
603    /// - 优先用 client 传的 `orderIDEx` (= backend `szOrderID`).
604    /// - 否则用 `(acc_id, order_id_hash)` 从 cache 找原 order, 取它的
605    ///   `szOrderID` + `version` + `exchange*` 字段构造 backend req.
606    ///
607    /// **fail-closed 语义**: cache miss → 返 `Err`, caller 把错误透传到
608    /// FTAPI client 让用户先刷新 `/api/orders` 或传 orderIDEx. 不允许 silent
609    /// fall-through 到 `order_id.to_string()` (= 把 hash 当 backend id, 见
610    /// pitfall #45 silent-success).
611    ///
612    /// **入参**:
613    /// - `acc_id`: FTAPI `c2s.header.acc_id`.
614    /// - `order_id`: FTAPI `c2s.order_id` (hash). `0` 视为 caller 没传, 仅靠
615    ///   `order_id_ex` 路径生效.
616    /// - `order_id_ex`: FTAPI `c2s.order_id_ex` (= backend szOrderID, 优先).
617    ///
618    /// **返回**:
619    /// - `Ok(snap)`: 命中 cache, 字段已 populated.
620    /// - `Err(ResolveOrderError::CacheMiss)`: cache 没存这个 (acc_id, order_id),
621    ///   caller 应返清晰提示 "先刷新 /api/orders 或传 orderIDEx".
622    /// - `Err(ResolveOrderError::MissingBackendId)`: cache 命中但 backend_order_id
623    ///   字段空 (= cache entry 来自老版本, 没存 szOrderID), caller 应返同样提示.
624    /// - `Err(ResolveOrderError::InvalidInput)`: 同时缺 order_id 和 order_id_ex.
625    pub fn find_order_for_trade_write(
626        &self,
627        acc_id: u64,
628        order_id: u64,
629        order_id_ex: Option<&str>,
630    ) -> Result<CachedOrderSnapshot, ResolveOrderError> {
631        // Case 1: orderIDEx 传了 — 按 order_id_ex 在 cache 找完整 order
632        // (匹配 backend `szOrderID`).
633        //
634        // **v1.4.106 codex 0920 F3 (P1) fail-closed 修**: cache miss 时**不再**
635        // 返 default snapshot (= 把 ex 作 backend_id, 其他字段 0). 之前的 fallback
636        // 让 modify/cancel handler 用 default `order_version=0` / 空 `exchange*` /
637        // 空 `security_type` 发 backend, 后续 backend 拒错 / 路由错. 用户看 daemon
638        // 接受了请求实则 silent fail.
639        //
640        // **新语义**: cache miss + 用户传 ex → `Err(CacheMiss)` 让 handler 透传
641        // 给 FTAPI client, 用户应先调 `/api/orders` 刷新或确保 daemon 没 restart
642        // 过. 对齐 #45 silent-success anti-pattern (snapshot 不变量必须严格).
643        let trimmed_ex = order_id_ex.map(str::trim).filter(|s| !s.is_empty());
644        if let Some(ex) = trimmed_ex {
645            if let Some(orders) = self.orders.get(&acc_id) {
646                if let Some(order) = orders
647                    .iter()
648                    .find(|o| !o.backend_order_id.is_empty() && o.backend_order_id == ex)
649                {
650                    return Ok(CachedOrderSnapshot::from_order(order));
651                }
652                // 未找到完整 order, 也接受老 entry (order_id_ex == ex 但 backend_order_id 空):
653                // 把 ex 作 backend_order_id 用 (用户显式传, 信任 caller).
654                if let Some(order) = orders.iter().find(|o| o.order_id_ex == ex) {
655                    let mut snap = CachedOrderSnapshot::from_order(order);
656                    if snap.backend_order_id.is_empty() {
657                        snap.backend_order_id = ex.to_string();
658                    }
659                    return Ok(snap);
660                }
661            }
662            // v1.4.106 codex 0920 F3 (P1): cache miss + 用户传 ex → fail closed.
663            // 之前 silent fallback 到 default snapshot (= 0 / "" 字段 + ex 作
664            // backend_id), 让 handler 发不完整 backend req. 现在统一 reject,
665            // 让用户先刷新 cache.
666            return Err(ResolveOrderError::CacheMiss);
667        }
668
669        // Case 2: 仅 order_id (hash) — 必须 cache 命中才能查到 backend_order_id.
670        if order_id == 0 {
671            return Err(ResolveOrderError::InvalidInput);
672        }
673        let orders = self
674            .orders
675            .get(&acc_id)
676            .ok_or(ResolveOrderError::CacheMiss)?;
677        let order = orders
678            .iter()
679            .find(|o| o.order_id == order_id)
680            .ok_or(ResolveOrderError::CacheMiss)?;
681        if order.backend_order_id.is_empty() {
682            return Err(ResolveOrderError::MissingBackendId);
683        }
684        Ok(CachedOrderSnapshot::from_order(order))
685    }
686
687    /// v1.4.90 S BUG-e4da-009: stub TTL(30s)。
688    ///
689    /// stub 插入超过此 TTL 且 backend 仍不返该 `order_id` → 视为 backend 永久
690    /// 拒单(never accepted into authoritative list)→ evict。
691    pub const STUB_TTL_MS: u64 = 30_000;
692
693    /// v1.4.90 S BUG-e4da-009: 当前 unix epoch ms。
694    ///
695    /// 抽出 helper 是为了 unit test 能用 mock 时间(不直接调)。
696    fn now_ms() -> u64 {
697        use std::time::{SystemTime, UNIX_EPOCH};
698        SystemTime::now()
699            .duration_since(UNIX_EPOCH)
700            .map(|d| d.as_millis() as u64)
701            .unwrap_or(0)
702    }
703
704    /// v1.4.90 S BUG-e4da-009 cache saga 真修:merge backend 权威列表,**保留** stub.
705    ///
706    /// 历史坑(跨 v1.4.73 → v1.4.89 7 版未真修):
707    /// ```text
708    /// 17:36:44.204092 place_order.rs:427  v1.4.82 A2 stub upsert (order_id=X)
709    /// 17:36:44.204126 place_order.rs:451  PlaceOrder success
710    /// 17:36:44.204138 futu_audit:511      v1.4.38 idempotency: cached
711    /// 17:36:44.226531 place_order.rs:488  v1.4.73 A1 orders refreshed count=0  ← 22.4ms 清零
712    /// ```
713    ///
714    /// 根因:v1.4.73 A1 spawn refresh 直接 `orders.insert(acc_id, backend_list)`
715    /// **整覆盖**,22ms 内把 v1.4.82 A2 刚 upsert 的 stub 抹掉。client 0ms 查
716    /// `/api/orders` 命中 stub OK,但 22ms 后再查就 count=0 —— "**单子消失**" 假象。
717    ///
718    /// 修法(async-safe):refresh 不再 `insert` 整覆盖,而是 **merge**:
719    /// - backend 返的每个 order: upsert(同 `order_id` 命中 stub → 覆盖且
720    ///   `is_stub=false`,不在 → push)
721    /// - cache 里 backend 没返的 stub orders(`is_stub=true`):
722    ///   - `now_ms - stub_inserted_at_ms < STUB_TTL_MS` (30s) → **保留**
723    ///   - 否则 → evict(backend 永久拒单兜底)
724    /// - cache 里 backend 没返的非 stub orders(`is_stub=false`):
725    ///   全清空(backend 是权威,老的非 stub 该被替换),但 C++ 会把
726    ///   `bIsLocalOrder=true` 的本地订单重新插回列表;Rust 只保留本地
727    ///   Deleted(23) 这类已确认终态,避免把普通历史单无限留存。
728    ///
729    /// 并发语义:用 DashMap entry api 取写锁,整 merge 在锁内完成 → 多个
730    /// `merge_preserving_stubs` 调用串行化(顺序与到达顺序一致)。
731    /// `upsert_order` 与 `merge_preserving_stubs` 之间也通过同一 entry lock
732    /// 排它,不会丢失 stub 插入与 merge 之间的并发更新。
733    pub fn merge_preserving_stubs(&self, acc_id: u64, backend_orders: Vec<CachedOrder>) {
734        self.merge_preserving_stubs_with_now(acc_id, backend_orders, Self::now_ms());
735    }
736
737    /// v1.4.90 S BUG-e4da-009: `merge_preserving_stubs` 的可注入时间版(test 用)。
738    ///
739    /// 业务代码只调 `merge_preserving_stubs`;本 fn 暴露便于 unit test 模拟
740    /// "stub 已超 TTL" / "stub 仍 fresh" 两种边界。
741    pub fn merge_preserving_stubs_with_now(
742        &self,
743        acc_id: u64,
744        backend_orders: Vec<CachedOrder>,
745        now_ms: u64,
746    ) {
747        let mut entry = self.orders.entry(acc_id).or_default();
748
749        // 收集既有 cache 里的 stub orders(按 order_id 索引,便于 merge 后判断)
750        let existing_stubs: Vec<CachedOrder> =
751            entry.iter().filter(|o| o.is_stub).cloned().collect();
752        let existing_local_deleted: Vec<CachedOrder> = entry
753            .iter()
754            .filter(|o| o.is_local_order && o.order_status == 23 && !o.is_stub)
755            .cloned()
756            .collect();
757
758        // 重置 entry,按 backend list 重建(每个 backend order 必然 is_stub=false)
759        let mut new_orders: Vec<CachedOrder> = backend_orders
760            .into_iter()
761            .map(|mut o| {
762                // backend 是权威,强制 is_stub=false(防 caller 不慎传 stub)
763                o.is_stub = false;
764                // C++ backend UnPackOrderList produces bIsLocalOrder=false; only
765                // local echo / operation result paths set it true.
766                o.is_local_order = false;
767                o.stub_inserted_at_ms = 0;
768                // v1.4.105 BUG-v1.4.104-001: backend 列表 = broker confirmed 的权威订单,
769                // 强制 is_pending_broker_confirm=false (防 caller 不慎传 pending).
770                o.is_pending_broker_confirm = false;
771                o
772            })
773            .collect();
774
775        // 把 backend 没返的 stub 按 TTL 保留(已 merge 进 backend list 的不重复)
776        for stub in existing_stubs {
777            if new_orders
778                .iter()
779                .any(|backend| Self::order_identity_matches(backend, &stub))
780            {
781                // backend 已 ack → 不保留 stub,由 backend 版本胜出
782                continue;
783            }
784            let age = now_ms.saturating_sub(stub.stub_inserted_at_ms);
785            if age < Self::STUB_TTL_MS {
786                new_orders.push(stub);
787            }
788            // else: 老 stub 超 TTL,evict(不 push)
789        }
790
791        for local in existing_local_deleted {
792            if new_orders
793                .iter()
794                .any(|backend| Self::order_identity_matches(backend, &local))
795            {
796                continue;
797            }
798            new_orders.push(local);
799        }
800
801        *entry = new_orders;
802        drop(entry);
803        self.prune_order_brokers_for_acc(acc_id);
804    }
805}
806
807fn positions_cache_key(acc_id: u64, asset_category: i32) -> PositionsCacheKey {
808    if asset_category != 0 {
809        PositionsCacheKey::scoped(acc_id, asset_category)
810    } else {
811        PositionsCacheKey::legacy(acc_id)
812    }
813}
814
815impl Default for TrdCache {
816    fn default() -> Self {
817        Self::new()
818    }
819}