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 (external reviewer 反馈 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_options(client, header, PositionListOptions::default()).await
96}
97
98#[derive(Debug, Clone, Copy, Default)]
99pub struct PositionListOptions {
100    /// `TrdFilterConditions.filterMarket` for mixed-market account views.
101    pub filter_market: Option<i32>,
102    /// C++ 10.7: crypto accounts must pass `currency`; non-crypto accounts
103    /// ignore it and keep their first-valid account currency.
104    pub currency: Option<i32>,
105    /// C++ 10.7: request option strategy/combo position view when supported.
106    pub option_strategy_view: Option<bool>,
107}
108
109/// 查询持仓列表,可显式携带 `TrdFilterConditions.filterMarket`。
110///
111/// C++ `APIServer_Trd_GetPositionList.cpp:45-51` 对持仓列表只在
112/// `filterConditions` 存在时调用 `FilterConditions_IsIncludeParams`,而
113/// `_APIServer_Trd_Comm.cpp:2932-2960` 才会使用 `filterMarket` 排除跨市场持仓。
114/// 因此 mixed-market 账户的 surface 若展示“指定市场”视图,必须显式传本字段。
115pub async fn get_position_list_with_filter_market(
116    client: &FutuClient,
117    header: &TrdHeader,
118    filter_market: Option<i32>,
119) -> Result<Vec<Position>> {
120    get_position_list_with_options(
121        client,
122        header,
123        PositionListOptions {
124            filter_market,
125            ..Default::default()
126        },
127    )
128    .await
129}
130
131pub async fn get_position_list_with_options(
132    client: &FutuClient,
133    header: &TrdHeader,
134    options: PositionListOptions,
135) -> Result<Vec<Position>> {
136    let req = build_get_position_list_request(header, options);
137
138    let body = prost::Message::encode_to_vec(&req);
139    let resp_frame = client
140        .request(proto_id::TRD_GET_POSITION_LIST, body)
141        .await?;
142
143    let resp: futu_proto::trd_get_position_list::Response =
144        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
145
146    if resp.ret_type != 0 {
147        return Err(crate::server_err(
148            resp.ret_type,
149            resp.ret_msg,
150            resp.err_code,
151        ));
152    }
153
154    let s2c = resp
155        .s2c
156        .ok_or(FutuError::Codec("missing s2c in GetPositionList".into()))?;
157
158    Ok(s2c.position_list.iter().map(Position::from_proto).collect())
159}
160
161fn build_get_position_list_request(
162    header: &TrdHeader,
163    options: PositionListOptions,
164) -> futu_proto::trd_get_position_list::Request {
165    futu_proto::trd_get_position_list::Request {
166        c2s: futu_proto::trd_get_position_list::C2s {
167            header: header.to_proto(),
168            filter_conditions: options.filter_market.map(|market| {
169                futu_proto::trd_common::TrdFilterConditions {
170                    code_list: vec![],
171                    id_list: vec![],
172                    begin_time: None,
173                    end_time: None,
174                    order_id_ex_list: vec![],
175                    filter_market: Some(market),
176                }
177            }),
178            filter_pl_ratio_min: None,
179            filter_pl_ratio_max: None,
180            refresh_cache: None,
181            asset_category: None,
182            currency: options.currency,
183            option_strategy_view: options.option_strategy_view,
184        },
185    }
186}
187
188/// 解锁交易结果
189#[derive(Debug, Clone, Default)]
190pub struct UnlockTradeOutcome {
191    /// 所有 broker 合计请求的账户数
192    pub total_requested: usize,
193    /// 所有 broker 合计成功解锁的账户数
194    pub total_unlocked: usize,
195    /// 服务端是否要求 OTP(首次调用可能返 true,用户应重传 `otp`)
196    pub need_otp: bool,
197    /// 如果 `need_otp = true`,这里列出需要 OTP 的账户
198    pub failed_accounts: Vec<u64>,
199    /// 服务端返回的信息(partial failure 时 daemon 会带详情)
200    pub message: Option<String>,
201}
202
203/// 解锁交易(v1.4.31+ 支持 OTP 二步)
204///
205/// 下单前需要先解锁。daemon 会按 broker 分组账户,per-broker 独立发 CMD2900。
206///
207/// - `pwd_md5`: 交易密码 MD5(32 位小写 hex)。lock 时可空。
208/// - `is_unlock`: true=解锁 / false=锁回
209/// - `otp`: OTP / 令牌动态密码明文(仅在首次 unlock 返回 `need_otp=true` 或
210///   服务端返 `TRADE_AUTH_NEED_AUTH_TOKEN(-8)` 时才需要传;普通账号留 `None`)
211///
212/// **返回 Ok(outcome)** 时表示 daemon 成功收到并处理了响应(可能 partial
213/// failure,看 outcome.total_unlocked vs total_requested)。
214/// **返回 Err** 表示完全失败(密码错 / 通道断等)。
215pub async fn unlock_trade(
216    client: &FutuClient,
217    pwd_md5: &str,
218    is_unlock: bool,
219    otp: Option<&str>,
220    security_firm: Option<i32>,
221    // v1.4.34: 只解锁 acc_ids 列表里的账户(空 Vec = 不 per-account filter)。
222    // 解决同 broker 内影子账户拖垮主账户的场景。和 security_firm 同时传时取交集。
223    acc_ids: Vec<u64>,
224) -> Result<UnlockTradeOutcome> {
225    let req = futu_proto::trd_unlock_trade::Request {
226        c2s: futu_proto::trd_unlock_trade::C2s {
227            unlock: is_unlock,
228            pwd_md5: Some(pwd_md5.to_string()),
229            security_firm,
230            sec_otp: otp.map(String::from),
231            acc_ids,
232        },
233    };
234
235    let body = prost::Message::encode_to_vec(&req);
236    let resp_frame = client.request(proto_id::TRD_UNLOCK_TRADE, body).await?;
237
238    let resp: futu_proto::trd_unlock_trade::Response =
239        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
240
241    // need_otp=true 的特殊分支:ret_type=-1 + err_code=-8 但逻辑上不是 hard error
242    if resp.ret_type != 0 {
243        let s2c_ref = resp.s2c.as_ref();
244        let need_otp = s2c_ref.and_then(|s| s.need_otp).unwrap_or(false);
245        if need_otp {
246            let failed = s2c_ref
247                .map(|s| s.account_result_list.iter().map(|a| a.acc_id).collect())
248                .unwrap_or_default();
249            return Ok(UnlockTradeOutcome {
250                total_requested: s2c_ref.map(|s| s.account_result_list.len()).unwrap_or(0),
251                total_unlocked: 0,
252                need_otp: true,
253                failed_accounts: failed,
254                message: resp.ret_msg,
255            });
256        }
257        return Err(crate::server_err(
258            resp.ret_type,
259            resp.ret_msg,
260            resp.err_code,
261        ));
262    }
263
264    // 成功路径(含 partial success —— ret_type=0 但 daemon 会在 ret_msg 里说明)
265    let s2c_ref = resp.s2c.as_ref();
266    let list = s2c_ref.map(|s| &s.account_result_list[..]).unwrap_or(&[]);
267    let total_requested = list.len();
268    let total_unlocked = list.iter().filter(|a| a.success).count();
269    let failed_accounts: Vec<u64> = list
270        .iter()
271        .filter(|a| !a.success)
272        .map(|a| a.acc_id)
273        .collect();
274    Ok(UnlockTradeOutcome {
275        total_requested,
276        total_unlocked,
277        need_otp: false,
278        failed_accounts,
279        message: resp.ret_msg,
280    })
281}
282
283/// 获取账户列表(C++ 默认语义)。
284///
285/// `Trd_GetAccList.C2S.needGeneralSecAccount` 在 C++ OpenD 默认缺省为 false:
286/// HK/US/SG/AU 综合账户体系下 `enTrdMkt == 6` 的证券账户默认隐藏。需要
287/// discovery 视图的 caller(例如 `futucli account`)应显式调用
288/// [`get_acc_list_with_options`] 并传 `need_general_sec_account=true`。
289pub async fn get_acc_list(client: &FutuClient) -> Result<Vec<TrdAcc>> {
290    get_acc_list_with_options(client, None, false).await
291}
292
293/// 用户侧账户发现视图。
294///
295/// C++ `APIServer_Trd_GetAccList.cpp:102-108` 默认隐藏综合账户体系下
296/// `enTrdMkt == NN_TrdMarket_SG` 的证券账户,只有
297/// `needGeneralSecAccount=true` 时才返回。CLI / MCP / REST 账户发现应返回同一份
298/// 完整业务账户集合,因此这里不设置 `trdCategory`,避免把期货、长期激励、
299/// crypto 等已开通账户按证券品类过滤掉。底层 SDK 默认 [`get_acc_list`]
300/// 继续保持 C++ 缺省行为。
301pub async fn get_acc_list_for_account_discovery(client: &FutuClient) -> Result<Vec<TrdAcc>> {
302    get_acc_list_with_options(client, None, true).await
303}
304
305/// 用户可见账户发现投影。
306///
307/// daemon/cache 保留完整已开通业务账户,供路由、鉴权和排障使用;CLI / MCP /
308/// REST 默认只展示 App 会作为独立账户露出的集合。期货-only 行在 App 里包在综合
309/// 账户下,不默认展示;crypto / 长期激励 / IPO route 这类 backend 明确标记的
310/// 业务账户即使没有 `TrdMarket` 权限列表也要展示。`paper_trade` 是 Rust
311/// 展示层派生的模拟账户标签,不代表 App 独立业务账户,不能绕过 futures-only
312/// 过滤。CLI `--all` 仍可看 raw discovery 全集。
313pub fn is_app_visible_account(acc: &TrdAcc) -> bool {
314    account_locator::is_app_visible_account(acc)
315}
316
317pub fn app_visible_accounts(accs: Vec<TrdAcc>) -> Vec<TrdAcc> {
318    account_locator::app_visible_accounts(accs)
319}
320
321/// 获取账户列表,可显式指定 C++ `Trd_GetAccList.C2S` 过滤选项。
322pub async fn get_acc_list_with_options(
323    client: &FutuClient,
324    trd_category: Option<i32>,
325    need_general_sec_account: bool,
326) -> Result<Vec<TrdAcc>> {
327    let req = build_get_acc_list_request(trd_category, need_general_sec_account);
328
329    let body = prost::Message::encode_to_vec(&req);
330    let resp_frame = client.request(proto_id::TRD_GET_ACC_LIST, body).await?;
331
332    let resp: futu_proto::trd_get_acc_list::Response =
333        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
334
335    if resp.ret_type != 0 {
336        return Err(crate::server_err(
337            resp.ret_type,
338            resp.ret_msg,
339            resp.err_code,
340        ));
341    }
342
343    let s2c = resp
344        .s2c
345        .ok_or(FutuError::Codec("missing s2c in GetAccList".into()))?;
346
347    Ok(s2c
348        .acc_list
349        .iter()
350        .map(|a| TrdAcc {
351            trd_env: a.trd_env,
352            acc_id: a.acc_id,
353            trd_market_auth_list: a.trd_market_auth_list.clone(),
354            acc_type: a.acc_type,
355            card_num: a.card_num.clone(),
356            security_firm: a.security_firm,
357            sim_acc_type: a.sim_acc_type,
358            uni_card_num: a.uni_card_num.clone(),
359            acc_status: a.acc_status,
360            acc_role: a.acc_role,
361            competition_acc_name: a.competition_acc_name.clone(),
362            acc_label: a.acc_label.clone(),
363            jp_acc_type: a.jp_acc_type.clone(),
364        })
365        .collect())
366}
367
368fn build_get_acc_list_request(
369    trd_category: Option<i32>,
370    need_general_sec_account: bool,
371) -> futu_proto::trd_get_acc_list::Request {
372    futu_proto::trd_get_acc_list::Request {
373        c2s: futu_proto::trd_get_acc_list::C2s {
374            user_id: 0,
375            trd_category,
376            need_general_sec_account: Some(need_general_sec_account),
377        },
378    }
379}
380
381/// 业务账户 —— 和官方 Futu `Trd_Common.TrdAcc` proto 一一对应。
382///
383/// v1.4.26 前只存 3 字段(trd_env / acc_id / trd_market_auth_list),
384/// 导致 CLI `futucli account` 表格只有 3 列,用户看不到 `security_firm` /
385/// `acc_type` / `card_num` 等重要信息。现在对齐 proto 完整保留。
386#[derive(Debug, Clone, Default)]
387pub struct TrdAcc {
388    /// 0=simulate, 1=real, 2=fund(对齐 `Trd_Common.TrdEnv`)
389    pub trd_env: i32,
390    /// 业务账号
391    pub acc_id: u64,
392    /// 账户支持的交易市场权限(`Trd_Common.TrdMarket` enum: 1=HK 2=US 3=CN ...)
393    pub trd_market_auth_list: Vec<i32>,
394    /// 账户类型(`Trd_Common.TrdAccType`: 0=未知 1=现金 2=保证金 3=期货)
395    pub acc_type: Option<i32>,
396    /// 账户卡号(人读标识,比如 "8105" 这样的短号)
397    pub card_num: Option<String>,
398    /// 所属券商(`Trd_Common.SecurityFirm`: 1=FutuSecurities(HK) 2=FutuInc(US)
399    /// 3=FutuSG 4=FutuAU 5=FutuCA 6=FutuMY 7=FutuJP)
400    pub security_firm: Option<i32>,
401    /// 模拟交易子类型(仅 trd_env=0 时有值,`Trd_Common.SimAccType`)
402    pub sim_acc_type: Option<i32>,
403    /// 综合账户卡号(子账户归属的父综合账户的 card_num)
404    pub uni_card_num: Option<String>,
405    /// 账户状态(`Trd_Common.TrdAccStatus`: 0=active 1=disabled)
406    pub acc_status: Option<i32>,
407    /// 账号分类(`Trd_Common.TrdAccRole`: 主/子账号)
408    pub acc_role: Option<i32>,
409    /// 赛事账户名称(C++ OpenD 10.6 `TrdAcc.competitionAccName`, field 12)。
410    pub competition_acc_name: Option<String>,
411    /// Daemon-derived user-visible account label (`crypto`,
412    /// `equity_incentive`, `ipo_route`, ...). Treat unknown values as opaque
413    /// display strings.
414    pub acc_label: Option<String>,
415    /// JP 子账户类型(日本账号特殊,`Trd_Common.TrdSubAccType`)
416    pub jp_acc_type: Vec<i32>,
417}
418
419impl AccountCardRecord for TrdAcc {
420    fn acc_id(&self) -> u64 {
421        self.acc_id
422    }
423
424    fn card_num(&self) -> Option<&str> {
425        self.card_num.as_deref()
426    }
427
428    fn uni_card_num(&self) -> Option<&str> {
429        self.uni_card_num.as_deref()
430    }
431}
432
433impl AccountVisibilityRecord for TrdAcc {
434    fn trd_market_auth_list(&self) -> &[i32] {
435        &self.trd_market_auth_list
436    }
437
438    fn acc_label(&self) -> Option<&str> {
439        self.acc_label.as_deref()
440    }
441}
442
443#[cfg(test)]
444mod tests;