Skip to main content

futu_backend/trade_query/
account_info.rs

1use super::common::{
2    api_currency_to_backend, backend_currency_to_api, build_market_info_list,
3    currency_to_fund_bond_ccy, pf, pfo, sum_diff_market_fund_assets_in_response_currency,
4    trd_market_to_currency,
5};
6use super::*;
7
8mod positions;
9#[cfg(test)]
10use positions::ComboPositionMeta;
11use positions::{
12    PositionAccountContext, cached_position_from_account_pstn, combo_positions_from_account_info,
13};
14
15#[derive(Debug, Clone, Default)]
16struct AccountInfoSidecarPlan {
17    account_market: Option<i32>,
18    security_firm: Option<i32>,
19    unique_id: u64,
20}
21
22impl AccountInfoSidecarPlan {
23    fn from_cache(trd_cache: &TrdCache, acc_id: u64) -> Self {
24        trd_cache
25            .accounts
26            .get(&acc_id)
27            .map(|acc| {
28                let acc = acc.value();
29                Self {
30                    account_market: acc.trd_market,
31                    security_firm: acc.security_firm,
32                    // C++ `INNData_Trd_AllAccList::GetAccUniqueID`:
33                    // `(BrokerID << 48) | (NN_TrdMarket_ConvC2S(enTrdMkt) << 32)
34                    // | IntraAccID`. `CachedTrdAcc.sort_key` stores the same
35                    // tuple from the backend account list.
36                    unique_id: if acc.sort_key != 0 {
37                        acc.sort_key
38                    } else {
39                        acc_id
40                    },
41                }
42            })
43            .unwrap_or(Self {
44                unique_id: acc_id,
45                ..Self::default()
46            })
47    }
48
49    fn is_hk_us_fund_account(&self) -> bool {
50        matches!(self.account_market, Some(13 | 22 | 23 | 113 | 123))
51    }
52
53    fn universal_supports_fund_sidecar(&self) -> bool {
54        // C++ `NNProto_Trd_AccReal::QueryFundNoLimit` sends CMD20086 for
55        // `m_enTrdMkt == NN_TrdMarket_SG` only, then gates by
56        // `IsUniAccSupportFund` (Futu HK / SG / MY / JP). Do not reuse the
57        // wider currency-classification fallback (`uni_card_num +
58        // security_firm`): legacy HK/US accounts can carry `uni_card_num`, but
59        // C++ still runs `QueryAssetInner(..., false)` and does not send the
60        // fund/bond sidecar for those markets.
61        matches!(self.account_market, Some(6)) && matches!(self.security_firm, Some(1 | 3 | 6 | 7))
62    }
63}
64
65/// 查询真实账户资金+持仓 (CMD 3020: AccountInfoReq)
66///
67/// **v1.4.106 codex 1556 F1+F2 (P1) 修法**:
68///
69/// - F1: 接 `currency: Option<i32>` — caller (handler) 把 user 请求的
70///   currency 透传进来; None 时由 daemon 补账户默认币种再发 CMD3020。
71///   对齐 C++ `QueryFundNoLimit` 最终要求 backend `AccountInfoReq`
72///   携带有效 `union_currency`; backend 对缺省值会报
73///   `unsupported currency:NONE`.
74///
75/// - F2: transport / decode error → `Err` (loud propagate). 之前 `Ok(())`
76///   silent 让 caller 看到 cache miss + `ret_type=0 + s2c.funds=None`
77///   (silent-success 反模式 D / pitfall #45). C++ 失败不会伪装成功.
78///
79/// - v1.4.107: when caller has no user-requested `assetCategory`, match C++
80///   `GetCategoriesByKouzaType`: FutuJP margin sends Foreign(2), FutuJP
81///   derivative fans out Domestic(1) + Foreign(2), other accounts send no
82///   asset_category field.
83pub async fn query_account_info(
84    backend: &BackendConn,
85    acc_id: u64,
86    trd_cache: &TrdCache,
87    requested_currency: Option<i32>,
88    requested_asset_category: Option<i32>,
89) -> Result<()> {
90    let category_plan =
91        account_info_asset_category_plan(trd_cache, acc_id, requested_asset_category);
92    for category in category_plan {
93        query_account_info_one(
94            backend,
95            acc_id,
96            trd_cache,
97            requested_currency,
98            category,
99            false,
100        )
101        .await?;
102    }
103    Ok(())
104}
105
106#[cfg(test)]
107mod tests;
108
109/// Query real-account positions through CMD3020 using the C++ PositionList
110/// request shape.
111///
112/// C++ `QueryPositionListNoLimit` calls `QueryAssetInner(...,
113/// bWithoutFund=true, ...)`, so this wrapper keeps the same asset-category
114/// fanout as [`query_account_info`] but asks backend to omit fund/bond asset
115/// data from the response.
116pub async fn query_position_account_info(
117    backend: &BackendConn,
118    acc_id: u64,
119    trd_cache: &TrdCache,
120    requested_asset_category: Option<i32>,
121    requested_currency: Option<i32>,
122) -> Result<()> {
123    let category_plan =
124        account_info_asset_category_plan(trd_cache, acc_id, requested_asset_category);
125    for category in category_plan {
126        query_account_info_one(
127            backend,
128            acc_id,
129            trd_cache,
130            requested_currency,
131            category,
132            true,
133        )
134        .await?;
135    }
136    Ok(())
137}
138
139fn account_info_asset_category_plan(
140    trd_cache: &TrdCache,
141    acc_id: u64,
142    requested_asset_category: Option<i32>,
143) -> Vec<Option<i32>> {
144    if let Some(category) = requested_asset_category.filter(|category| *category > 0) {
145        return vec![Some(category)];
146    }
147
148    if let Some(acc) = trd_cache.accounts.get(&acc_id) {
149        let acc = acc.value();
150        let is_jp_broker = acc.security_firm == Some(7);
151        if is_jp_broker {
152            match acc.kouza_type {
153                Some(2) => return vec![Some(2)],
154                Some(3) => return vec![Some(1), Some(2)],
155                _ => {}
156            }
157        }
158    }
159
160    vec![None]
161}
162
163fn cmd3020_union_currency(
164    trd_cache: &TrdCache,
165    acc_id: u64,
166    requested_currency: Option<u32>,
167) -> u32 {
168    requested_currency
169        .or_else(|| cmd3020_default_currency_from_cache(trd_cache, acc_id))
170        .unwrap_or(1)
171}
172
173fn cmd3020_default_currency_from_cache(trd_cache: &TrdCache, acc_id: u64) -> Option<u32> {
174    let acc = trd_cache.lookup_account(acc_id)?;
175
176    // C++ `NNProto_Trd_AccBase::GetQueryCurrencySet`: omitted currency first
177    // uses common-currency cache; if that set is empty, it falls back to
178    // `INNData_Trd_CommonCurrency::GetAccountFirstValidCurrency(accItem)`.
179    // Rust does not yet persist C++'s common-currency deque, so the safe
180    // backend refresh default is the same first-valid rule. User-facing
181    // GetFunds defaults stay in `futu_trd::read_plan`.
182    futu_trd::currency::first_valid_currency_for_account(
183        acc.security_firm,
184        acc.trd_market,
185        acc.uni_card_num.as_deref(),
186        &acc.trd_market_auth_list,
187    )
188    .map(|currency| currency as u32)
189    .or_else(|| acc.trd_market.map(trd_market_to_currency))
190}
191
192async fn query_account_info_one(
193    backend: &BackendConn,
194    acc_id: u64,
195    trd_cache: &TrdCache,
196    requested_currency: Option<i32>,
197    effective_asset_category: Option<i32>,
198    without_fund_and_bond_data: bool,
199) -> Result<()> {
200    use prost::Message;
201
202    // CMD3020 backend requires an explicit `union_currency`. C++ fills a
203    // default when the FTAPI request omits `currency`; sending None reaches
204    // backend as `unsupported currency:NONE`.
205    //
206    // FTAPI proto.Trd_GetFunds.currency is public `NN_TrdCurrency`, while
207    // backend `asset_query::AccountInfoReq.union_currency` is
208    // `odr_sys_cmn::Currency`. C++ converts through
209    // `NN_TrdCurrency_ConvC2S` before sending CMD3020; AUD/CAD/MYR are not
210    // numerically equal across the two enums.
211    let requested_currency_u32: Option<u32> =
212        requested_currency.and_then(|c| u32::try_from(c).ok());
213    let union_currency_api_u32 = cmd3020_union_currency(trd_cache, acc_id, requested_currency_u32);
214    let union_currency_backend_u32 =
215        api_currency_to_backend(union_currency_api_u32 as i32).unwrap_or(1);
216    let mut sidecar_currency_u32 = union_currency_api_u32;
217
218    let asset_category_u32: Option<u32> =
219        effective_asset_category.and_then(|a| u32::try_from(a).ok());
220    let cache_asset_category = effective_asset_category.filter(|a| *a > 0).unwrap_or(0);
221    let sidecar_plan = AccountInfoSidecarPlan::from_cache(trd_cache, acc_id);
222    let should_query_fund_bond_sidecar = !without_fund_and_bond_data
223        && (sidecar_plan.is_hk_us_fund_account() || sidecar_plan.universal_supports_fund_sidecar());
224    let skip_account_info_for_fund_account =
225        !without_fund_and_bond_data && sidecar_plan.is_hk_us_fund_account();
226    let account_info_without_fund =
227        without_fund_and_bond_data || sidecar_plan.universal_supports_fund_sidecar();
228
229    if !skip_account_info_for_fund_account {
230        let req = asset_query::AccountInfoReq {
231            // v1.4.110 P0-1: cipher: Some(vec![]) — C++ always sets cipher
232            // (empty if not unlocked). 走 msg_header::build_real 收口.
233            msg_header: Some(crate::msg_header::build_real(
234                acc_id,
235                Some(vec![]),
236                None,
237                None,
238            )),
239            union_currency: Some(union_currency_backend_u32),
240            select_field_list: vec![], // 返回全部
241            quote_level: Some(1),      // US_BASIC
242            quote_type: Some(1),       // BOTH
243            with_position_im: None,
244            notice_type: None,
245            with_matched_quantity: None,
246            // C++ `QueryFundNoLimit` sends SG/Universal AccountInfoReq with
247            // `without_fund_and_bond_data=true` and merges CMD20086
248            // FundBondDetailAsset separately.
249            without_fund_and_bond_data: Some(account_info_without_fund),
250            use_overnight_price: Some(true),
251            without_combo: None,
252            without_delisted_symbol: None,
253            without_zero_quantity_pstn: None,
254            aas_fallback: None,
255            version: None,
256            expand_portfolio: None,
257            asset_category: asset_category_u32, // v1.4.106 F3: JP 衍生品账户必传; 其他类型 None
258            op_nn_uid: None,
259            high_prec_cur_price: None,
260            use_high_prec: None,
261        };
262
263        // v1.4.106 codex 1556 F2 (P1): transport/decode error 必须 loud propagate
264        // (Err), 不允许 silent Ok(()). caller (handler) 已经 loud-propagate Err,
265        // user 可以看到清晰 backend 失败原因 (cipher 过期 / acc 状态 / 反刷).
266        let resp = backend
267            .request(CMD_ACCOUNT_INFO, req.encode_to_vec())
268            .await
269            .map_err(|e| {
270                tracing::warn!(acc_id, error = %e, "CMD3020 account info query failed (loud propagate per audit 1556 F2)");
271                e
272            })?;
273
274        let parsed: asset_query::AccountInfoRsp =
275            Message::decode(resp.body.as_ref()).map_err(|e| {
276                tracing::warn!(acc_id, error = %e, body_len = resp.body.len(),
277                               "CMD3020 decode failed (loud propagate per audit 1556 F2)");
278                futu_core::error::FutuError::Proto(e)
279            })?;
280        account_info_response_status_like_cpp(&parsed, acc_id)?;
281
282        tracing::info!(
283            acc_id,
284            has_fund = parsed.union_fund_info.is_some(),
285            has_cash = parsed.union_cash_info.is_some(),
286            positions = parsed.pstn_info_list.len(),
287            "CMD3020 response parsed"
288        );
289
290        // === 资金 ===
291        if let Some(ref fund_info) = parsed.union_fund_info {
292            let backend_top_currency = fund_info
293                .currency
294                .or_else(|| parsed.union_cash_info.as_ref().and_then(|c| c.currency));
295            let currency = backend_top_currency.map(backend_currency_to_api);
296            if requested_currency_u32.is_none()
297                && let Some(currency) = currency
298                && currency > 0
299            {
300                sidecar_currency_u32 = currency as u32;
301            }
302            let cash_currency = parsed
303                .union_cash_info
304                .as_ref()
305                .and_then(|c| c.currency)
306                .map(backend_currency_to_api);
307            let cash = parsed
308                .union_cash_info
309                .as_ref()
310                .map(|c| pf(&c.balance))
311                .unwrap_or(0.0);
312            let avl_withdrawal = parsed
313                .union_cash_info
314                .as_ref()
315                .map(|c| pf(&c.cash_drawable))
316                .unwrap_or(0.0);
317
318            // C++ dt_status mapping: 2→Unlimited, 3→EMCall, 4/5→DTCall, else→Unknown
319            let _dt_status_raw = fund_info.dt_status.unwrap_or(0);
320
321            let market_info_list = build_market_info_list(&parsed.fund_info_list);
322
323            let securities_assets = sum_diff_market_fund_assets_in_response_currency(
324                &parsed.diff_market_fund_info_list,
325            )
326            .or_else(|| {
327                // Legacy backend fallback before `diff_market_fund_info_list`:
328                // keep the old native marketInfo projection rather than
329                // fabricating a cross-currency total.
330                let req_currency = currency.unwrap_or(0);
331                let markets_currencies: [(i32, i32); 8] = [
332                    (1, 1),
333                    (2, 2),
334                    (4, 3),
335                    (15, 4),
336                    (6, 5),
337                    (8, 6),
338                    (112, 7),
339                    (111, 8),
340                ];
341                let sum: f64 = market_info_list
342                    .iter()
343                    .filter_map(|mi| {
344                        markets_currencies
345                            .iter()
346                            .find(|&&(m, native_currency)| {
347                                m == mi.trd_market && native_currency == req_currency
348                            })
349                            .map(|_| mi.assets)
350                    })
351                    .sum();
352                (!market_info_list.is_empty()).then_some(sum)
353            });
354
355            trd_cache.update_funds_scoped_with_returned_currency(
356                acc_id,
357                cache_asset_category,
358                requested_currency,
359                CachedFunds {
360                    power: pf(&fund_info.max_power_long),
361                    total_assets: pf(&fund_info.total_asset),
362                    cash,
363                    market_val: pf(&fund_info.mv),
364                    frozen_cash: pf(&fund_info.hold),
365                    debt_cash: pf(&fund_info.debit_recover),
366                    avl_withdrawal_cash: avl_withdrawal,
367                    currency,
368                    available_funds: pfo(&fund_info.available),
369                    unrealized_pl: pfo(&fund_info.unrealized_profit),
370                    realized_pl: pfo(&fund_info.realized_profit),
371                    risk_level: fund_info.risk_level.map(|r| r as i32),
372                    initial_margin: pfo(&fund_info.initial_margin),
373                    maintenance_margin: pfo(&fund_info.maintenance_margin),
374                    max_power_short: pfo(&fund_info.max_power_short),
375                    net_cash_power: pfo(&fund_info.net_cash_power),
376                    long_mv: pfo(&fund_info.long_mv),
377                    short_mv: pfo(&fund_info.short_mv),
378                    pending_asset: pfo(&fund_info.pending_asset),
379                    max_withdrawal: pfo(&fund_info.drawable),
380                    risk_status: fund_info.risk_status_client,
381                    margin_call_margin: pfo(&fund_info.margin_call),
382                    securities_assets,
383                    fund_assets: None,
384                    bond_assets: None,
385                    crypto_mv: None,
386                    exposure_level: None,
387                    exposure_limit: None,
388                    used_limit: None,
389                    remaining_limit: None,
390                    // v1.4.98 T1-4 (mobile-source-audit): US PDT 6 字段透传
391                    // (asset_query.proto:80-102 backend already returns 这些)
392                    is_pdt: fund_info.is_pdt,
393                    // pdt_seq: backend repeated int32, daemon join 成 string
394                    // ("1,3,5") 对齐 proto/Trd_Common.proto:378 string 类型
395                    // (mobile UI 直接显示数字 list)
396                    pdt_seq: if fund_info.pdt_seq.is_empty() {
397                        None
398                    } else {
399                        Some(
400                            fund_info
401                                .pdt_seq
402                                .iter()
403                                .map(|n| n.to_string())
404                                .collect::<Vec<_>>()
405                                .join(","),
406                        )
407                    },
408                    beginning_dtbp: pfo(&fund_info.beginning_dtbp),
409                    remaining_dtbp: pfo(&fund_info.remaining_dtbp),
410                    dt_call_amount: pfo(&fund_info.dt_call_amount),
411                    dt_status: fund_info.dt_status,
412                    cash_info_list: parsed
413                        .cash_info_list
414                        .iter()
415                        .map(|c| CachedCashInfo {
416                            currency: c.currency.map(backend_currency_to_api).unwrap_or(0),
417                            cash: pf(&c.balance),
418                            available_balance: pf(&c.cash_drawable),
419                            net_cash_power: pf(&c.cash_buypower),
420                        })
421                        .collect(),
422                    market_info_list,
423                },
424            );
425
426            tracing::info!(
427                acc_id,
428                power = pf(&fund_info.max_power_long),
429                total = pf(&fund_info.total_asset),
430                top_currency = ?currency,
431                cash_currency = ?cash_currency,
432                "fund cached via CMD3020"
433            );
434        }
435
436        // === 持仓 ===
437        let position_account = PositionAccountContext::from_cache(trd_cache, acc_id);
438        let positions: Vec<CachedPosition> = parsed
439            .pstn_info_list
440            .iter()
441            .map(|p| cached_position_from_account_pstn(p, None, &position_account))
442            .collect();
443
444        tracing::info!(
445            acc_id,
446            count = positions.len(),
447            "positions cached via CMD3020"
448        );
449        // C++ `FillPositionList` reads the current `Ndt_Trd_AccPosition` array
450        // returned for the asset key. A successful empty backend reply means the
451        // authoritative position list is empty, so we must replace the cache with
452        // `[]`; keeping the previous vector would resurrect sold-out positions.
453        trd_cache.update_positions_scoped(acc_id, cache_asset_category, positions);
454
455        let combo_positions =
456            combo_positions_from_account_info(&position_account, &parsed.combo_info_list);
457        tracing::info!(
458            acc_id,
459            count = combo_positions.len(),
460            "combo positions cached via CMD3020"
461        );
462        // C++ `NNProto_Trd_AccReal::OnReply_QueryAsset` always calls
463        // `SetComboPositionList` with the unpacked combo list. Empty backend
464        // combo_info_list therefore clears stale combo-view cache.
465        trd_cache.update_combo_positions_scoped(acc_id, cache_asset_category, combo_positions);
466    }
467
468    if should_query_fund_bond_sidecar {
469        let sidecar_result = query_fund_bond_detail_asset(
470            backend,
471            acc_id,
472            trd_cache,
473            requested_currency,
474            cache_asset_category,
475            sidecar_currency_u32,
476            sidecar_plan,
477        )
478        .await;
479        if let Err(err) = sidecar_result {
480            if account_info_without_fund {
481                // C++ `QueryFundNoLimit` batches CMD3020 and CMD20086 for
482                // Universal accounts. The API layer later fills the response
483                // from cache; if CMD3020 has already succeeded, CMD20086 is an
484                // enrichment for fund/bond totals. Do not make accinfo unusable
485                // just because the sidecar returned a business `unknown`.
486                tracing::warn!(
487                    acc_id,
488                    error = %err,
489                    "CMD20086 sidecar failed after CMD3020 success; keeping Universal account funds from CMD3020"
490                );
491            } else {
492                // HK/US fund-account branch sends only CMD20086, so there is
493                // no CMD3020 base cache to fall back to. Keep this fail-loud.
494                return Err(err);
495            }
496        }
497    }
498
499    tracing::info!(
500        acc_id,
501        asset_category = ?cache_asset_category,
502        without_fund_and_bond_data,
503        "CMD3020 warmup complete"
504    );
505
506    Ok(())
507}
508
509fn account_info_response_status_like_cpp(
510    parsed: &asset_query::AccountInfoRsp,
511    acc_id: u64,
512) -> Result<()> {
513    // Ref: FutuOpenD/Src/NNProtoCenter/Trade/_NNProto_Trd_Comm.h:20-30
514    // and Trade/Asset/NNProto_Trd_AccReal.cpp:512-568.
515    // C++ requires `result`, `msg_header`, and matching `msg_header.account_id`
516    // before writing CMD3020 funds / positions into account cache.
517    let Some(result_code) = parsed.result else {
518        return Err(futu_core::error::FutuError::Codec(
519            "CMD3020 account info missing result".to_string(),
520        ));
521    };
522    if result_code != 0 {
523        let err = parsed.err_msg.as_deref().unwrap_or("unknown");
524        tracing::warn!(acc_id, result_code, err, "CMD3020 returned error");
525        // v1.4.53 A2 log bug 修:之前 return Ok(()) 让上层 bridge.rs 误判
526        // "succeeded"。现在正确返 Err 让 dispatch_trade_data_queries 触发 warn
527        // fallback 分支。
528        return Err(futu_core::error::FutuError::ServerError {
529            ret_type: result_code,
530            msg: format!("CMD3020 business error: {err}"),
531        });
532    }
533    let header = parsed.msg_header.as_ref().ok_or_else(|| {
534        futu_core::error::FutuError::Codec("CMD3020 account info missing msg_header".to_string())
535    })?;
536    let backend_acc_id = header.account_id.ok_or_else(|| {
537        futu_core::error::FutuError::Codec(
538            "CMD3020 account info msg_header missing account_id".to_string(),
539        )
540    })?;
541    if backend_acc_id != acc_id {
542        return Err(futu_core::error::FutuError::Codec(format!(
543            "CMD3020 account info account mismatch: server={backend_acc_id} local={acc_id}"
544        )));
545    }
546    Ok(())
547}
548
549async fn query_fund_bond_detail_asset(
550    backend: &BackendConn,
551    acc_id: u64,
552    trd_cache: &TrdCache,
553    requested_currency: Option<i32>,
554    cache_asset_category: i32,
555    currency_u32: u32,
556    sidecar_plan: AccountInfoSidecarPlan,
557) -> Result<()> {
558    use prost::Message;
559
560    let ccy = currency_to_fund_bond_ccy(currency_u32).to_string();
561    let req = mobile_fund_asset::FundBondDetailAssetReq {
562        unique_id: Some(sidecar_plan.unique_id),
563        ccy: Some(ccy.clone()),
564        asset_type: Some(mobile_fund_asset::AssetType::AllAsset as i32),
565    };
566
567    let resp = backend
568        .request(CMD_FUND_BOND_DETAIL_ASSET, req.encode_to_vec())
569        .await
570        .map_err(|e| {
571            tracing::warn!(
572                acc_id,
573                unique_id = sidecar_plan.unique_id,
574                ccy,
575                error = %e,
576                "CMD20086 fund/bond detail asset query failed"
577            );
578            e
579        })?;
580
581    let parsed: mobile_fund_asset::FundBondDetailAssetRsp = Message::decode(resp.body.as_ref())
582        .map_err(|e| {
583            tracing::warn!(
584                acc_id,
585                body_len = resp.body.len(),
586                error = %e,
587                "CMD20086 decode failed"
588            );
589            futu_core::error::FutuError::Proto(e)
590        })?;
591
592    if parsed.error_code.unwrap_or(-1) != 0 {
593        let result_code = parsed.error_code.unwrap_or(-1);
594        let err = parsed.error_msg.as_deref().unwrap_or("unknown");
595        tracing::warn!(
596            acc_id,
597            account_market = ?sidecar_plan.account_market,
598            security_firm = ?sidecar_plan.security_firm,
599            unique_id = sidecar_plan.unique_id,
600            ccy,
601            asset_type = mobile_fund_asset::AssetType::AllAsset as i32,
602            result_code,
603            err,
604            "CMD20086 returned error"
605        );
606        return Err(futu_core::error::FutuError::ServerError {
607            ret_type: result_code,
608            msg: format!("CMD20086 fund/bond detail asset business error: {err}"),
609        });
610    }
611
612    let fund_asset = parsed
613        .fund_asset
614        .as_ref()
615        .map(|asset| pf(&asset.fund_asset))
616        .ok_or_else(|| futu_core::error::FutuError::ServerError {
617            ret_type: -1,
618            msg: "CMD20086 missing fund_asset".to_string(),
619        })?;
620    let bond_asset = parsed
621        .bond_asset
622        .as_ref()
623        .map(|asset| pf(&asset.bond_asset))
624        .ok_or_else(|| futu_core::error::FutuError::ServerError {
625            ret_type: -1,
626            msg: "CMD20086 missing bond_asset".to_string(),
627        })?;
628
629    // v1.4.111 P2-1 Tier 2 audit log: cache miss 时 trace. CMD20086 是 partial cache
630    // update — 只更新 fund_assets/bond_assets, cash_info_list 等由其他 CMD 维护.
631    // cold-start 时 existing=None → 新建 CachedFunds::default() + 写 fund/bond 是
632    // legit partial update; 不是 silent corruption (audit verified, essentials/
633    // 2026-05-27). cash 字段空是合理 default, 等其他 push 来填.
634    let (existing, _) =
635        trd_cache.get_funds_scoped(acc_id, cache_asset_category, requested_currency);
636    let mut funds = existing.unwrap_or_else(|| {
637        tracing::debug!(
638            acc_id,
639            cache_asset_category,
640            requested_currency,
641            "CMD20086 fund query: cache miss, creating new CachedFunds (partial update with fund/bond)"
642        );
643        Default::default()
644    });
645    funds.fund_assets = Some(fund_asset);
646    funds.bond_assets = Some(bond_asset);
647
648    if sidecar_plan.is_hk_us_fund_account() {
649        // C++ fund-account branch displays fund + bond as the total account
650        // assets. `total_asset.pending_asset` is copied into
651        // `Ndt_Trd_AccFund::fPendingAsset` by `UnPackFundFunds`.
652        //
653        // C++ `UnPackFundFunds` treats `total_asset` as required for HK/US
654        // fund accounts; a missing field turns the whole QueryFund reply into
655        // a data error instead of a success with partial fund/bond totals.
656        let total = parsed.total_asset.as_ref().ok_or_else(|| {
657            futu_core::error::FutuError::ServerError {
658                ret_type: -1,
659                msg: "CMD20086 missing total_asset for fund account".to_string(),
660            }
661        })?;
662        funds.total_assets = fund_asset + bond_asset;
663        funds.pending_asset = pfo(&total.pending_asset);
664    } else if sidecar_plan.universal_supports_fund_sidecar() {
665        // C++ SG/Universal branch sends AccountInfoReq with
666        // `without_fund_and_bond_data=true`, then adds the two sidecar totals
667        // to the securities net asset before filling `totalAssets`.
668        funds.total_assets += fund_asset + bond_asset;
669    }
670
671    trd_cache.update_funds_scoped_with_returned_currency(
672        acc_id,
673        cache_asset_category,
674        requested_currency,
675        funds,
676    );
677
678    tracing::info!(
679        acc_id,
680        unique_id = sidecar_plan.unique_id,
681        ccy,
682        fund_asset,
683        bond_asset,
684        "fund/bond totals cached via CMD20086"
685    );
686
687    Ok(())
688}