Skip to main content

futu_backend/trade_query/
account_info.rs

1use super::common::{
2    backend_currency_to_api, backend_market_to_trd_market, backend_stock_market_to_sec_market,
3    build_market_info_list, currency_to_fund_bond_ccy, pf, pfo,
4    sum_diff_market_fund_assets_in_response_currency, trd_market_to_currency,
5};
6use super::*;
7
8#[derive(Debug, Clone, Default)]
9struct AccountInfoSidecarPlan {
10    account_market: Option<i32>,
11    security_firm: Option<i32>,
12    uni_card_num: Option<String>,
13    unique_id: u64,
14}
15
16impl AccountInfoSidecarPlan {
17    fn from_cache(trd_cache: &TrdCache, acc_id: u64) -> Self {
18        trd_cache
19            .accounts
20            .get(&acc_id)
21            .map(|acc| {
22                let acc = acc.value();
23                Self {
24                    account_market: acc.trd_market,
25                    security_firm: acc.security_firm,
26                    uni_card_num: acc.uni_card_num.clone(),
27                    // C++ `INNData_Trd_AllAccList::GetAccUniqueID`:
28                    // `(BrokerID << 48) | (NN_TrdMarket_ConvC2S(enTrdMkt) << 32)
29                    // | IntraAccID`. `CachedTrdAcc.sort_key` stores the same
30                    // tuple from the backend account list.
31                    unique_id: if acc.sort_key != 0 {
32                        acc.sort_key
33                    } else {
34                        acc_id
35                    },
36                }
37            })
38            .unwrap_or(Self {
39                unique_id: acc_id,
40                ..Self::default()
41            })
42    }
43
44    fn is_hk_us_fund_account(&self) -> bool {
45        matches!(self.account_market, Some(13 | 22 | 23 | 113 | 123))
46    }
47
48    fn is_universal(&self) -> bool {
49        if matches!(self.account_market, Some(6)) {
50            return true;
51        }
52        // Rust cache may carry backend raw Account.market values (for example
53        // AU/JP/MY/CA) even though C++ m_enTrdMkt is the normalized Universal
54        // enum. Mirror the gateway currency classifier's cache-drift defense:
55        // a non-empty universal card number plus known broker is enough to
56        // keep Universal-only sidecars enabled.
57        self.uni_card_num
58            .as_deref()
59            .is_some_and(|s| !s.trim().is_empty())
60            && self.security_firm.is_some()
61    }
62
63    fn universal_supports_fund_sidecar(&self) -> bool {
64        // C++ `IsUniAccSupportFund`: Futu HK / SG / MY / JP.
65        self.is_universal() && matches!(self.security_firm, Some(1 | 3 | 6 | 7))
66    }
67}
68
69/// 查询真实账户资金+持仓 (CMD 3020: AccountInfoReq)
70///
71/// **v1.4.106 codex 1556 F1+F2 (P1) 修法**:
72///
73/// - F1: 接 `currency: Option<i32>` — caller (handler) 把 user 请求的
74///   currency 透传进来; None 时由 daemon 补账户默认币种再发 CMD3020。
75///   对齐 C++ `QueryFundNoLimit` 最终要求 backend `AccountInfoReq`
76///   携带有效 `union_currency`; backend 对缺省值会报
77///   `unsupported currency:NONE`.
78///
79/// - F2: transport / decode error → `Err` (loud propagate). 之前 `Ok(())`
80///   silent 让 caller 看到 cache miss + `ret_type=0 + s2c.funds=None`
81///   (silent-success 反模式 D / pitfall #45). C++ 失败不会伪装成功.
82///
83/// - v1.4.107: when caller has no user-requested `assetCategory`, match C++
84///   `GetCategoriesByKouzaType`: FutuJP margin sends Foreign(2), FutuJP
85///   derivative fans out Domestic(1) + Foreign(2), other accounts send no
86///   asset_category field.
87pub async fn query_account_info(
88    backend: &BackendConn,
89    acc_id: u64,
90    trd_cache: &TrdCache,
91    requested_currency: Option<i32>,
92    requested_asset_category: Option<i32>,
93) -> Result<()> {
94    let category_plan =
95        account_info_asset_category_plan(trd_cache, acc_id, requested_asset_category);
96    for category in category_plan {
97        query_account_info_one(
98            backend,
99            acc_id,
100            trd_cache,
101            requested_currency,
102            category,
103            false,
104        )
105        .await?;
106    }
107    Ok(())
108}
109
110#[cfg(test)]
111mod tests;
112
113/// Query real-account positions through CMD3020 using the C++ PositionList
114/// request shape.
115///
116/// C++ `QueryPositionListNoLimit` calls `QueryAssetInner(...,
117/// bWithoutFund=true, ...)`, so this wrapper keeps the same asset-category
118/// fanout as [`query_account_info`] but asks backend to omit fund/bond asset
119/// data from the response.
120pub async fn query_position_account_info(
121    backend: &BackendConn,
122    acc_id: u64,
123    trd_cache: &TrdCache,
124    requested_asset_category: Option<i32>,
125    requested_currency: Option<i32>,
126) -> Result<()> {
127    let category_plan =
128        account_info_asset_category_plan(trd_cache, acc_id, requested_asset_category);
129    for category in category_plan {
130        query_account_info_one(
131            backend,
132            acc_id,
133            trd_cache,
134            requested_currency,
135            category,
136            true,
137        )
138        .await?;
139    }
140    Ok(())
141}
142
143fn account_info_asset_category_plan(
144    trd_cache: &TrdCache,
145    acc_id: u64,
146    requested_asset_category: Option<i32>,
147) -> Vec<Option<i32>> {
148    if let Some(category) = requested_asset_category.filter(|category| *category > 0) {
149        return vec![Some(category)];
150    }
151
152    if let Some(acc) = trd_cache.accounts.get(&acc_id) {
153        let acc = acc.value();
154        let is_jp_broker = acc.security_firm == Some(7);
155        if is_jp_broker {
156            match acc.kouza_type {
157                Some(2) => return vec![Some(2)],
158                Some(3) => return vec![Some(1), Some(2)],
159                _ => {}
160            }
161        }
162    }
163
164    vec![None]
165}
166
167fn cmd3020_union_currency(
168    trd_cache: &TrdCache,
169    acc_id: u64,
170    requested_currency: Option<u32>,
171) -> u32 {
172    requested_currency
173        .or_else(|| cmd3020_default_currency_from_cache(trd_cache, acc_id))
174        .unwrap_or(1)
175}
176
177fn cmd3020_default_currency_from_cache(trd_cache: &TrdCache, acc_id: u64) -> Option<u32> {
178    let acc = trd_cache.lookup_account(acc_id)?;
179
180    // C++ `NNProto_Trd_AccBase::GetQueryCurrencySet`: omitted currency first
181    // uses common-currency cache; if that set is empty, it falls back to
182    // `INNData_Trd_CommonCurrency::GetAccountFirstValidCurrency(accItem)`.
183    // Rust does not yet persist C++'s common-currency deque, so the safe
184    // backend refresh default is the same first-valid rule. User-facing
185    // GetFunds defaults stay in `futu_trd::read_plan`.
186    futu_trd::currency::first_valid_currency_for_account(
187        acc.security_firm,
188        acc.trd_market,
189        acc.uni_card_num.as_deref(),
190        &acc.trd_market_auth_list,
191    )
192    .map(|currency| currency as u32)
193    .or_else(|| acc.trd_market.map(trd_market_to_currency))
194}
195
196async fn query_account_info_one(
197    backend: &BackendConn,
198    acc_id: u64,
199    trd_cache: &TrdCache,
200    requested_currency: Option<i32>,
201    effective_asset_category: Option<i32>,
202    without_fund_and_bond_data: bool,
203) -> Result<()> {
204    use prost::Message;
205
206    // CMD3020 backend requires an explicit `union_currency`. C++ fills a
207    // default when the FTAPI request omits `currency`; sending None reaches
208    // backend as `unsupported currency:NONE`.
209    //
210    // FTAPI proto.Trd_GetFunds.currency is i32, backend asset_query is u32.
211    // Handler 层已对 Futures/Universal currency 和 JP derivative assetCategory
212    // 做 loud validation;这里仍避免把负数 cast 成巨大 u32.
213    let requested_currency_u32: Option<u32> =
214        requested_currency.and_then(|c| u32::try_from(c).ok());
215    let union_currency_u32 = cmd3020_union_currency(trd_cache, acc_id, requested_currency_u32);
216    let mut sidecar_currency_u32 = union_currency_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_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 codex 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 codex 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            if requested_currency_u32.is_none()
296                && let Some(currency) = backend_top_currency
297            {
298                sidecar_currency_u32 = currency;
299            }
300            let currency = backend_top_currency.map(backend_currency_to_api);
301            let cash_currency = parsed
302                .union_cash_info
303                .as_ref()
304                .and_then(|c| c.currency)
305                .map(backend_currency_to_api);
306            let cash = parsed
307                .union_cash_info
308                .as_ref()
309                .map(|c| pf(&c.balance))
310                .unwrap_or(0.0);
311            let avl_withdrawal = parsed
312                .union_cash_info
313                .as_ref()
314                .map(|c| pf(&c.cash_drawable))
315                .unwrap_or(0.0);
316
317            // C++ dt_status mapping: 2→Unlimited, 3→EMCall, 4/5→DTCall, else→Unknown
318            let _dt_status_raw = fund_info.dt_status.unwrap_or(0);
319
320            let market_info_list = build_market_info_list(&parsed.fund_info_list);
321
322            let securities_assets = sum_diff_market_fund_assets_in_response_currency(
323                &parsed.diff_market_fund_info_list,
324            )
325            .or_else(|| {
326                // Legacy backend fallback before `diff_market_fund_info_list`:
327                // keep the old native marketInfo projection rather than
328                // fabricating a cross-currency total.
329                let req_currency = currency.unwrap_or(0);
330                let markets_currencies: [(i32, i32); 8] = [
331                    (1, 1),
332                    (2, 2),
333                    (4, 3),
334                    (15, 4),
335                    (6, 5),
336                    (8, 6),
337                    (112, 7),
338                    (111, 8),
339                ];
340                let sum: f64 = market_info_list
341                    .iter()
342                    .filter_map(|mi| {
343                        markets_currencies
344                            .iter()
345                            .find(|&&(m, native_currency)| {
346                                m == mi.trd_market && native_currency == req_currency
347                            })
348                            .map(|_| mi.assets)
349                    })
350                    .sum();
351                (!market_info_list.is_empty()).then_some(sum)
352            });
353
354            trd_cache.update_funds_scoped_with_returned_currency(
355                acc_id,
356                cache_asset_category,
357                requested_currency,
358                CachedFunds {
359                    power: pf(&fund_info.max_power_long),
360                    total_assets: pf(&fund_info.total_asset),
361                    cash,
362                    market_val: pf(&fund_info.mv),
363                    frozen_cash: pf(&fund_info.hold),
364                    debt_cash: pf(&fund_info.debit_recover),
365                    avl_withdrawal_cash: avl_withdrawal,
366                    currency,
367                    available_funds: pfo(&fund_info.available),
368                    unrealized_pl: pfo(&fund_info.unrealized_profit),
369                    realized_pl: pfo(&fund_info.realized_profit),
370                    risk_level: fund_info.risk_level.map(|r| r as i32),
371                    initial_margin: pfo(&fund_info.initial_margin),
372                    maintenance_margin: pfo(&fund_info.maintenance_margin),
373                    max_power_short: pfo(&fund_info.max_power_short),
374                    net_cash_power: pfo(&fund_info.net_cash_power),
375                    long_mv: pfo(&fund_info.long_mv),
376                    short_mv: pfo(&fund_info.short_mv),
377                    pending_asset: pfo(&fund_info.pending_asset),
378                    max_withdrawal: pfo(&fund_info.drawable),
379                    risk_status: fund_info.risk_status_client,
380                    margin_call_margin: pfo(&fund_info.margin_call),
381                    securities_assets,
382                    fund_assets: None,
383                    bond_assets: None,
384                    crypto_mv: None,
385                    exposure_level: None,
386                    exposure_limit: None,
387                    used_limit: None,
388                    remaining_limit: None,
389                    // v1.4.98 T1-4 (mobile-source-audit): US PDT 6 字段透传
390                    // (asset_query.proto:80-102 backend already returns 这些)
391                    is_pdt: fund_info.is_pdt,
392                    // pdt_seq: backend repeated int32, daemon join 成 string
393                    // ("1,3,5") 对齐 proto/Trd_Common.proto:378 string 类型
394                    // (mobile UI 直接显示数字 list)
395                    pdt_seq: if fund_info.pdt_seq.is_empty() {
396                        None
397                    } else {
398                        Some(
399                            fund_info
400                                .pdt_seq
401                                .iter()
402                                .map(|n| n.to_string())
403                                .collect::<Vec<_>>()
404                                .join(","),
405                        )
406                    },
407                    beginning_dtbp: pfo(&fund_info.beginning_dtbp),
408                    remaining_dtbp: pfo(&fund_info.remaining_dtbp),
409                    dt_call_amount: pfo(&fund_info.dt_call_amount),
410                    dt_status: fund_info.dt_status,
411                    cash_info_list: parsed
412                        .cash_info_list
413                        .iter()
414                        .map(|c| CachedCashInfo {
415                            currency: c.currency.map(backend_currency_to_api).unwrap_or(0),
416                            cash: pf(&c.balance),
417                            available_balance: pf(&c.cash_drawable),
418                            net_cash_power: pf(&c.cash_buypower),
419                        })
420                        .collect(),
421                    market_info_list,
422                },
423            );
424
425            tracing::info!(
426                acc_id,
427                power = pf(&fund_info.max_power_long),
428                total = pf(&fund_info.total_asset),
429                top_currency = ?currency,
430                cash_currency = ?cash_currency,
431                "fund cached via CMD3020"
432            );
433        }
434
435        // === 持仓 ===
436        let positions: Vec<CachedPosition> = parsed
437            .pstn_info_list
438            .iter()
439            .map(|p| {
440                let sec_market = p.stock_market.map(backend_stock_market_to_sec_market);
441                let pstn_id_str = p.pstn_id.as_deref().unwrap_or("");
442                let position_id = hash_str_to_u64(pstn_id_str);
443                tracing::debug!(pstn_id_str, position_id, code = ?p.symbol, "position hash");
444                CachedPosition {
445                    position_id,
446                    position_side: p.pstn_type.unwrap_or(0),
447                    code: p.symbol.clone().unwrap_or_default(),
448                    name: p.stock_name.clone().unwrap_or_default(),
449                    qty: pf(&p.qty),
450                    can_sell_qty: pf(&p.qty_avbl),
451                    price: pf(&p.cur_price),
452                    cost_price: pf(&p.diluted_cost),
453                    val: pf(&p.mv),
454                    pl_val: pf(&p.diluted_profit),
455                    pl_ratio: pfo(&p.diluted_profit_ratio),
456                    sec_market,
457                    td_pl_val: pfo(&p.today_profit),
458                    td_trd_val: pfo(&p.today_turnover),
459                    td_buy_val: pfo(&p.today_buy_turnover),
460                    td_buy_qty: pfo(&p.today_buy_qty),
461                    td_sell_val: pfo(&p.today_sell_turnover),
462                    td_sell_qty: pfo(&p.today_sell_qty),
463                    unrealized_pl: pfo(&p.unrealized_profit),
464                    realized_pl: pfo(&p.realized_profit),
465                    currency: p.currency.map(backend_currency_to_api),
466                    trd_market: p.stock_market.map(backend_market_to_trd_market),
467                    diluted_cost_price: pfo(&p.diluted_cost),
468                    average_cost_price: pfo(&p.average_cost),
469                    average_pl_ratio: pfo(&p.average_profit_ratio),
470                    // v1.4.42: DTE 在 handler 层按 code 实时算(每天变,cache 不存)
471                    expiry_date_distance: None,
472                }
473            })
474            .collect();
475
476        tracing::info!(
477            acc_id,
478            count = positions.len(),
479            "positions cached via CMD3020"
480        );
481        // C++ `FillPositionList` reads the current `Ndt_Trd_AccPosition` array
482        // returned for the asset key. A successful empty backend reply means the
483        // authoritative position list is empty, so we must replace the cache with
484        // `[]`; keeping the previous vector would resurrect sold-out positions.
485        trd_cache.update_positions_scoped(acc_id, cache_asset_category, positions);
486    }
487
488    if should_query_fund_bond_sidecar {
489        query_fund_bond_detail_asset(
490            backend,
491            acc_id,
492            trd_cache,
493            requested_currency,
494            cache_asset_category,
495            sidecar_currency_u32,
496            sidecar_plan,
497        )
498        .await?;
499    }
500
501    tracing::info!(
502        acc_id,
503        asset_category = ?cache_asset_category,
504        without_fund_and_bond_data,
505        "CMD3020 warmup complete"
506    );
507
508    Ok(())
509}
510
511fn account_info_response_status_like_cpp(
512    parsed: &asset_query::AccountInfoRsp,
513    acc_id: u64,
514) -> Result<()> {
515    // Ref: FutuOpenD/Src/NNProtoCenter/Trade/_NNProto_Trd_Comm.h:20-30
516    // and Trade/Asset/NNProto_Trd_AccReal.cpp:512-568.
517    // C++ requires `result`, `msg_header`, and matching `msg_header.account_id`
518    // before writing CMD3020 funds / positions into account cache.
519    let Some(result_code) = parsed.result else {
520        return Err(futu_core::error::FutuError::Codec(
521            "CMD3020 account info missing result".to_string(),
522        ));
523    };
524    if result_code != 0 {
525        let err = parsed.err_msg.as_deref().unwrap_or("unknown");
526        tracing::warn!(acc_id, result_code, err, "CMD3020 returned error");
527        // v1.4.53 A2 log bug 修:之前 return Ok(()) 让上层 bridge.rs 误判
528        // "succeeded"。现在正确返 Err 让 dispatch_trade_data_queries 触发 warn
529        // fallback 分支。
530        return Err(futu_core::error::FutuError::ServerError {
531            ret_type: result_code,
532            msg: format!("CMD3020 business error: {err}"),
533        });
534    }
535    let header = parsed.msg_header.as_ref().ok_or_else(|| {
536        futu_core::error::FutuError::Codec("CMD3020 account info missing msg_header".to_string())
537    })?;
538    let backend_acc_id = header.account_id.unwrap_or(0);
539    if backend_acc_id != acc_id {
540        return Err(futu_core::error::FutuError::Codec(format!(
541            "CMD3020 account info account mismatch: server={backend_acc_id} local={acc_id}"
542        )));
543    }
544    Ok(())
545}
546
547async fn query_fund_bond_detail_asset(
548    backend: &BackendConn,
549    acc_id: u64,
550    trd_cache: &TrdCache,
551    requested_currency: Option<i32>,
552    cache_asset_category: i32,
553    currency_u32: u32,
554    sidecar_plan: AccountInfoSidecarPlan,
555) -> Result<()> {
556    use prost::Message;
557
558    let ccy = currency_to_fund_bond_ccy(currency_u32).to_string();
559    let req = mobile_fund_asset::FundBondDetailAssetReq {
560        unique_id: Some(sidecar_plan.unique_id),
561        ccy: Some(ccy.clone()),
562        asset_type: Some(mobile_fund_asset::AssetType::AllAsset as i32),
563    };
564
565    let resp = backend
566        .request(CMD_FUND_BOND_DETAIL_ASSET, req.encode_to_vec())
567        .await
568        .map_err(|e| {
569            tracing::warn!(
570                acc_id,
571                unique_id = sidecar_plan.unique_id,
572                ccy,
573                error = %e,
574                "CMD20086 fund/bond detail asset query failed"
575            );
576            e
577        })?;
578
579    let parsed: mobile_fund_asset::FundBondDetailAssetRsp = Message::decode(resp.body.as_ref())
580        .map_err(|e| {
581            tracing::warn!(
582                acc_id,
583                body_len = resp.body.len(),
584                error = %e,
585                "CMD20086 decode failed"
586            );
587            futu_core::error::FutuError::Proto(e)
588        })?;
589
590    if parsed.error_code.unwrap_or(-1) != 0 {
591        let result_code = parsed.error_code.unwrap_or(-1);
592        let err = parsed.error_msg.as_deref().unwrap_or("unknown");
593        tracing::warn!(acc_id, result_code, err, "CMD20086 returned error");
594        return Err(futu_core::error::FutuError::ServerError {
595            ret_type: result_code,
596            msg: format!("CMD20086 fund/bond detail asset business error: {err}"),
597        });
598    }
599
600    let fund_asset = parsed
601        .fund_asset
602        .as_ref()
603        .map(|asset| pf(&asset.fund_asset))
604        .ok_or_else(|| futu_core::error::FutuError::ServerError {
605            ret_type: -1,
606            msg: "CMD20086 missing fund_asset".to_string(),
607        })?;
608    let bond_asset = parsed
609        .bond_asset
610        .as_ref()
611        .map(|asset| pf(&asset.bond_asset))
612        .ok_or_else(|| futu_core::error::FutuError::ServerError {
613            ret_type: -1,
614            msg: "CMD20086 missing bond_asset".to_string(),
615        })?;
616
617    let (existing, _) =
618        trd_cache.get_funds_scoped(acc_id, cache_asset_category, requested_currency);
619    let mut funds = existing.unwrap_or_default();
620    funds.fund_assets = Some(fund_asset);
621    funds.bond_assets = Some(bond_asset);
622
623    if sidecar_plan.is_hk_us_fund_account() {
624        // C++ fund-account branch displays fund + bond as the total account
625        // assets. `total_asset.pending_asset` is copied into
626        // `Ndt_Trd_AccFund::fPendingAsset` by `UnPackFundFunds`.
627        //
628        // C++ `UnPackFundFunds` treats `total_asset` as required for HK/US
629        // fund accounts; a missing field turns the whole QueryFund reply into
630        // a data error instead of a success with partial fund/bond totals.
631        let total = parsed.total_asset.as_ref().ok_or_else(|| {
632            futu_core::error::FutuError::ServerError {
633                ret_type: -1,
634                msg: "CMD20086 missing total_asset for fund account".to_string(),
635            }
636        })?;
637        funds.total_assets = fund_asset + bond_asset;
638        funds.pending_asset = pfo(&total.pending_asset);
639    } else if sidecar_plan.universal_supports_fund_sidecar() {
640        // C++ SG/Universal branch sends AccountInfoReq with
641        // `without_fund_and_bond_data=true`, then adds the two sidecar totals
642        // to the securities net asset before filling `totalAssets`.
643        funds.total_assets += fund_asset + bond_asset;
644    }
645
646    trd_cache.update_funds_scoped_with_returned_currency(
647        acc_id,
648        cache_asset_category,
649        requested_currency,
650        funds,
651    );
652
653    tracing::info!(
654        acc_id,
655        unique_id = sidecar_plan.unique_id,
656        ccy,
657        fund_asset,
658        bond_asset,
659        "fund/bond totals cached via CMD20086"
660    );
661
662    Ok(())
663}