Skip to main content

futu_trd/
account.rs

1use futu_core::account_locator::{self, AccountCardRecord, AccountVisibilityRecord};
2use futu_core::error::{FutuError, Result};
3use futu_core::proto_id;
4use futu_net::client::FutuClient;
5
6use crate::read_plan;
7use crate::types::{Funds, Position, TrdHeader};
8
9/// 查询账户资金 (向后兼容入口, 不传 currency).
10///
11/// 高层 gateway 会在用户未传 currency 时按券商派生默认资金视图币种
12/// (FutuHK=HKD / FutuInc=USD / FutuCA=CAD 等)。直连底层 client 的调用者
13/// 若需要指定币种,可改用 [`get_funds_with_currency`]。
14pub async fn get_funds(client: &FutuClient, header: &TrdHeader) -> Result<Funds> {
15    get_funds_with_currency(client, header, None).await
16}
17
18/// v1.4.103 (deger 反馈 P1 — 综合账户币种不一致): 查询账户资金, 显式传
19/// currency 参数 (proto enum int: 1=HKD / 2=USD / 3=CNH / 4=JPY / 5=SGD /
20/// 6=AUD / 7=CAD / 8=MYR).
21///
22/// **综合账户 (uniCardNum 非空)**: gateway 会在缺省时按券商派生默认币种;
23/// 显式传 currency 时则要求 backend/cache 返回同币种视图。
24///
25/// **普通账户 (HK-only / US-only)**: currency 字段被 backend 忽略, 传不传
26/// 行为相同.
27pub async fn get_funds_with_currency(
28    client: &FutuClient,
29    header: &TrdHeader,
30    currency: Option<i32>,
31) -> Result<Funds> {
32    let req = futu_proto::trd_get_funds::Request {
33        c2s: futu_proto::trd_get_funds::C2s {
34            header: header.to_proto(),
35            refresh_cache: None,
36            currency,
37            asset_category: None,
38        },
39    };
40
41    let body = prost::Message::encode_to_vec(&req);
42    let resp_frame = client.request(proto_id::TRD_GET_FUNDS, body).await?;
43
44    let resp: futu_proto::trd_get_funds::Response =
45        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
46
47    if resp.ret_type != 0 {
48        return Err(crate::server_err(
49            resp.ret_type,
50            resp.ret_msg,
51            resp.err_code,
52        ));
53    }
54
55    let s2c = resp
56        .s2c
57        .ok_or(FutuError::Codec("missing s2c in GetFunds".into()))?;
58
59    // proto 里 `s2c.funds` 就是 optional —— sim 账户 / 新开户 / 服务端没数据时
60    // 可能不返回,返回空 Funds(所有字段 0.0)而不是 error。
61    //
62    // v1.4.27 修(BUG-2,加拿大同事 v1.4.26 回归测试发现):v1.4.26 及之前
63    // 直接 `.ok_or("missing funds in GetFunds")` 报错,导致 4 个 sim 账户
64    // (sim_type 1+2 / HK+US)funds 查询全部失败。proto 本就是 optional,
65    // 我们错当 required 处理。
66    let funds = s2c.funds.unwrap_or_else(|| {
67        tracing::warn!(
68            trd_env = ?header.trd_env,
69            acc_id = header.acc_id,
70            trd_market = ?header.trd_market,
71            "get_funds: s2c.funds is None (sim account or no data); returning empty Funds"
72        );
73        Default::default()
74    });
75
76    // 若 caller 显式传 currency, backend 仍可能按账户基准币种返回。对比
77    // requested currency vs returned currency tag, 不一致时记录 warning;
78    // 上层 surface 可选择把这个短提示返回给用户。
79    if let Some(warn_msg) = read_plan::funds_currency_mismatch_warning(currency, funds.currency) {
80        tracing::warn!(
81            acc_id = header.acc_id,
82            trd_market = ?header.trd_market,
83            requested_currency = ?currency,
84            returned_currency = ?funds.currency,
85            warning = %warn_msg,
86            "GetFunds: backend ignored requested currency and returned account base currency"
87        );
88    }
89
90    Ok(Funds::from_proto(&funds))
91}
92
93/// 查询持仓列表
94pub async fn get_position_list(client: &FutuClient, header: &TrdHeader) -> Result<Vec<Position>> {
95    get_position_list_with_filter_market(client, header, None).await
96}
97
98/// 查询持仓列表,可显式携带 `TrdFilterConditions.filterMarket`。
99///
100/// C++ `APIServer_Trd_GetPositionList.cpp:45-51` 对持仓列表只在
101/// `filterConditions` 存在时调用 `FilterConditions_IsIncludeParams`,而
102/// `_APIServer_Trd_Comm.cpp:2932-2960` 才会使用 `filterMarket` 排除跨市场持仓。
103/// 因此 mixed-market 账户的 surface 若展示“指定市场”视图,必须显式传本字段。
104pub async fn get_position_list_with_filter_market(
105    client: &FutuClient,
106    header: &TrdHeader,
107    filter_market: Option<i32>,
108) -> Result<Vec<Position>> {
109    let req = build_get_position_list_request(header, filter_market);
110
111    let body = prost::Message::encode_to_vec(&req);
112    let resp_frame = client
113        .request(proto_id::TRD_GET_POSITION_LIST, body)
114        .await?;
115
116    let resp: futu_proto::trd_get_position_list::Response =
117        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
118
119    if resp.ret_type != 0 {
120        return Err(crate::server_err(
121            resp.ret_type,
122            resp.ret_msg,
123            resp.err_code,
124        ));
125    }
126
127    let s2c = resp
128        .s2c
129        .ok_or(FutuError::Codec("missing s2c in GetPositionList".into()))?;
130
131    Ok(s2c.position_list.iter().map(Position::from_proto).collect())
132}
133
134fn build_get_position_list_request(
135    header: &TrdHeader,
136    filter_market: Option<i32>,
137) -> futu_proto::trd_get_position_list::Request {
138    futu_proto::trd_get_position_list::Request {
139        c2s: futu_proto::trd_get_position_list::C2s {
140            header: header.to_proto(),
141            filter_conditions: filter_market.map(|market| {
142                futu_proto::trd_common::TrdFilterConditions {
143                    code_list: vec![],
144                    id_list: vec![],
145                    begin_time: None,
146                    end_time: None,
147                    order_id_ex_list: vec![],
148                    filter_market: Some(market),
149                }
150            }),
151            filter_pl_ratio_min: None,
152            filter_pl_ratio_max: None,
153            refresh_cache: None,
154            asset_category: None,
155        },
156    }
157}
158
159/// 解锁交易结果
160#[derive(Debug, Clone, Default)]
161pub struct UnlockTradeOutcome {
162    /// 所有 broker 合计请求的账户数
163    pub total_requested: usize,
164    /// 所有 broker 合计成功解锁的账户数
165    pub total_unlocked: usize,
166    /// 服务端是否要求 OTP(首次调用可能返 true,用户应重传 `otp`)
167    pub need_otp: bool,
168    /// 如果 `need_otp = true`,这里列出需要 OTP 的账户
169    pub failed_accounts: Vec<u64>,
170    /// 服务端返回的信息(partial failure 时 daemon 会带详情)
171    pub message: Option<String>,
172}
173
174/// 解锁交易(v1.4.31+ 支持 OTP 二步)
175///
176/// 下单前需要先解锁。daemon 会按 broker 分组账户,per-broker 独立发 CMD2900。
177///
178/// - `pwd_md5`: 交易密码 MD5(32 位小写 hex)。lock 时可空。
179/// - `is_unlock`: true=解锁 / false=锁回
180/// - `otp`: OTP / 令牌动态密码明文(仅在首次 unlock 返回 `need_otp=true` 或
181///   服务端返 `TRADE_AUTH_NEED_AUTH_TOKEN(-8)` 时才需要传;普通账号留 `None`)
182///
183/// **返回 Ok(outcome)** 时表示 daemon 成功收到并处理了响应(可能 partial
184/// failure,看 outcome.total_unlocked vs total_requested)。
185/// **返回 Err** 表示完全失败(密码错 / 通道断等)。
186pub async fn unlock_trade(
187    client: &FutuClient,
188    pwd_md5: &str,
189    is_unlock: bool,
190    otp: Option<&str>,
191    security_firm: Option<i32>,
192    // v1.4.34: 只解锁 acc_ids 列表里的账户(空 Vec = 不 per-account filter)。
193    // 解决同 broker 内影子账户拖垮主账户的场景。和 security_firm 同时传时取交集。
194    acc_ids: Vec<u64>,
195) -> Result<UnlockTradeOutcome> {
196    let req = futu_proto::trd_unlock_trade::Request {
197        c2s: futu_proto::trd_unlock_trade::C2s {
198            unlock: is_unlock,
199            pwd_md5: Some(pwd_md5.to_string()),
200            security_firm,
201            sec_otp: otp.map(String::from),
202            acc_ids,
203        },
204    };
205
206    let body = prost::Message::encode_to_vec(&req);
207    let resp_frame = client.request(proto_id::TRD_UNLOCK_TRADE, body).await?;
208
209    let resp: futu_proto::trd_unlock_trade::Response =
210        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
211
212    // need_otp=true 的特殊分支:ret_type=-1 + err_code=-8 但逻辑上不是 hard error
213    if resp.ret_type != 0 {
214        let s2c_ref = resp.s2c.as_ref();
215        let need_otp = s2c_ref.and_then(|s| s.need_otp).unwrap_or(false);
216        if need_otp {
217            let failed = s2c_ref
218                .map(|s| s.account_result_list.iter().map(|a| a.acc_id).collect())
219                .unwrap_or_default();
220            return Ok(UnlockTradeOutcome {
221                total_requested: s2c_ref.map(|s| s.account_result_list.len()).unwrap_or(0),
222                total_unlocked: 0,
223                need_otp: true,
224                failed_accounts: failed,
225                message: resp.ret_msg,
226            });
227        }
228        return Err(crate::server_err(
229            resp.ret_type,
230            resp.ret_msg,
231            resp.err_code,
232        ));
233    }
234
235    // 成功路径(含 partial success —— ret_type=0 但 daemon 会在 ret_msg 里说明)
236    let s2c_ref = resp.s2c.as_ref();
237    let list = s2c_ref.map(|s| &s.account_result_list[..]).unwrap_or(&[]);
238    let total_requested = list.len();
239    let total_unlocked = list.iter().filter(|a| a.success).count();
240    let failed_accounts: Vec<u64> = list
241        .iter()
242        .filter(|a| !a.success)
243        .map(|a| a.acc_id)
244        .collect();
245    Ok(UnlockTradeOutcome {
246        total_requested,
247        total_unlocked,
248        need_otp: false,
249        failed_accounts,
250        message: resp.ret_msg,
251    })
252}
253
254/// 获取账户列表(C++ 默认语义)。
255///
256/// `Trd_GetAccList.C2S.needGeneralSecAccount` 在 C++ OpenD 默认缺省为 false:
257/// HK/US/SG/AU 综合账户体系下 `enTrdMkt == 6` 的证券账户默认隐藏。需要
258/// discovery 视图的 caller(例如 `futucli account`)应显式调用
259/// [`get_acc_list_with_options`] 并传 `need_general_sec_account=true`。
260pub async fn get_acc_list(client: &FutuClient) -> Result<Vec<TrdAcc>> {
261    get_acc_list_with_options(client, None, false).await
262}
263
264/// 用户侧账户发现视图。
265///
266/// C++ `APIServer_Trd_GetAccList.cpp:102-108` 默认隐藏综合账户体系下
267/// `enTrdMkt == NN_TrdMarket_SG` 的证券账户,只有
268/// `needGeneralSecAccount=true` 时才返回。CLI / MCP / REST 账户发现应返回同一份
269/// 完整业务账户集合,因此这里不设置 `trdCategory`,避免把期货、长期激励、
270/// crypto 等已开通账户按证券品类过滤掉。底层 SDK 默认 [`get_acc_list`]
271/// 继续保持 C++ 缺省行为。
272pub async fn get_acc_list_for_account_discovery(client: &FutuClient) -> Result<Vec<TrdAcc>> {
273    get_acc_list_with_options(client, None, true).await
274}
275
276/// 用户可见账户发现投影。
277///
278/// daemon/cache 保留完整已开通业务账户,供路由、鉴权和排障使用;CLI / MCP /
279/// REST 默认只展示 App 会作为独立账户露出的集合。期货-only 行在 App 里包在综合
280/// 账户下,不默认展示;crypto / 长期激励 / IPO route 这类 backend 明确标记的
281/// 业务账户即使没有 `TrdMarket` 权限列表也要展示。`paper_trade` 是 Rust
282/// 展示层派生的模拟账户标签,不代表 App 独立业务账户,不能绕过 futures-only
283/// 过滤。CLI `--all` 仍可看 raw discovery 全集。
284pub fn is_app_visible_account(acc: &TrdAcc) -> bool {
285    account_locator::is_app_visible_account(acc)
286}
287
288pub fn app_visible_accounts(accs: Vec<TrdAcc>) -> Vec<TrdAcc> {
289    account_locator::app_visible_accounts(accs)
290}
291
292/// 获取账户列表,可显式指定 C++ `Trd_GetAccList.C2S` 过滤选项。
293pub async fn get_acc_list_with_options(
294    client: &FutuClient,
295    trd_category: Option<i32>,
296    need_general_sec_account: bool,
297) -> Result<Vec<TrdAcc>> {
298    let req = build_get_acc_list_request(trd_category, need_general_sec_account);
299
300    let body = prost::Message::encode_to_vec(&req);
301    let resp_frame = client.request(proto_id::TRD_GET_ACC_LIST, body).await?;
302
303    let resp: futu_proto::trd_get_acc_list::Response =
304        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
305
306    if resp.ret_type != 0 {
307        return Err(crate::server_err(
308            resp.ret_type,
309            resp.ret_msg,
310            resp.err_code,
311        ));
312    }
313
314    let s2c = resp
315        .s2c
316        .ok_or(FutuError::Codec("missing s2c in GetAccList".into()))?;
317
318    Ok(s2c
319        .acc_list
320        .iter()
321        .map(|a| TrdAcc {
322            trd_env: a.trd_env,
323            acc_id: a.acc_id,
324            trd_market_auth_list: a.trd_market_auth_list.clone(),
325            acc_type: a.acc_type,
326            card_num: a.card_num.clone(),
327            security_firm: a.security_firm,
328            sim_acc_type: a.sim_acc_type,
329            uni_card_num: a.uni_card_num.clone(),
330            acc_status: a.acc_status,
331            acc_role: a.acc_role,
332            acc_label: a.acc_label.clone(),
333            jp_acc_type: a.jp_acc_type.clone(),
334        })
335        .collect())
336}
337
338fn build_get_acc_list_request(
339    trd_category: Option<i32>,
340    need_general_sec_account: bool,
341) -> futu_proto::trd_get_acc_list::Request {
342    futu_proto::trd_get_acc_list::Request {
343        c2s: futu_proto::trd_get_acc_list::C2s {
344            user_id: 0,
345            trd_category,
346            need_general_sec_account: Some(need_general_sec_account),
347        },
348    }
349}
350
351/// 业务账户 —— 和官方 Futu `Trd_Common.TrdAcc` proto 一一对应(12 字段)。
352///
353/// v1.4.26 前只存 3 字段(trd_env / acc_id / trd_market_auth_list),
354/// 导致 CLI `futucli account` 表格只有 3 列,用户看不到 `security_firm` /
355/// `acc_type` / `card_num` 等重要信息。现在对齐 proto 完整保留。
356#[derive(Debug, Clone, Default)]
357pub struct TrdAcc {
358    /// 0=simulate, 1=real, 2=fund(对齐 `Trd_Common.TrdEnv`)
359    pub trd_env: i32,
360    /// 业务账号
361    pub acc_id: u64,
362    /// 账户支持的交易市场权限(`Trd_Common.TrdMarket` enum: 1=HK 2=US 3=CN ...)
363    pub trd_market_auth_list: Vec<i32>,
364    /// 账户类型(`Trd_Common.TrdAccType`: 0=未知 1=现金 2=保证金 3=期货)
365    pub acc_type: Option<i32>,
366    /// 账户卡号(人读标识,比如 "8105" 这样的短号)
367    pub card_num: Option<String>,
368    /// 所属券商(`Trd_Common.SecurityFirm`: 1=FutuSecurities(HK) 2=FutuInc(US)
369    /// 3=FutuSG 4=FutuAU 5=FutuCA 6=FutuMY 7=FutuJP)
370    pub security_firm: Option<i32>,
371    /// 模拟交易子类型(仅 trd_env=0 时有值,`Trd_Common.SimAccType`)
372    pub sim_acc_type: Option<i32>,
373    /// 综合账户卡号(子账户归属的父综合账户的 card_num)
374    pub uni_card_num: Option<String>,
375    /// 账户状态(`Trd_Common.TrdAccStatus`: 0=active 1=disabled)
376    pub acc_status: Option<i32>,
377    /// 账号分类(`Trd_Common.TrdAccRole`: 主/子账号)
378    pub acc_role: Option<i32>,
379    /// Daemon-derived user-visible account label (`crypto`,
380    /// `equity_incentive`, `ipo_route`, ...). Treat unknown values as opaque
381    /// display strings.
382    pub acc_label: Option<String>,
383    /// JP 子账户类型(日本账号特殊,`Trd_Common.TrdSubAccType`)
384    pub jp_acc_type: Vec<i32>,
385}
386
387impl AccountCardRecord for TrdAcc {
388    fn acc_id(&self) -> u64 {
389        self.acc_id
390    }
391
392    fn card_num(&self) -> Option<&str> {
393        self.card_num.as_deref()
394    }
395
396    fn uni_card_num(&self) -> Option<&str> {
397        self.uni_card_num.as_deref()
398    }
399}
400
401impl AccountVisibilityRecord for TrdAcc {
402    fn trd_market_auth_list(&self) -> &[i32] {
403        &self.trd_market_auth_list
404    }
405
406    fn acc_label(&self) -> Option<&str> {
407        self.acc_label.as_deref()
408    }
409}
410
411#[cfg(test)]
412mod tests;