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 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 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 self.is_universal() && matches!(self.security_firm, Some(1 | 3 | 6 | 7))
66 }
67}
68
69pub 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
113pub 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 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 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 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![], 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 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 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 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 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 is_pdt: fund_info.is_pdt,
392 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 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 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 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 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 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 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 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}