Skip to main content

futu_backend/trade_query/
crypto_account.rs

1use 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
13/// Query crypto account funds through CMD20631.
14///
15/// C++ 10.5.6508: `NNProto_Trd_AccCrypto.cpp:197-215` sends
16/// `asset_pl::AccountInfoReq` with an `odr_sys_cmn::MsgHeader` and
17/// `union_currency`. `APIServer_Trd_GetFunds.cpp` then maps crypto market value
18/// to `Funds.cryptoMv` and keeps ordinary `marketVal` at zero.
19pub 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
28/// Query crypto positions through CMD20631.
29///
30/// The public `Trd_GetPositionList.C2S` proto has no currency field. Crypto
31/// backend requires `union_currency`, so Rust follows the C++ GetFunds no-currency
32/// default and uses HKD until the public position proto gains an explicit
33/// currency selector.
34pub 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    // Ref: FutuOpenD/Src/NNProtoCenter/Trade/_NNProto_Trd_Comm.h:20-30
103    // and Trade/Asset/NNProto_Trd_AccCrypto.cpp:223-264.
104    // C++ treats `result` and `msg_header.account_id` as required before it
105    // writes crypto funds/positions into account cache.
106    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;