Skip to main content

futu_backend/trade_query/
sim.rs

1use super::common::{pf, pfo};
2use super::*;
3use crate::command_runtime::execute_trade_read;
4use crate::msg_header;
5use bytes::Bytes;
6use futu_command_spec::TradeQueryEnvironment;
7use futu_domain_trade_account::{
8    SimFundsDynamicFacts, SimFundsFacts, SimFundsStaticFacts, SimPositionIdentityProfitFacts,
9    SimPositionMarketFacts, SimQueryResponseStatusDecision, SimQueryResponseStatusFacts,
10    decide_sim_query_response_status_like_cpp, project_sim_funds_like_cpp,
11    project_sim_position_currency_like_cpp, project_sim_position_identity_profit_like_cpp,
12    project_sim_position_sec_market_like_cpp, sim_account_header_market_like_cpp,
13    sim_account_query_market_from_cache_like_cpp,
14};
15
16pub async fn query_funds_sim(
17    backend: &BackendConn,
18    acc_id: u64,
19    trd_cache: &TrdCache,
20) -> Result<()> {
21    use crate::proto_internal::sim_user_asset_interface;
22    use prost::Message;
23
24    // v1.4.53 BUG-6 真修:用 sim 专属 proto + registry Funds.sim_cmd。
25    // 对齐 C++ `NNProto_Trd_Acc.cpp:915-922`(sim 分支走 sim_user_asset_interface::CashInfoReq)
26    // + `M_SendProto_SetSimReqMsgHeader`: header.market 必须原样使用账户
27    // m_enTrdMkt,不能转换成 sim proto 里的 MARKET_*。这些枚举值存在重叠
28    // 冲突(例如 NN_TrdMarket 100=Sim_US_Margin,而 sim proto 文档没有 100),
29    // remap 会让 backend 把账户类型判错。
30    let market = sim_header_market_for_account(trd_cache, acc_id);
31    let req = sim_user_asset_interface::CashInfoReq {
32        msg_header: Some(msg_header::build_sim(
33            acc_id,
34            Some(vec![]),
35            Some(market),
36            None,
37        )),
38    };
39
40    let operation = TradeQueryOperation::Funds;
41    let cmd = sim_trade_query_cmd(operation)?;
42    let resp = execute_trade_read(
43        backend,
44        operation,
45        TradeQueryEnvironment::Sim,
46        None,
47        Bytes::from(req.encode_to_vec()),
48    )
49    .await?;
50
51    let parsed: sim_user_asset_interface::CashInfoRsp = Message::decode(resp.body.as_ref())?;
52    ensure_sim_query_response_status(
53        "sim fund",
54        cmd,
55        parsed.result,
56        parsed.err_msg.as_deref(),
57        parsed.msg_header.as_ref(),
58        acc_id,
59    )?;
60
61    let cash_info = parsed.cash_info.as_ref().ok_or_else(|| {
62        futu_core::error::FutuError::Codec(
63            "sim fund response missing required cash_info".to_string(),
64        )
65    })?;
66    let facts = sim_funds_facts(cash_info);
67    let projected = project_sim_funds_like_cpp(&facts)
68        .map_err(|err| futu_core::error::FutuError::Codec(err.to_string()))?;
69    trd_cache.update_funds(acc_id, cached_sim_funds(projected));
70    Ok(())
71}
72
73fn sim_funds_facts(
74    cash_info: &crate::proto_internal::sim_odr_sys_cmn::CashInfo,
75) -> SimFundsFacts<'_> {
76    SimFundsFacts {
77        static_info: cash_info
78            .static_info
79            .as_ref()
80            .map(|info| SimFundsStaticFacts {
81                balance: info.balance.as_deref(),
82                hold: info.hold.as_deref(),
83            }),
84        dynamic_info: cash_info
85            .dynamic_info
86            .as_ref()
87            .map(|info| SimFundsDynamicFacts {
88                max_power_long: info.max_power_long.as_deref(),
89                total_asset: info.total_asset.as_deref(),
90                mv: info.mv.as_deref(),
91                debit_recover: info.debit_recover.as_deref(),
92                drawable: info.drawable.as_deref(),
93                short_mv: info.short_mv.as_deref(),
94                long_mv: info.long_mv.as_deref(),
95                margin_call: info.margin_call.as_deref(),
96                unrealized_profit: info.unrealized_profit.as_deref(),
97                realized_profit: info.realized_profit.as_deref(),
98                loan_max: info.loan_max.as_deref(),
99                margin_call_recover: info.margin_call_recover.as_deref(),
100                risk_level: info.risk_level,
101                risk_status: info.risk_status,
102                margin_call_balance: info.margin_call_balance.as_deref(),
103                margin_call_balance_ratio: info.margin_call_balance_ratio.as_deref(),
104                absolute_safe_mcb_ratio: info.absolute_safe_mcb_ratio.as_deref(),
105                max_power_short: info.max_power_short.as_deref(),
106            }),
107    }
108}
109
110fn cached_sim_funds(projected: futu_domain_trade_account::ProjectedSimFunds) -> CachedFunds {
111    CachedFunds {
112        power: projected.max_buy_power,
113        total_assets: projected.net_asset,
114        cash: projected.total_cash,
115        market_val: projected.market_value,
116        frozen_cash: projected.frozen_fund,
117        debt_cash: projected.debit_recover,
118        avl_withdrawal_cash: projected.drawable,
119        // C++ SIM `UnPackFund` does not consume `CashInfo.currency`.
120        currency: None,
121        available_funds: None,
122        unrealized_pl: Some(projected.unrealized_profit),
123        realized_pl: Some(projected.realized_profit),
124        risk_level: Some(projected.risk_level),
125        initial_margin: None,
126        maintenance_margin: Some(projected.maintenance_margin),
127        max_power_short: Some(projected.short_power),
128        net_cash_power: None,
129        long_mv: Some(projected.long_mv),
130        short_mv: Some(projected.short_mv),
131        pending_asset: None,
132        // Paper-trading FillFunds never exposes maxWithdrawal.
133        max_withdrawal: None,
134        risk_status: Some(projected.risk_status),
135        // `margin_call_recover` and public `marginCallMargin` are distinct C++
136        // members; the SIM response path does not expose the latter.
137        margin_call_margin: None,
138        securities_assets: None,
139        fund_assets: None,
140        bond_assets: None,
141        crypto_mv: None,
142        exposure_level: None,
143        exposure_limit: None,
144        used_limit: None,
145        remaining_limit: None,
146        // C++ `UnPackFund` does not consume the SIM proto PDT/DTBP fields.
147        is_pdt: None,
148        pdt_seq: None,
149        beginning_dtbp: None,
150        remaining_dtbp: None,
151        dt_call_amount: None,
152        dt_status: None,
153        cash_info_list: vec![],
154        market_info_list: vec![],
155    }
156}
157
158/// C++ `M_SendProto_SetSimReqMsgHeader` 直接 `pMsgHeader->set_market(m_enTrdMkt)`.
159///
160/// 这里故意不对齐 `proto-internal/sim_odr_sys_cmn.proto::Market` 文档枚举,
161/// 因为生产 C++ wire 传的是 `NN_TrdMarket` 原值。两套枚举并不等价:
162/// `7/12/13/100` 等值在不同 enum 下含义会冲突。若把
163/// `Sim_US_Margin=100` remap 成 `MARKET_US_STOCK=2`,backend 会把 US SIM
164/// 保证金账户当普通 US stock 查询,出现 `not support this account type`。
165fn sim_header_market_for_account(trd_cache: &TrdCache, acc_id: u64) -> u32 {
166    let trd_market = trd_cache
167        .accounts
168        .get(&acc_id)
169        .map(|entry| {
170            let acc = entry.value();
171            sim_account_query_market_from_cache_like_cpp(
172                acc.trd_market,
173                &acc.trd_market_auth_list,
174                0,
175            )
176        })
177        .unwrap_or(0);
178    sim_account_header_market_like_cpp(trd_market)
179}
180
181pub async fn query_positions_sim(
182    backend: &BackendConn,
183    acc_id: u64,
184    trd_market: i32,
185    trd_cache: &TrdCache,
186) -> Result<()> {
187    use crate::proto_internal::sim_user_asset_interface;
188    use prost::Message;
189
190    // v1.4.53 BUG-6 真修:用 sim 专属 proto + registry Positions.sim_cmd。
191    // 对齐 C++ `NNProto_Trd_Acc.cpp:787-808`(sim 分支走 sim_user_asset_interface::PstnInfoReq)
192    // + `M_SendProto_SetSimReqMsgHeader`: market 字段必填,且必须是账户
193    // `m_enTrdMkt` 原值。不能转换成 sim proto `Market` 文档枚举。
194    let market = sim_account_header_market_like_cpp(trd_market);
195    let req = sim_user_asset_interface::PstnInfoReq {
196        msg_header: Some(msg_header::build_sim(
197            acc_id,
198            Some(vec![]),
199            Some(market),
200            None,
201        )),
202    };
203
204    let operation = TradeQueryOperation::Positions;
205    let cmd = sim_trade_query_cmd(operation)?;
206    let resp = execute_trade_read(
207        backend,
208        operation,
209        TradeQueryEnvironment::Sim,
210        None,
211        Bytes::from(req.encode_to_vec()),
212    )
213    .await?;
214
215    let parsed: sim_user_asset_interface::PstnInfoRsp = Message::decode(resp.body.as_ref())?;
216    ensure_sim_query_response_status(
217        "sim position",
218        cmd,
219        parsed.result,
220        parsed.err_msg.as_deref(),
221        parsed.msg_header.as_ref(),
222        acc_id,
223    )?;
224
225    let positions: Vec<CachedPosition> = parsed
226        .pstn_infos
227        .iter()
228        .map(|p| {
229            let backend_position_id = p.pstn_id.as_deref().ok_or_else(|| {
230                futu_core::error::FutuError::Codec(
231                    "sim position response missing required pstn_id".to_string(),
232                )
233            })?;
234            let raw_market = p.market;
235            let trd_market = raw_market.and_then(|m| i32::try_from(m).ok());
236            let identity_profit =
237                project_sim_position_identity_profit_like_cpp(SimPositionIdentityProfitFacts {
238                    backend_position_id,
239                    raw_market,
240                    backend_profit_ratio: p.profit_ratio.as_deref(),
241                    backend_unrealized_pl: pfo(&p.unrealized_profit),
242                    backend_realized_pl: pfo(&p.realized_profit),
243                });
244            Ok(CachedPosition {
245                position_id: identity_profit.position_id,
246                business_position_id: None,
247                position_acc_id: None,
248                sub_account_id: None,
249                position_side: p.pstn_type.unwrap_or(0),
250                code: p.symbol.as_ref().cloned().unwrap_or_default(),
251                name: p.stock_name.as_ref().cloned().unwrap_or_default(),
252                qty: pf(&p.qty),
253                can_sell_qty: pf(&p.qty_avbl),
254                price: pf(&p.cur_price),
255                cost_price: pf(&p.cost_price),
256                val: pf(&p.mv),
257                pl_val: pf(&p.profit),
258                pl_ratio: identity_profit.pl_ratio,
259                sec_market: project_sim_position_sec_market_like_cpp(SimPositionMarketFacts {
260                    raw_market,
261                    code: p.symbol.as_deref(),
262                    exchange: None,
263                }),
264                td_pl_val: pfo(&p.today_profit),
265                td_trd_val: pfo(&p.today_turnover),
266                td_buy_val: pfo(&p.today_buy_turnover),
267                td_buy_qty: pfo(&p.today_buy_qty),
268                td_sell_val: pfo(&p.today_sell_turnover),
269                td_sell_qty: pfo(&p.today_sell_qty),
270                unrealized_pl: identity_profit.unrealized_pl,
271                realized_pl: identity_profit.realized_pl,
272                currency: project_sim_position_currency_like_cpp(raw_market),
273                trd_market,
274                diluted_cost_price: pfo(&p.cost_price),
275                average_cost_price: pfo(&p.buy_avg_price),
276                average_pl_ratio: identity_profit.average_pl_ratio,
277                combo_id: None,
278                business_combo_id: None,
279                strategy_type: None,
280                position_type: None,
281                acc_id: None,
282                jp_acc_type: None,
283                // v1.4.42: DTE 在 handler 层按 code 实时算
284            })
285        })
286        .collect::<Result<Vec<_>>>()?;
287
288    tracing::debug!(count = positions.len(), "sim positions cached");
289    // Same replacement semantics as real CMD3020: success with zero rows must
290    // clear stale positions instead of leaving the previous snapshot visible.
291    trd_cache.update_positions(acc_id, positions);
292
293    Ok(())
294}
295
296fn sim_trade_query_cmd(operation: TradeQueryOperation) -> Result<u16> {
297    trade_query_command(operation)
298        .sim
299        .map(|route| route.cmd_id)
300        .ok_or_else(|| {
301            futu_core::error::FutuError::Codec(format!(
302                "{operation:?} has no registered simulated-account backend route"
303            ))
304        })
305}
306
307fn ensure_sim_query_response_status(
308    kind: &str,
309    cmd: u16,
310    result: Option<i32>,
311    err_msg: Option<&str>,
312    msg_header: Option<&crate::proto_internal::sim_odr_sys_cmn::MsgHeader>,
313    acc_id: u64,
314) -> Result<()> {
315    let decision = decide_sim_query_response_status_like_cpp(SimQueryResponseStatusFacts {
316        result,
317        has_msg_header: msg_header.is_some(),
318        backend_account_id: msg_header.and_then(|header| header.account_id),
319        local_account_id: acc_id,
320        err_msg,
321    });
322
323    match decision {
324        SimQueryResponseStatusDecision::Accepted => Ok(()),
325        SimQueryResponseStatusDecision::BusinessError {
326            result,
327            err_msg: backend_msg,
328        } => {
329            let msg = decision
330                .error_message(kind, cmd)
331                .unwrap_or_else(|| format!("{kind} query returned unrenderable business error"));
332            tracing::warn!(
333                result,
334                err = backend_msg.unwrap_or("unknown"),
335                kind,
336                cmd,
337                "sim query returned business error"
338            );
339            Err(futu_core::error::FutuError::ServerError {
340                ret_type: result,
341                msg,
342            })
343        }
344        _ => Err(futu_core::error::FutuError::Codec(
345            decision
346                .error_message(kind, cmd)
347                .unwrap_or_else(|| format!("{kind} query status rejected for cmd {cmd}")),
348        )),
349    }
350}
351
352#[cfg(test)]
353mod tests;