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 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 matches!(self.account_market, Some(6)) && matches!(self.security_firm, Some(1 | 3 | 6 | 7))
62 }
63}
64
65pub 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
109pub 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 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 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 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![], quote_level: Some(1), quote_type: Some(1), with_position_im: None,
244 notice_type: None,
245 with_matched_quantity: None,
246 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, op_nn_uid: None,
259 high_prec_cur_price: None,
260 use_high_prec: None,
261 };
262
263 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 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 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 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 is_pdt: fund_info.is_pdt,
393 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 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 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 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 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 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 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 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 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 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 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}