futu_backend/trade_query/
crypto_account.rs1use futu_core::error::{FutuError, Result};
2
3use super::common::{hash_str_to_u64, pf, pfo};
4use super::*;
5
6use crate::crypto_trade::lookup_crypto_account_context;
7use crate::trade_cmd::CMD_CRYPTO_ACCOUNT_INFO;
8
9const FTAPI_CURRENCY_HKD: i32 = 1;
10const TRD_MARKET_CRYPTO: i32 = 7;
11const SEC_MARKET_CRYPTO: i32 = 101;
12
13pub async fn query_crypto_account_info(
20 backend: &BackendConn,
21 acc_id: u64,
22 trd_cache: &TrdCache,
23 requested_currency: Option<i32>,
24) -> Result<()> {
25 query_crypto_account_info_inner(backend, acc_id, trd_cache, requested_currency).await
26}
27
28pub async fn query_crypto_position_account_info(
35 backend: &BackendConn,
36 acc_id: u64,
37 trd_cache: &TrdCache,
38) -> Result<()> {
39 query_crypto_account_info_inner(backend, acc_id, trd_cache, Some(FTAPI_CURRENCY_HKD)).await
40}
41
42async fn query_crypto_account_info_inner(
43 backend: &BackendConn,
44 acc_id: u64,
45 trd_cache: &TrdCache,
46 requested_currency: Option<i32>,
47) -> Result<()> {
48 use prost::Message;
49
50 let effective_currency = requested_currency.unwrap_or(FTAPI_CURRENCY_HKD);
51 let currency_label = ftapi_currency_to_backend_label(effective_currency)?;
52 let ctx = lookup_crypto_account_context(trd_cache, acc_id)?;
53 let req = asset_pl::AccountInfoReq {
54 msg_header: Some(ctx.build_asset_msg_header("account_info")),
55 union_currency: Some(currency_label.to_string()),
56 without_zero_quantity_pstn: None,
57 };
58 let resp = backend
59 .request(CMD_CRYPTO_ACCOUNT_INFO, req.encode_to_vec())
60 .await
61 .map_err(|e| {
62 tracing::warn!(
63 acc_id,
64 error = %e,
65 "CMD20631 crypto account info query failed"
66 );
67 e
68 })?;
69
70 let parsed: asset_pl::AccountInfoRsp = Message::decode(resp.body.as_ref()).map_err(|e| {
71 tracing::warn!(
72 acc_id,
73 body_len = resp.body.len(),
74 error = %e,
75 "CMD20631 crypto account info decode failed"
76 );
77 FutuError::Proto(e)
78 })?;
79 crypto_account_response_status_like_cpp(&parsed, acc_id)?;
80
81 let funds = project_crypto_funds(&parsed, Some(effective_currency));
82 trd_cache.update_funds_scoped(acc_id, 0, Some(effective_currency), funds.clone());
83 if requested_currency.is_none() {
84 trd_cache.update_funds_scoped(acc_id, 0, None, funds);
85 }
86 trd_cache.update_positions_scoped(acc_id, 0, project_crypto_positions(&parsed));
87
88 tracing::info!(
89 acc_id,
90 currency = currency_label,
91 has_fund = parsed.union_fund_info.is_some(),
92 positions = parsed.pstn_info_list.len(),
93 "crypto account cached via CMD20631"
94 );
95 Ok(())
96}
97
98fn crypto_account_response_status_like_cpp(
99 parsed: &asset_pl::AccountInfoRsp,
100 acc_id: u64,
101) -> Result<()> {
102 let Some(result_code) = parsed.result else {
107 return Err(FutuError::Codec(
108 "CMD20631 crypto account info missing result".to_string(),
109 ));
110 };
111 if result_code != 0 {
112 let err = parsed.err_msg.as_deref().unwrap_or("unknown");
113 tracing::warn!(acc_id, result_code, err, "CMD20631 returned error");
114 return Err(FutuError::ServerError {
115 ret_type: result_code,
116 msg: format!("CMD20631 crypto account info business error: {err}"),
117 });
118 }
119 let header = parsed.msg_header.as_ref().ok_or_else(|| {
120 FutuError::Codec("CMD20631 crypto account info missing msg_header".to_string())
121 })?;
122 let server_acc_id = header.account_id.unwrap_or(0);
123 if server_acc_id != acc_id {
124 return Err(FutuError::Codec(format!(
125 "CMD20631 crypto account info account mismatch: server={server_acc_id} local={acc_id}"
126 )));
127 }
128 Ok(())
129}
130
131fn ftapi_currency_to_backend_label(currency: i32) -> Result<&'static str> {
132 match currency {
133 1 => Ok("HKD"),
134 2 => Ok("USD"),
135 3 => Ok("CNH"),
136 4 => Ok("JPY"),
137 5 => Ok("SGD"),
138 6 => Ok("AUD"),
139 7 => Ok("CAD"),
140 8 => Ok("MYR"),
141 _ => Err(FutuError::ServerError {
142 ret_type: -1,
143 msg: format!("Crypto account info: unsupported currency id {currency}"),
144 }),
145 }
146}
147
148fn backend_currency_label_to_ftapi(currency: Option<&str>) -> Option<i32> {
149 match currency?.trim().to_ascii_uppercase().as_str() {
150 "HKD" => Some(1),
151 "USD" => Some(2),
152 "CNH" | "CNY" => Some(3),
153 "JPY" => Some(4),
154 "SGD" => Some(5),
155 "AUD" => Some(6),
156 "CAD" => Some(7),
157 "MYR" => Some(8),
158 _ => None,
159 }
160}
161
162fn project_crypto_funds(
163 parsed: &asset_pl::AccountInfoRsp,
164 effective_currency: Option<i32>,
165) -> CachedFunds {
166 let fund = parsed.union_fund_info.as_ref();
167 let cash = parsed.union_cash_info.as_ref();
168 let limit = parsed.position_limit_info.as_ref();
169 let currency = fund
170 .and_then(|f| backend_currency_label_to_ftapi(f.currency.as_deref()))
171 .or_else(|| cash.and_then(|c| backend_currency_label_to_ftapi(c.currency.as_deref())))
172 .or(effective_currency);
173
174 CachedFunds {
175 power: fund.map(|f| pf(&f.max_power_long)).unwrap_or(0.0),
176 total_assets: fund.map(|f| pf(&f.total_asset)).unwrap_or(0.0),
177 cash: cash.map(|c| pf(&c.balance)).unwrap_or(0.0),
178 market_val: 0.0,
179 frozen_cash: fund.map(|f| pf(&f.hold)).unwrap_or(0.0),
180 debt_cash: 0.0,
181 avl_withdrawal_cash: cash.map(|c| pf(&c.cash_drawable)).unwrap_or(0.0),
182 currency,
183 available_funds: fund.and_then(|f| pfo(&f.available)),
184 unrealized_pl: fund.and_then(|f| pfo(&f.unrealized_profit)),
185 realized_pl: fund.and_then(|f| pfo(&f.realized_profit)),
186 risk_level: None,
187 initial_margin: None,
188 maintenance_margin: None,
189 max_power_short: fund.and_then(|f| pfo(&f.max_power_short)),
190 net_cash_power: cash.and_then(|c| pfo(&c.cash_buypower)),
191 long_mv: fund.and_then(|f| pfo(&f.long_mv)),
192 short_mv: fund.and_then(|f| pfo(&f.short_mv)),
193 pending_asset: None,
194 max_withdrawal: fund.and_then(|f| pfo(&f.drawable)),
195 risk_status: None,
196 margin_call_margin: None,
197 securities_assets: None,
198 fund_assets: None,
199 bond_assets: None,
200 crypto_mv: fund.and_then(|f| pfo(&f.mv)),
201 exposure_level: limit.and_then(|l| l.position_limit_status),
202 exposure_limit: limit.and_then(|l| pfo(&l.position_limit)),
203 used_limit: limit.and_then(|l| pfo(&l.total_position)),
204 remaining_limit: limit.and_then(|l| pfo(&l.remaining_position_limit)),
205 is_pdt: None,
206 pdt_seq: None,
207 beginning_dtbp: None,
208 remaining_dtbp: None,
209 dt_call_amount: None,
210 dt_status: None,
211 cash_info_list: parsed
212 .cash_info_list
213 .iter()
214 .filter_map(|c| {
215 let currency = backend_currency_label_to_ftapi(c.currency.as_deref())?;
216 Some(CachedCashInfo {
217 currency,
218 cash: pf(&c.balance),
219 available_balance: pf(&c.cash_drawable),
220 net_cash_power: pf(&c.cash_buypower),
221 })
222 })
223 .collect(),
224 market_info_list: vec![],
225 }
226}
227
228fn project_crypto_positions(parsed: &asset_pl::AccountInfoRsp) -> Vec<CachedPosition> {
229 parsed
230 .pstn_info_list
231 .iter()
232 .map(project_crypto_position)
233 .collect()
234}
235
236fn project_crypto_position(p: &asset_pl::AccPstnInfo) -> CachedPosition {
237 let pstn_id_str = p.pstn_id.as_deref().unwrap_or("");
238 CachedPosition {
239 position_id: hash_str_to_u64(pstn_id_str),
240 position_side: p.pstn_type.unwrap_or(0),
241 code: p.futu_symbol.clone().unwrap_or_default(),
242 name: p.stock_name.clone().unwrap_or_default(),
243 qty: pf(&p.qty),
244 can_sell_qty: pf(&p.qty_avbl),
245 price: pf(&p.cur_price),
246 cost_price: pf(&p.diluted_cost),
247 val: pf(&p.mv),
248 pl_val: pf(&p.diluted_profit),
249 pl_ratio: pfo(&p.diluted_profit_ratio),
250 sec_market: Some(SEC_MARKET_CRYPTO),
251 td_pl_val: pfo(&p.today_profit),
252 td_trd_val: None,
253 td_buy_val: None,
254 td_buy_qty: None,
255 td_sell_val: None,
256 td_sell_qty: None,
257 unrealized_pl: pfo(&p.unrealized_profit),
258 realized_pl: pfo(&p.realized_profit),
259 currency: backend_currency_label_to_ftapi(p.currency.as_deref()),
260 trd_market: Some(TRD_MARKET_CRYPTO),
261 diluted_cost_price: pfo(&p.diluted_cost),
262 average_cost_price: pfo(&p.average_cost),
263 average_pl_ratio: pfo(&p.average_profit_ratio),
264 expiry_date_distance: None,
265 }
266}
267
268#[cfg(test)]
269mod tests;