Skip to main content

futu_cache/trd_cache/
snapshots.rs

1use super::*;
2
3impl TrdCache {
4    /// **v1.4.106 Finding A** (legacy compat): 不带 currency 维度的 update.
5    /// 用 `FundsCacheKey::legacy(acc_id)` 作 key. 适用于:
6    /// - 现有 caller 还没改 signature 的 (背景: backend push 不一定知 currency)
7    /// - SingleCurrency / sim / Crypto / Forex 账户 (本来就单币种)
8    ///
9    /// **新 caller 应优先用 [`Self::update_funds_per_currency`]** 显式标
10    /// currency 维度, 让 Universal/Futures 账户能存独立 snapshot per currency.
11    pub fn update_funds(&self, acc_id: u64, funds: CachedFunds) {
12        self.funds.insert(FundsCacheKey::legacy(acc_id), funds);
13    }
14
15    /// **v1.4.106 Finding A** (preferred for Universal/Futures): 带 currency
16    /// 维度的 update. backend push 时若知 funds 的实际 currency (从 `f.currency`
17    /// 字段或 push context 派生), 应该用这个 helper 让多币种 snapshot 不互相覆盖.
18    ///
19    /// 对齐 C++ `INNData_Trd_Acc::SetAccFund(stKey, enCurrency, ...)`.
20    pub fn update_funds_per_currency(
21        &self,
22        acc_id: u64,
23        currency: Option<i32>,
24        funds: CachedFunds,
25    ) {
26        let key = match currency {
27            Some(c) => FundsCacheKey::per_currency(acc_id, c),
28            None => FundsCacheKey::legacy(acc_id),
29        };
30        self.funds.insert(key, funds);
31    }
32
33    /// Currency + asset-category aware funds update.
34    ///
35    /// JP derivative accounts use `asset_category` as part of the C++ asset key.
36    /// Non-JP/legacy callers should pass `asset_category=0`, which preserves the
37    /// existing legacy/per-currency key shape.
38    pub fn update_funds_scoped(
39        &self,
40        acc_id: u64,
41        asset_category: i32,
42        currency: Option<i32>,
43        funds: CachedFunds,
44    ) {
45        let key = if asset_category != 0 {
46            FundsCacheKey::full(acc_id, asset_category, currency)
47        } else {
48            match currency {
49                Some(c) => FundsCacheKey::per_currency(acc_id, c),
50                None => FundsCacheKey::legacy(acc_id),
51            }
52        };
53        self.funds.insert(key, funds);
54    }
55
56    /// Update the requested funds bucket and also mirror the returned backend
57    /// currency bucket when it is known.
58    ///
59    /// C++ stores `Ndt_Trd_AccFund` under `accFund.enCurrency`
60    /// (`INNData_Trd_Acc.cpp::SetAccFund`). A Rust caller may request CMD3020
61    /// with `currency=None` because the daemon derived the backend default, but
62    /// REST/CLI later read the same account through an explicit effective
63    /// currency bucket. Mirroring prevents an older per-currency snapshot from
64    /// masking a fresher default refresh.
65    pub fn update_funds_scoped_with_returned_currency(
66        &self,
67        acc_id: u64,
68        asset_category: i32,
69        requested_currency: Option<i32>,
70        funds: CachedFunds,
71    ) {
72        let returned_currency = funds.currency;
73        self.update_funds_scoped(acc_id, asset_category, requested_currency, funds.clone());
74
75        if let Some(returned_currency) = returned_currency
76            && requested_currency != Some(returned_currency)
77        {
78            self.update_funds_scoped(acc_id, asset_category, Some(returned_currency), funds);
79        }
80    }
81
82    /// **v1.4.106 Finding A**: cache lookup with C++-equivalent fallback.
83    ///
84    /// 对齐 C++ `INNData_Trd_Acc::GetAccFund(stKey, enCurrency, pAccFund)`:
85    /// 先试 requested currency, 找不到则 fallback 到 latest/first available
86    /// currency, **返 false** (caller 应看 boolean 决定是否 trust).
87    ///
88    /// 输入 `currency`:
89    /// - `Some(c)`: Universal/Futures 路径, 优先 match per-currency snapshot
90    /// - `None`: SingleCurrency 路径, 直接 match `legacy(acc_id)` snapshot
91    ///
92    /// 输出 `(funds, currency_match)`:
93    /// - `(Some(funds), true)`: 精确命中 requested currency snapshot
94    /// - `(Some(funds), false)`: 命中 fallback (legacy 或不同 currency 的
95    ///   snapshot — caller 应**不要 silent trust**, 至少 log warn 或 surface
96    ///   currency mismatch)
97    /// - `(None, _)`: 完全 cache miss
98    #[must_use]
99    pub fn get_funds(&self, acc_id: u64, currency: Option<i32>) -> (Option<CachedFunds>, bool) {
100        self.get_funds_scoped(acc_id, 0, currency)
101    }
102
103    /// Funds lookup using the same `(acc_id, asset_category, currency)` dimensions
104    /// as [`Self::update_funds_scoped`].
105    ///
106    /// For `asset_category != 0` we require an exact scoped hit. Falling back to a
107    /// legacy or another asset-category snapshot would mix JP derivative asset
108    /// buckets and silently return the wrong funds.
109    #[must_use]
110    pub fn get_funds_scoped(
111        &self,
112        acc_id: u64,
113        asset_category: i32,
114        currency: Option<i32>,
115    ) -> (Option<CachedFunds>, bool) {
116        // Step 1: 精确 match
117        let exact_key = if asset_category != 0 {
118            FundsCacheKey::full(acc_id, asset_category, currency)
119        } else {
120            match currency {
121                Some(c) => FundsCacheKey::per_currency(acc_id, c),
122                None => FundsCacheKey::legacy(acc_id),
123            }
124        };
125        if let Some(f) = self.funds.get(&exact_key) {
126            return (Some(f.value().clone()), true);
127        }
128        if asset_category != 0 {
129            return (None, false);
130        }
131        // Step 2: fallback to legacy(acc_id) — backend 不带 currency context
132        // push 时落进 legacy key
133        if currency.is_some()
134            && let Some(f) = self.funds.get(&FundsCacheKey::legacy(acc_id))
135        {
136            return (Some(f.value().clone()), false);
137        }
138        // Step 3: fallback to ANY snapshot for this acc_id (latest available
139        // currency, 等价于 C++ "first available")
140        for entry in self.funds.iter() {
141            if entry.key().acc_id == acc_id {
142                return (Some(entry.value().clone()), false);
143            }
144        }
145        (None, false)
146    }
147
148    pub fn update_positions(&self, acc_id: u64, positions: Vec<CachedPosition>) {
149        self.update_positions_scoped(acc_id, 0, positions);
150    }
151
152    pub fn update_positions_scoped(
153        &self,
154        acc_id: u64,
155        asset_category: i32,
156        positions: Vec<CachedPosition>,
157    ) {
158        let key = positions_cache_key(acc_id, asset_category);
159        self.positions.insert(key, positions);
160    }
161
162    pub fn update_combo_positions_scoped(
163        &self,
164        acc_id: u64,
165        asset_category: i32,
166        positions: Vec<CachedPosition>,
167    ) {
168        let key = positions_cache_key(acc_id, asset_category);
169        self.combo_positions.insert(key, positions);
170    }
171
172    #[must_use]
173    pub fn get_positions_scoped(
174        &self,
175        acc_id: u64,
176        asset_category: i32,
177    ) -> Option<Vec<CachedPosition>> {
178        let key = positions_cache_key(acc_id, asset_category);
179        self.positions.get(&key).map(|p| p.value().clone())
180    }
181
182    #[must_use]
183    pub fn get_combo_positions_scoped(
184        &self,
185        acc_id: u64,
186        asset_category: i32,
187    ) -> Option<Vec<CachedPosition>> {
188        let key = positions_cache_key(acc_id, asset_category);
189        self.combo_positions.get(&key).map(|p| p.value().clone())
190    }
191
192    #[must_use]
193    pub fn has_positions_scoped(&self, acc_id: u64, asset_category: i32) -> bool {
194        let key = positions_cache_key(acc_id, asset_category);
195        self.positions.contains_key(&key)
196    }
197
198    #[must_use]
199    pub fn has_combo_positions_scoped(&self, acc_id: u64, asset_category: i32) -> bool {
200        let key = positions_cache_key(acc_id, asset_category);
201        self.combo_positions.contains_key(&key)
202    }
203
204    /// C++ `INNData_Trd_Acc::GetComboPositionItem(nAccID, Unknown, nPositionID)`.
205    ///
206    /// Combo trade-write paths receive public FTAPI `positionID` (a hash). The
207    /// backend requires the original `business_position_id` string plus the
208    /// position's long account/sub-account ids. Search the combo-position view
209    /// for the account and return the cached row instead of guessing.
210    #[must_use]
211    pub fn find_combo_position_item(
212        &self,
213        acc_id: u64,
214        position_id: u64,
215    ) -> Option<CachedPosition> {
216        if position_id == 0 {
217            return None;
218        }
219        let legacy_key = positions_cache_key(acc_id, 0);
220        if let Some(positions) = self.combo_positions.get(&legacy_key)
221            && let Some(position) = positions
222                .value()
223                .iter()
224                .find(|position| position.position_id == position_id)
225        {
226            return Some(position.clone());
227        }
228        for entry in self.combo_positions.iter() {
229            if entry.key().acc_id != acc_id || *entry.key() == legacy_key {
230                continue;
231            }
232            if let Some(position) = entry
233                .value()
234                .iter()
235                .find(|position| position.position_id == position_id)
236            {
237                return Some(position.clone());
238            }
239        }
240        None
241    }
242}
243
244fn positions_cache_key(acc_id: u64, asset_category: i32) -> PositionsCacheKey {
245    if asset_category != 0 {
246        PositionsCacheKey::scoped(acc_id, asset_category)
247    } else {
248        PositionsCacheKey::legacy(acc_id)
249    }
250}