Skip to main content

futu_cache/trd_cache/
snapshots.rs

1use super::*;
2use futu_domain_trade_account::{
3    TradeFundsSnapshotKeyPlan, TradePositionsSnapshotKeyPlan, plan_combo_position_lookup_like_cpp,
4    plan_funds_lookup_keys_like_cpp, plan_funds_write_key_like_cpp,
5    plan_funds_write_key_with_returned_currency_like_cpp, plan_positions_currency_key_like_cpp,
6    plan_positions_key_like_cpp,
7};
8
9use super::freshness::insert_newer_snapshot;
10
11impl TrdCache {
12    /// **v1.4.106 Finding A** (legacy compat): 不带 currency 维度的 update.
13    /// 用 `FundsCacheKey::legacy(acc_id)` 作 key. 适用于:
14    /// - 现有 caller 还没改 signature 的 (背景: backend push 不一定知 currency)
15    /// - SingleCurrency / sim / Crypto / Forex 账户 (本来就单币种)
16    ///
17    /// **新 caller 应优先用 [`Self::update_funds_per_currency`]** 显式标
18    /// currency 维度, 让 Universal/Futures 账户能存独立 snapshot per currency.
19    pub fn update_funds(&self, acc_id: u64, funds: CachedFunds) {
20        let key = funds_cache_key(plan_funds_write_key_like_cpp(acc_id, 0, None));
21        let snapshot = self.snapshot_freshness.stamp(funds);
22        insert_newer_snapshot(&self.funds, key, snapshot);
23    }
24
25    /// **v1.4.106 Finding A** (preferred for Universal/Futures): 带 currency
26    /// 维度的 update. backend push 时若知 funds 的实际 currency (从 `f.currency`
27    /// 字段或 push context 派生), 应该用这个 helper 让多币种 snapshot 不互相覆盖.
28    ///
29    /// 对齐 C++ `INNData_Trd_Acc::SetAccFund(stKey, enCurrency, ...)`.
30    pub fn update_funds_per_currency(
31        &self,
32        acc_id: u64,
33        currency: Option<i32>,
34        funds: CachedFunds,
35    ) {
36        let key = funds_cache_key(plan_funds_write_key_like_cpp(acc_id, 0, currency));
37        let snapshot = self.snapshot_freshness.stamp(funds);
38        insert_newer_snapshot(&self.funds, key, snapshot);
39    }
40
41    /// Currency + asset-category aware funds update.
42    ///
43    /// JP derivative accounts use `asset_category` as part of the C++ asset key.
44    /// Non-JP/legacy callers should pass `asset_category=0`, which preserves the
45    /// existing legacy/per-currency key shape.
46    pub fn update_funds_scoped(
47        &self,
48        acc_id: u64,
49        asset_category: i32,
50        currency: Option<i32>,
51        funds: CachedFunds,
52    ) {
53        let key = funds_cache_key(plan_funds_write_key_like_cpp(
54            acc_id,
55            asset_category,
56            currency,
57        ));
58        let snapshot = self.snapshot_freshness.stamp(funds);
59        insert_newer_snapshot(&self.funds, key, snapshot);
60    }
61
62    /// Update the requested funds bucket and also mirror the returned backend
63    /// currency bucket when it is known.
64    ///
65    /// C++ stores `Ndt_Trd_AccFund` under `accFund.enCurrency`
66    /// (`INNData_Trd_Acc.cpp::SetAccFund`). A Rust caller may request CMD3020
67    /// with `currency=None` because the daemon derived the backend default, but
68    /// REST/CLI later read the same account through an explicit effective
69    /// currency bucket. Mirroring prevents an older per-currency snapshot from
70    /// masking a fresher default refresh.
71    pub fn update_funds_scoped_with_returned_currency(
72        &self,
73        acc_id: u64,
74        asset_category: i32,
75        requested_currency: Option<i32>,
76        funds: CachedFunds,
77    ) {
78        let write_plan = plan_funds_write_key_with_returned_currency_like_cpp(
79            acc_id,
80            asset_category,
81            requested_currency,
82            funds.currency,
83        );
84        let requested_snapshot = self.snapshot_freshness.stamp(funds.clone());
85        insert_newer_snapshot(
86            &self.funds,
87            funds_cache_key(write_plan.requested),
88            requested_snapshot,
89        );
90
91        if let Some(mirror_key) = write_plan.returned_currency_mirror {
92            let mirror_snapshot = self.snapshot_freshness.stamp(funds);
93            insert_newer_snapshot(&self.funds, funds_cache_key(mirror_key), mirror_snapshot);
94        }
95    }
96
97    /// **v1.4.106 Finding A**: cache lookup with C++-equivalent fallback.
98    ///
99    /// 对齐 C++ `INNData_Trd_Acc::GetAccFund(stKey, enCurrency, pAccFund)`:
100    /// 先试 requested currency, 找不到则 fallback 到 latest/first available
101    /// currency, **返 false** (caller 应看 boolean 决定是否 trust).
102    ///
103    /// 输入 `currency`:
104    /// - `Some(c)`: Universal/Futures 路径, 优先 match per-currency snapshot
105    /// - `None`: SingleCurrency 路径, 直接 match `legacy(acc_id)` snapshot
106    ///
107    /// 输出 `(funds, currency_match)`:
108    /// - `(Some(funds), true)`: 精确命中 requested currency snapshot
109    /// - `(Some(funds), false)`: 命中 fallback (legacy 或不同 currency 的
110    ///   snapshot — caller 应**不要 silent trust**, 至少 log warn 或 surface
111    ///   currency mismatch)
112    /// - `(None, _)`: 完全 cache miss
113    #[must_use]
114    pub fn get_funds(&self, acc_id: u64, currency: Option<i32>) -> (Option<CachedFunds>, bool) {
115        self.get_funds_scoped(acc_id, 0, currency)
116    }
117
118    /// Funds lookup using the same `(acc_id, asset_category, currency)` dimensions
119    /// as [`Self::update_funds_scoped`].
120    ///
121    /// For `asset_category != 0` we require an exact scoped hit. Falling back to a
122    /// legacy or another asset-category snapshot would mix JP derivative asset
123    /// buckets and silently return the wrong funds.
124    #[must_use]
125    pub fn get_funds_scoped(
126        &self,
127        acc_id: u64,
128        asset_category: i32,
129        currency: Option<i32>,
130    ) -> (Option<CachedFunds>, bool) {
131        let lookup = self.get_funds_scoped_with_freshness(acc_id, asset_category, currency);
132        (lookup.funds, lookup.currency_match)
133    }
134
135    #[must_use]
136    pub fn get_funds_scoped_with_freshness(
137        &self,
138        acc_id: u64,
139        asset_category: i32,
140        currency: Option<i32>,
141    ) -> FundsSnapshotLookup {
142        let lookup_plan = plan_funds_lookup_keys_like_cpp(acc_id, asset_category, currency);
143        let exact_key = funds_cache_key(lookup_plan.exact);
144        if let Some(snapshot) = self.funds.get(&exact_key) {
145            return funds_lookup(&self.snapshot_freshness, exact_key, snapshot.value(), true);
146        }
147        // Step 2: fallback to legacy(acc_id) — backend 不带 currency context
148        // push 时落进 legacy key
149        if let Some(legacy_fallback) = lookup_plan.legacy_fallback
150            && let legacy_key = funds_cache_key(legacy_fallback)
151            && let Some(snapshot) = self.funds.get(&legacy_key)
152        {
153            return funds_lookup(
154                &self.snapshot_freshness,
155                legacy_key,
156                snapshot.value(),
157                false,
158            );
159        }
160        // Step 3: fallback to ANY snapshot for this acc_id (latest available
161        // currency, 等价于 C++ "first available")
162        if let Some(scan_acc_id) = lookup_plan.scan_acc_id {
163            for entry in self.funds.iter() {
164                if entry.key().acc_id == scan_acc_id {
165                    return funds_lookup(
166                        &self.snapshot_freshness,
167                        *entry.key(),
168                        entry.value(),
169                        false,
170                    );
171                }
172            }
173        }
174        FundsSnapshotLookup {
175            funds: None,
176            currency_match: false,
177            freshness: self.snapshot_freshness.freshness(
178                PositionsCacheKey::scoped(acc_id, asset_category),
179                None::<&StampedTradeSnapshot<CachedFunds>>,
180            ),
181        }
182    }
183
184    pub fn update_positions(&self, acc_id: u64, positions: Vec<CachedPosition>) {
185        self.update_positions_scoped(acc_id, 0, positions);
186    }
187
188    pub fn update_positions_scoped(
189        &self,
190        acc_id: u64,
191        asset_category: i32,
192        positions: Vec<CachedPosition>,
193    ) {
194        let key = positions_cache_key(plan_positions_key_like_cpp(acc_id, asset_category));
195        let snapshot = self.snapshot_freshness.stamp(positions);
196        insert_newer_snapshot(&self.positions, key, snapshot);
197    }
198
199    pub fn update_positions_currency_scoped(
200        &self,
201        acc_id: u64,
202        asset_category: i32,
203        currency: i32,
204        positions: Vec<CachedPosition>,
205    ) {
206        let key = positions_cache_key(plan_positions_currency_key_like_cpp(
207            acc_id,
208            asset_category,
209            currency,
210        ));
211        let snapshot = self.snapshot_freshness.stamp(positions);
212        insert_newer_snapshot(&self.positions, key, snapshot);
213    }
214
215    pub fn update_combo_positions_scoped(
216        &self,
217        acc_id: u64,
218        asset_category: i32,
219        positions: Vec<CachedPosition>,
220    ) {
221        let key = positions_cache_key(plan_positions_key_like_cpp(acc_id, asset_category));
222        let snapshot = self.snapshot_freshness.stamp(positions);
223        insert_newer_snapshot(&self.combo_positions, key, snapshot);
224    }
225
226    #[must_use]
227    pub fn get_positions_scoped(
228        &self,
229        acc_id: u64,
230        asset_category: i32,
231    ) -> Option<Vec<CachedPosition>> {
232        self.get_positions_scoped_with_freshness(acc_id, asset_category)
233            .positions
234    }
235
236    #[must_use]
237    pub fn get_positions_currency_scoped(
238        &self,
239        acc_id: u64,
240        asset_category: i32,
241        currency: i32,
242    ) -> Option<Vec<CachedPosition>> {
243        self.get_positions_currency_scoped_with_freshness(acc_id, asset_category, currency)
244            .positions
245    }
246
247    #[must_use]
248    pub fn get_combo_positions_scoped(
249        &self,
250        acc_id: u64,
251        asset_category: i32,
252    ) -> Option<Vec<CachedPosition>> {
253        self.get_combo_positions_scoped_with_freshness(acc_id, asset_category)
254            .positions
255    }
256
257    #[must_use]
258    pub fn get_positions_scoped_with_freshness(
259        &self,
260        acc_id: u64,
261        asset_category: i32,
262    ) -> PositionsSnapshotLookup {
263        let key = positions_cache_key(plan_positions_key_like_cpp(acc_id, asset_category));
264        positions_lookup(&self.snapshot_freshness, key, self.positions.get(&key))
265    }
266
267    #[must_use]
268    pub fn get_positions_currency_scoped_with_freshness(
269        &self,
270        acc_id: u64,
271        asset_category: i32,
272        currency: i32,
273    ) -> PositionsSnapshotLookup {
274        let key = positions_cache_key(plan_positions_currency_key_like_cpp(
275            acc_id,
276            asset_category,
277            currency,
278        ));
279        positions_lookup(&self.snapshot_freshness, key, self.positions.get(&key))
280    }
281
282    #[must_use]
283    pub fn get_combo_positions_scoped_with_freshness(
284        &self,
285        acc_id: u64,
286        asset_category: i32,
287    ) -> PositionsSnapshotLookup {
288        let key = positions_cache_key(plan_positions_key_like_cpp(acc_id, asset_category));
289        positions_lookup(
290            &self.snapshot_freshness,
291            key,
292            self.combo_positions.get(&key),
293        )
294    }
295
296    #[must_use]
297    pub fn has_positions_scoped(&self, acc_id: u64, asset_category: i32) -> bool {
298        let key = positions_cache_key(plan_positions_key_like_cpp(acc_id, asset_category));
299        self.positions.contains_key(&key)
300    }
301
302    /// C++ `INNData_Trd_Acc::GetComboPositionItem(nAccID, Unknown, nPositionID)`.
303    ///
304    /// Combo trade-write paths receive public FTAPI `positionID` (a hash). The
305    /// backend requires the original `business_position_id` string plus the
306    /// position's long account/sub-account ids. Search the combo-position view
307    /// for the account and return the cached row instead of guessing.
308    #[must_use]
309    pub fn find_combo_position_item(
310        &self,
311        acc_id: u64,
312        position_id: u64,
313    ) -> Option<CachedPosition> {
314        let lookup_plan = plan_combo_position_lookup_like_cpp(acc_id, position_id);
315        let legacy_plan = lookup_plan.legacy_key?;
316        let legacy_key = positions_cache_key(legacy_plan);
317        if let Some(positions) = self.combo_positions.get(&legacy_key)
318            && let Some(position) = positions
319                .value()
320                .value()
321                .iter()
322                .find(|position| position.position_id == position_id)
323        {
324            return Some(position.clone());
325        }
326        if let Some(scan_acc_id) = lookup_plan.scan_remaining_account_buckets {
327            for entry in self.combo_positions.iter() {
328                if entry.key().acc_id != scan_acc_id || *entry.key() == legacy_key {
329                    continue;
330                }
331                if let Some(position) = entry
332                    .value()
333                    .value()
334                    .iter()
335                    .find(|position| position.position_id == position_id)
336                {
337                    return Some(position.clone());
338                }
339            }
340        }
341        None
342    }
343}
344
345fn funds_lookup(
346    freshness_store: &super::freshness::TradeSnapshotFreshnessStore,
347    key: FundsCacheKey,
348    snapshot: &StampedTradeSnapshot<CachedFunds>,
349    currency_match: bool,
350) -> FundsSnapshotLookup {
351    FundsSnapshotLookup {
352        funds: Some(snapshot.value().clone()),
353        currency_match,
354        freshness: freshness_store.freshness(
355            PositionsCacheKey::scoped(key.acc_id, key.asset_category),
356            Some(snapshot),
357        ),
358    }
359}
360
361fn positions_lookup(
362    freshness_store: &super::freshness::TradeSnapshotFreshnessStore,
363    key: PositionsCacheKey,
364    snapshot: Option<
365        dashmap::mapref::one::Ref<'_, PositionsCacheKey, StampedTradeSnapshot<Vec<CachedPosition>>>,
366    >,
367) -> PositionsSnapshotLookup {
368    let freshness = freshness_store.freshness(key.asset_scope(), snapshot.as_deref());
369    PositionsSnapshotLookup {
370        positions: snapshot.map(|snapshot| snapshot.value().value().clone()),
371        freshness,
372    }
373}
374
375fn funds_cache_key(plan: TradeFundsSnapshotKeyPlan) -> FundsCacheKey {
376    if plan.asset_category != 0 {
377        FundsCacheKey::full(plan.acc_id, plan.asset_category, plan.currency)
378    } else {
379        match plan.currency {
380            Some(currency) => FundsCacheKey::per_currency(plan.acc_id, currency),
381            None => FundsCacheKey::legacy(plan.acc_id),
382        }
383    }
384}
385
386fn positions_cache_key(plan: TradePositionsSnapshotKeyPlan) -> PositionsCacheKey {
387    PositionsCacheKey::full(plan.acc_id, plan.asset_category, plan.currency)
388}