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/// C++ 10.7 `Trd_GetPositionList.C2S.currency` is required for crypto
31/// accounts. Forward it as CMD20631 `union_currency` instead of reusing the
32/// non-crypto first-valid fallback.
33pub async fn query_crypto_position_account_info(
34    backend: &BackendConn,
35    acc_id: u64,
36    trd_cache: &TrdCache,
37    requested_currency: Option<i32>,
38) -> Result<()> {
39    query_crypto_account_info_inner(backend, acc_id, trd_cache, requested_currency).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.ok_or_else(|| {
123        FutuError::Codec("CMD20631 crypto account info msg_header missing account_id".to_string())
124    })?;
125    if server_acc_id != acc_id {
126        return Err(FutuError::Codec(format!(
127            "CMD20631 crypto account info account mismatch: server={server_acc_id} local={acc_id}"
128        )));
129    }
130    Ok(())
131}
132
133fn ftapi_currency_to_backend_label(currency: i32) -> Result<&'static str> {
134    match currency {
135        1 => Ok("HKD"),
136        2 => Ok("USD"),
137        3 => Ok("CNH"),
138        4 => Ok("JPY"),
139        5 => Ok("SGD"),
140        6 => Ok("AUD"),
141        7 => Ok("CAD"),
142        8 => Ok("MYR"),
143        _ => Err(FutuError::ServerError {
144            ret_type: -1,
145            msg: format!("Crypto account info: unsupported currency id {currency}"),
146        }),
147    }
148}
149
150fn backend_currency_label_to_ftapi(currency: Option<&str>) -> Option<i32> {
151    match currency?.trim().to_ascii_uppercase().as_str() {
152        "HKD" => Some(1),
153        "USD" => Some(2),
154        "CNH" | "CNY" => Some(3),
155        "JPY" => Some(4),
156        "SGD" => Some(5),
157        "AUD" => Some(6),
158        "CAD" => Some(7),
159        "MYR" => Some(8),
160        _ => None,
161    }
162}
163
164fn project_crypto_funds(
165    parsed: &asset_pl::AccountInfoRsp,
166    effective_currency: Option<i32>,
167) -> CachedFunds {
168    let fund = parsed.union_fund_info.as_ref();
169    let cash = parsed.union_cash_info.as_ref();
170    let limit = parsed.position_limit_info.as_ref();
171    let currency = fund
172        .and_then(|f| backend_currency_label_to_ftapi(f.currency.as_deref()))
173        .or_else(|| cash.and_then(|c| backend_currency_label_to_ftapi(c.currency.as_deref())))
174        .or(effective_currency);
175
176    CachedFunds {
177        power: fund.map(|f| pf(&f.max_power_long)).unwrap_or(0.0),
178        total_assets: fund.map(|f| pf(&f.total_asset)).unwrap_or(0.0),
179        cash: cash.map(|c| pf(&c.balance)).unwrap_or(0.0),
180        market_val: 0.0,
181        frozen_cash: fund.map(|f| pf(&f.hold)).unwrap_or(0.0),
182        debt_cash: 0.0,
183        avl_withdrawal_cash: cash.map(|c| pf(&c.cash_drawable)).unwrap_or(0.0),
184        currency,
185        available_funds: fund.and_then(|f| pfo(&f.available)),
186        unrealized_pl: fund.and_then(|f| pfo(&f.unrealized_profit)),
187        realized_pl: fund.and_then(|f| pfo(&f.realized_profit)),
188        risk_level: None,
189        initial_margin: None,
190        maintenance_margin: None,
191        max_power_short: fund.and_then(|f| pfo(&f.max_power_short)),
192        net_cash_power: cash.and_then(|c| pfo(&c.cash_buypower)),
193        long_mv: fund.and_then(|f| pfo(&f.long_mv)),
194        short_mv: fund.and_then(|f| pfo(&f.short_mv)),
195        pending_asset: None,
196        max_withdrawal: fund.and_then(|f| pfo(&f.drawable)),
197        risk_status: None,
198        margin_call_margin: None,
199        securities_assets: None,
200        fund_assets: None,
201        bond_assets: None,
202        crypto_mv: fund.and_then(|f| pfo(&f.mv)),
203        exposure_level: limit.and_then(|l| l.position_limit_status),
204        exposure_limit: limit.and_then(|l| pfo(&l.position_limit)),
205        used_limit: limit.and_then(|l| pfo(&l.total_position)),
206        remaining_limit: limit.and_then(|l| pfo(&l.remaining_position_limit)),
207        is_pdt: None,
208        pdt_seq: None,
209        beginning_dtbp: None,
210        remaining_dtbp: None,
211        dt_call_amount: None,
212        dt_status: None,
213        cash_info_list: parsed
214            .cash_info_list
215            .iter()
216            .filter_map(|c| {
217                let currency = backend_currency_label_to_ftapi(c.currency.as_deref())?;
218                Some(CachedCashInfo {
219                    currency,
220                    cash: pf(&c.balance),
221                    available_balance: pf(&c.cash_drawable),
222                    net_cash_power: pf(&c.cash_buypower),
223                })
224            })
225            .collect(),
226        market_info_list: vec![],
227    }
228}
229
230fn project_crypto_positions(parsed: &asset_pl::AccountInfoRsp) -> Vec<CachedPosition> {
231    parsed
232        .pstn_info_list
233        .iter()
234        .map(project_crypto_position)
235        .collect()
236}
237
238fn project_crypto_position(p: &asset_pl::AccPstnInfo) -> CachedPosition {
239    let pstn_id_str = p.pstn_id.as_deref().unwrap_or("");
240    CachedPosition {
241        position_id: hash_str_to_u64(pstn_id_str),
242        business_position_id: None,
243        position_acc_id: None,
244        sub_account_id: None,
245        position_side: p.pstn_type.unwrap_or(0),
246        code: p.futu_symbol.clone().unwrap_or_default(),
247        name: p.stock_name.clone().unwrap_or_default(),
248        qty: pf(&p.qty),
249        can_sell_qty: pf(&p.qty_avbl),
250        price: pf(&p.cur_price),
251        cost_price: pf(&p.diluted_cost),
252        val: pf(&p.mv),
253        pl_val: pf(&p.diluted_profit),
254        pl_ratio: pfo(&p.diluted_profit_ratio),
255        sec_market: Some(SEC_MARKET_CRYPTO),
256        td_pl_val: pfo(&p.today_profit),
257        td_trd_val: None,
258        td_buy_val: None,
259        td_buy_qty: None,
260        td_sell_val: None,
261        td_sell_qty: None,
262        unrealized_pl: pfo(&p.unrealized_profit),
263        realized_pl: pfo(&p.realized_profit),
264        currency: backend_currency_label_to_ftapi(p.currency.as_deref()),
265        trd_market: Some(TRD_MARKET_CRYPTO),
266        diluted_cost_price: pfo(&p.diluted_cost),
267        average_cost_price: pfo(&p.average_cost),
268        average_pl_ratio: pfo(&p.average_profit_ratio),
269        combo_id: None,
270        business_combo_id: None,
271        strategy_type: None,
272        position_type: None,
273        acc_id: None,
274        jp_acc_type: None,
275        expiry_date_distance: None,
276    }
277}
278
279#[cfg(test)]
280mod tests;