Skip to main content

futu_backend/trade_query/
sim.rs

1use super::common::{pf, pfo};
2use super::*;
3use crate::msg_header;
4
5pub async fn query_funds_sim(
6    backend: &BackendConn,
7    acc_id: u64,
8    trd_cache: &TrdCache,
9) -> Result<()> {
10    use crate::proto_internal::sim_user_asset_interface;
11    use prost::Message;
12
13    // v1.4.53 BUG-6 真修:用 sim 专属 proto + registry Funds.sim_cmd。
14    // 对齐 C++ `NNProto_Trd_Acc.cpp:915-922`(sim 分支走 sim_user_asset_interface::CashInfoReq)
15    // + `M_SendProto_SetSimReqMsgHeader`: header.market 必须原样使用账户
16    // m_enTrdMkt,不能转换成 sim proto 里的 MARKET_*。这些枚举值存在重叠
17    // 冲突(例如 NN_TrdMarket 100=Sim_US_Margin,而 sim proto 文档没有 100),
18    // remap 会让 backend 把账户类型判错。
19    let market = sim_header_market_for_account(trd_cache, acc_id);
20    let req = sim_user_asset_interface::CashInfoReq {
21        msg_header: Some(msg_header::build_sim(
22            acc_id,
23            Some(vec![]),
24            Some(market),
25            None,
26        )),
27    };
28
29    let cmd = trade_query_command(TradeQueryOperation::Funds).sim_cmd;
30    let resp = backend.request(cmd, req.encode_to_vec()).await?;
31
32    let parsed: sim_user_asset_interface::CashInfoRsp = Message::decode(resp.body.as_ref())?;
33    sim_response_status_like_cpp(
34        "sim fund",
35        cmd,
36        parsed.result,
37        parsed.err_msg.as_deref(),
38        parsed.msg_header.as_ref(),
39        acc_id,
40    )?;
41
42    if let Some(cash_info) = &parsed.cash_info {
43        let static_info = &cash_info.static_info;
44        let dynamic_info = &cash_info.dynamic_info;
45
46        let frozen = static_info.as_ref().map(|s| pf(&s.hold)).unwrap_or(0.0);
47
48        if let Some(d) = dynamic_info {
49            trd_cache.update_funds(
50                acc_id,
51                CachedFunds {
52                    power: pf(&d.max_power_long),
53                    total_assets: pf(&d.total_asset),
54                    cash: static_info.as_ref().map(|s| pf(&s.balance)).unwrap_or(0.0),
55                    market_val: pf(&d.mv),
56                    frozen_cash: frozen,
57                    debt_cash: pf(&d.debit_recover),
58                    avl_withdrawal_cash: pf(&d.drawable),
59                    currency: cash_info.currency.map(|c| c as i32),
60                    available_funds: None,
61                    unrealized_pl: None,
62                    realized_pl: None,
63                    risk_level: d.risk_level.map(|r| r as i32),
64                    initial_margin: None,
65                    maintenance_margin: None,
66                    max_power_short: pfo(&d.max_power_short),
67                    net_cash_power: None,
68                    long_mv: pfo(&d.long_mv),
69                    short_mv: pfo(&d.short_mv),
70                    pending_asset: None,
71                    max_withdrawal: pfo(&d.cash_drawable),
72                    risk_status: d.risk_status.map(|r| r as i32),
73                    margin_call_margin: pfo(&d.margin_call_recover),
74                    securities_assets: None,
75                    fund_assets: None,
76                    bond_assets: None,
77                    crypto_mv: None,
78                    exposure_level: None,
79                    exposure_limit: None,
80                    used_limit: None,
81                    remaining_limit: None,
82                    // v1.4.98 T1-4: sim 账户 backend 不返 PDT 字段, 全 None
83                    is_pdt: None,
84                    pdt_seq: None,
85                    beginning_dtbp: None,
86                    remaining_dtbp: None,
87                    dt_call_amount: None,
88                    dt_status: None,
89                    cash_info_list: vec![],
90                    market_info_list: vec![],
91                },
92            );
93        }
94    }
95    Ok(())
96}
97
98/// C++ `M_SendProto_SetSimReqMsgHeader` 直接 `pMsgHeader->set_market(m_enTrdMkt)`.
99///
100/// 这里故意不对齐 `proto-internal/sim_odr_sys_cmn.proto::Market` 文档枚举,
101/// 因为生产 C++ wire 传的是 `NN_TrdMarket` 原值。两套枚举并不等价:
102/// `7/12/13/100` 等值在不同 enum 下含义会冲突。若把
103/// `Sim_US_Margin=100` remap 成 `MARKET_US_STOCK=2`,backend 会把 US SIM
104/// 保证金账户当普通 US stock 查询,出现 `not support this account type`。
105pub(super) fn trd_market_to_sim_header_market(trd_market: i32) -> u32 {
106    u32::try_from(trd_market).unwrap_or(0)
107}
108
109fn sim_header_market_for_account(trd_cache: &TrdCache, acc_id: u64) -> u32 {
110    let trd_market = trd_cache
111        .accounts
112        .get(&acc_id)
113        .and_then(|entry| {
114            let acc = entry.value();
115            acc.trd_market
116                .or_else(|| acc.trd_market_auth_list.first().copied())
117        })
118        .unwrap_or(0);
119    trd_market_to_sim_header_market(trd_market)
120}
121
122/// SIM 持仓 response 的 market 在 C++ 中也按 `NN_TrdMarket` 原值处理:
123/// `NN_TrdMarket_ConvS2C(enTrdEnv=Sim)` 直接返回 server value,随后
124/// `APIServer_Trd_GetPositionList.cpp` 用 `GetTrdSecMarket` /
125/// `GetCurrencyByTrdMarket` 派生对外 sec_market 与 currency。
126fn sim_trd_market_to_sec_market(trd_market: u32) -> Option<i32> {
127    match trd_market {
128        1 | 9 | 10 | 113 => Some(1),       // HK
129        2 | 7 | 11 | 100 | 123 => Some(2), // US
130        3 | 4 => Some(31),                 // CN / HKCC fallback
131        6 | 12 | 124 => Some(41),          // SG
132        8 => Some(61),                     // AU
133        13 | 15 | 126 => Some(51),         // JP
134        111 | 125 => Some(71),             // MY
135        112 => Some(81),                   // CA
136        _ => None,
137    }
138}
139
140fn sim_trd_market_to_currency(trd_market: u32) -> Option<i32> {
141    match trd_market {
142        1 | 9 | 10 | 113 => Some(1),       // HKD
143        2 | 7 | 11 | 100 | 123 => Some(2), // USD
144        3 | 4 => Some(3),                  // CNH
145        13 | 15 | 126 => Some(4),          // JPY
146        6 | 12 | 124 => Some(5),           // SGD
147        8 => Some(6),                      // AUD
148        112 => Some(7),                    // CAD
149        111 | 125 => Some(8),              // MYR
150        _ => None,
151    }
152}
153
154pub async fn query_positions_sim(
155    backend: &BackendConn,
156    acc_id: u64,
157    trd_market: i32,
158    trd_cache: &TrdCache,
159) -> Result<()> {
160    use crate::proto_internal::sim_user_asset_interface;
161    use prost::Message;
162
163    // v1.4.53 BUG-6 真修:用 sim 专属 proto + registry Positions.sim_cmd。
164    // 对齐 C++ `NNProto_Trd_Acc.cpp:787-808`(sim 分支走 sim_user_asset_interface::PstnInfoReq)
165    // + `M_SendProto_SetSimReqMsgHeader`: market 字段必填,且必须是账户
166    // `m_enTrdMkt` 原值。不能转换成 sim proto `Market` 文档枚举。
167    let market = trd_market_to_sim_header_market(trd_market);
168    let req = sim_user_asset_interface::PstnInfoReq {
169        msg_header: Some(msg_header::build_sim(
170            acc_id,
171            Some(vec![]),
172            Some(market),
173            None,
174        )),
175    };
176
177    let cmd = trade_query_command(TradeQueryOperation::Positions).sim_cmd;
178    let resp = backend.request(cmd, req.encode_to_vec()).await?;
179
180    let parsed: sim_user_asset_interface::PstnInfoRsp = Message::decode(resp.body.as_ref())?;
181    sim_response_status_like_cpp(
182        "sim position",
183        cmd,
184        parsed.result,
185        parsed.err_msg.as_deref(),
186        parsed.msg_header.as_ref(),
187        acc_id,
188    )?;
189
190    let positions: Vec<CachedPosition> = parsed
191        .pstn_infos
192        .iter()
193        .map(|p| {
194            let trd_market = p.market.and_then(|m| i32::try_from(m).ok());
195            CachedPosition {
196                position_id: p.pstn_id.as_ref().and_then(|s| s.parse().ok()).unwrap_or(0),
197                position_side: p.pstn_type.unwrap_or(0),
198                code: p.symbol.as_ref().cloned().unwrap_or_default(),
199                name: p.stock_name.as_ref().cloned().unwrap_or_default(),
200                qty: pf(&p.qty),
201                can_sell_qty: pf(&p.qty_avbl),
202                price: pf(&p.cur_price),
203                cost_price: pf(&p.cost_price),
204                val: pf(&p.mv),
205                pl_val: pf(&p.profit),
206                pl_ratio: pfo(&p.profit_ratio),
207                sec_market: p.market.and_then(sim_trd_market_to_sec_market),
208                td_pl_val: pfo(&p.today_profit),
209                td_trd_val: pfo(&p.today_turnover),
210                td_buy_val: pfo(&p.today_buy_turnover),
211                td_buy_qty: pfo(&p.today_buy_qty),
212                td_sell_val: pfo(&p.today_sell_turnover),
213                td_sell_qty: pfo(&p.today_sell_qty),
214                unrealized_pl: None,
215                realized_pl: None,
216                currency: p.market.and_then(sim_trd_market_to_currency),
217                trd_market,
218                diluted_cost_price: pfo(&p.cost_price),
219                average_cost_price: pfo(&p.buy_avg_price),
220                average_pl_ratio: None,
221                // v1.4.42: DTE 在 handler 层按 code 实时算
222                expiry_date_distance: None,
223            }
224        })
225        .collect();
226
227    tracing::debug!(acc_id, count = positions.len(), "sim positions cached");
228    // Same replacement semantics as real CMD3020: success with zero rows must
229    // clear stale positions instead of leaving the previous snapshot visible.
230    trd_cache.update_positions(acc_id, positions);
231
232    Ok(())
233}
234
235fn sim_response_status_like_cpp(
236    kind: &str,
237    cmd: u16,
238    result: Option<i32>,
239    err_msg: Option<&str>,
240    msg_header: Option<&crate::proto_internal::sim_odr_sys_cmn::MsgHeader>,
241    acc_id: u64,
242) -> Result<()> {
243    // Ref: FutuOpenD/Src/NNProtoCenter/Trade/_NNProto_Trd_Comm.h:20-30
244    // and Trade/Asset/NNProto_Trd_AccSimulate.cpp:330-357 / 373-404.
245    // Sim fund/position responses require explicit `result`, present
246    // `msg_header`, and matching `msg_header.account_id` before cache writes.
247    let Some(result_code) = result else {
248        return Err(futu_core::error::FutuError::Codec(format!(
249            "{kind} CMD {cmd} missing result"
250        )));
251    };
252    if result_code != 0 {
253        let err_msg = err_msg.unwrap_or("unknown");
254        tracing::warn!(
255            acc_id,
256            result = result_code,
257            err = err_msg,
258            kind,
259            cmd,
260            "sim query returned business error"
261        );
262        return Err(futu_core::error::FutuError::ServerError {
263            ret_type: result_code,
264            msg: format!("{kind} CMD {cmd}: {err_msg}"),
265        });
266    }
267    let header = msg_header.ok_or_else(|| {
268        futu_core::error::FutuError::Codec(format!("{kind} CMD {cmd} missing msg_header"))
269    })?;
270    let backend_acc_id = header.account_id.unwrap_or(0);
271    if backend_acc_id != acc_id {
272        return Err(futu_core::error::FutuError::Codec(format!(
273            "{kind} CMD {cmd} account mismatch: server={backend_acc_id} local={acc_id}"
274        )));
275    }
276    Ok(())
277}
278
279#[cfg(test)]
280mod tests;