Skip to main content

futucli/cmd/
tier_m.rs

1//! `futucli` Tier M (mobile-driven extension) commands — v1.4.94 M1 + v1.4.95 U2-A/D/B
2//!
3//! 11 subcommand 镜像 REST + MCP:
4//!
5//! | subcommand              | proto_id | mobile cmd | 用途                       |
6//! |-------------------------|----------|------------|----------------------------|
7//! | `cash-log`              | 22701    | 3000       | 资金明细查询 (M1)          |
8//! | `cash-detail`           | 22702    | 3001       | 单条资金详情 (M1)          |
9//! | `biz-group`             | 22703    | 3002       | 业务分类元数据 (M1)        |
10//! | `margin-info`           | 22704    | 3101/02/07 | per-account margin (U2-D)  |
11//! | `account-flag`          | 22705    | 5281       | 账户合规标志 (U2-A)        |
12//! | `bond-total-asset`      | 22706    | 9373       | 债券账户总持仓 (U2-B)      |
13//! | `bond-single-asset`     | 22707    | 9374       | 单只债券持仓 (U2-B)        |
14//! | `bond-position-list`    | 22708    | 9375       | 债券持仓列表 (U2-B)        |
15//! | `bond-answer-state`     | 22709    | 10043      | 是否需答题 (U2-B)          |
16//! | `bond-trade-reminder`   | 22710    | 10057      | 交易提醒 (U2-B)            |
17//!
18//! ## 架构
19//!
20//! 直接 `connect_gateway` + `client.request(proto_id, body)`, decode Daemon
21//! FTAPI wrapper, JSON pretty print. 不走 futu-trd 高层 helper (Tier M
22//! handler proto 在 futu-backend::proto_internal 直接定义, 没必要再 wrap 一层).
23//!
24//! ## 输出格式
25//!
26//! 默认 JSON pretty (`OutputFormat::Json` 或 `OutputFormat::Table`).
27//! 11 endpoint 字段较多 + 嵌套结构 (notice_list / margin_info), table view
28//! 不易表达, 统一用 JSON. Table format 也 fallback 到 JSON.
29
30use anyhow::{Context, Result, bail};
31use prost::Message;
32
33use crate::common::connect_gateway;
34use crate::output::OutputFormat;
35use futu_backend::proto_internal::{
36    account_flag, bond_client_view, realtime_asset_log, risk_user_account_info,
37};
38use futu_core::proto_id;
39
40/// 共用 helper: env 字符串 → trd_env int (0=sim / 1=real).
41///
42/// v1.4.102 codex 42 F2 (P2): typo (e.g. "prod" / "reel") 现 reject —
43/// 之前 "non-real => 0" silent 把 typo 当 sim, 违 BUG-005 精神.
44/// 接受: "real" / "REAL" / "sim" / "simulate" / "SIM" / "" (空 → 默认 real).
45fn parse_env_int(env: &str) -> Result<i32> {
46    match env.trim().to_ascii_lowercase().as_str() {
47        "real" => Ok(1),
48        "sim" | "simulate" => Ok(0),
49        // 空字符串 = clap default-not-given (cli `--env` 默认值);
50        // 历史行为是 "默认 real". 保持兼容 (Tier M 多数 default 实盘).
51        "" => Ok(1),
52        other => Err(anyhow::anyhow!(
53            "unknown env {other:?} (expected: real / sim / simulate). \
54             v1.4.102 codex 42 F2 fix: typo 拒, 不 silent 当 sim"
55        )),
56    }
57}
58
59/// 共用 helper: 调 daemon + decode generic
60async fn dispatch<RspT: prost::Message + Default>(
61    gateway: &str,
62    client_id: &str,
63    proto_id: u32,
64    body: Vec<u8>,
65) -> Result<RspT> {
66    let (client, _push_rx) = connect_gateway(gateway, client_id).await?;
67    let frame = client
68        .request(proto_id, body)
69        .await
70        .with_context(|| format!("request proto_id {proto_id}"))?;
71    RspT::decode(frame.body.as_ref())
72        .with_context(|| format!("decode response for proto_id {proto_id}"))
73}
74
75/// 共用 helper: JSON pretty print (table format fallback to JSON for nested data)
76fn print_json_pretty<T: serde::Serialize>(_format: OutputFormat, val: &T) -> Result<()> {
77    let s = serde_json::to_string_pretty(val)?;
78    println!("{s}");
79    Ok(())
80}
81
82// ============================================================================
83// v1.4.94 M1: cash log — cmd 3000/3001/3002
84// ============================================================================
85
86/// `futucli cash-log` — 资金明细查询 (mobile-driven, 比 acc-cash-flow 字段更全)
87pub struct CashLogCommand<'a> {
88    pub gateway: &'a str,
89    pub env: &'a str,
90    pub acc_id: u64,
91    pub begin_time: u64,
92    pub end_time: u64,
93    pub log_id_cursor: Option<String>,
94    pub biz_group_id: Option<u32>,
95    pub biz_sub_group_id: Option<u32>,
96    pub in_out: Option<u32>,
97    pub keyword: Option<String>,
98    pub symbol: Option<String>,
99    pub stock_id: Option<u64>,
100    pub max_cnt: Option<u32>,
101    pub currency: Option<String>,
102    pub format: OutputFormat,
103}
104
105pub async fn run_cash_log(input: CashLogCommand<'_>) -> Result<()> {
106    if input.acc_id == 0 {
107        bail!("--acc-id 必填 (call `futucli list-accounts` to discover)");
108    }
109    if input.end_time < input.begin_time {
110        bail!(
111            "--end-time {} 不能早于 --begin-time {}",
112            input.end_time,
113            input.begin_time
114        );
115    }
116    let req = realtime_asset_log::DaemonGetCashLogReq {
117        c2s: realtime_asset_log::daemon_get_cash_log_req::C2s {
118            header: realtime_asset_log::DaemonGetCashLogHeader {
119                acc_id: input.acc_id,
120                trd_env: Some(parse_env_int(input.env)?),
121            },
122            // market + account_id 由 daemon 从 acc_id 自动派生 (cash_log handler)
123            inner: Some(realtime_asset_log::GetCashLogReq {
124                begin_time: Some(input.begin_time),
125                end_time: Some(input.end_time),
126                log_id: input.log_id_cursor,
127                biz_group_id: input.biz_group_id,
128                biz_sub_group_id: input.biz_sub_group_id,
129                in_out: input.in_out,
130                keyword: input.keyword,
131                symbol: input.symbol,
132                stock_id: input.stock_id,
133                max_cnt: input.max_cnt,
134                currency: input.currency,
135                ..Default::default()
136            }),
137        },
138    };
139    let body = req.encode_to_vec();
140    let resp: realtime_asset_log::DaemonGetCashLogRsp = dispatch(
141        input.gateway,
142        "futucli-cash-log",
143        proto_id::TRD_GET_CASH_LOG,
144        body,
145    )
146    .await?;
147    if resp.ret_type != 0 {
148        bail!(
149            "GetCashLog failed: ret_type={} ret_msg={:?}",
150            resp.ret_type,
151            resp.ret_msg
152        );
153    }
154    print_json_pretty(input.format, &resp.s2c.and_then(|s| s.inner))?;
155    Ok(())
156}
157
158/// `futucli cash-detail` — 单条资金流水详情
159pub async fn run_cash_detail(
160    gateway: &str,
161    env: &str,
162    acc_id: u64,
163    log_id: String,
164    format: OutputFormat,
165) -> Result<()> {
166    if acc_id == 0 {
167        bail!("--acc-id 必填");
168    }
169    if log_id.trim().is_empty() {
170        bail!("--log-id 必填 (从 cash-log 返回的 log_id 字段拿, 字符串)");
171    }
172    let req = realtime_asset_log::DaemonGetCashDetailReq {
173        c2s: realtime_asset_log::daemon_get_cash_detail_req::C2s {
174            header: realtime_asset_log::DaemonGetCashLogHeader {
175                acc_id,
176                trd_env: Some(parse_env_int(env)?),
177            },
178            // market + account_id 由 daemon auto-derive
179            inner: Some(realtime_asset_log::GetCashDetailReq {
180                log_id: Some(log_id),
181                market: None,
182                account_id: None,
183            }),
184        },
185    };
186    let body = req.encode_to_vec();
187    let resp: realtime_asset_log::DaemonGetCashDetailRsp = dispatch(
188        gateway,
189        "futucli-cash-detail",
190        proto_id::TRD_GET_CASH_DETAIL,
191        body,
192    )
193    .await?;
194    if resp.ret_type != 0 {
195        bail!(
196            "GetCashDetail failed: ret_type={} ret_msg={:?}",
197            resp.ret_type,
198            resp.ret_msg
199        );
200    }
201    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
202    Ok(())
203}
204
205/// `futucli biz-group` — 业务分类元数据
206pub async fn run_biz_group(
207    gateway: &str,
208    env: &str,
209    acc_id: u64,
210    format: OutputFormat,
211) -> Result<()> {
212    if acc_id == 0 {
213        bail!("--acc-id 必填");
214    }
215    let req = realtime_asset_log::DaemonGetBizGroupReq {
216        c2s: realtime_asset_log::daemon_get_biz_group_req::C2s {
217            header: realtime_asset_log::DaemonGetCashLogHeader {
218                acc_id,
219                trd_env: Some(parse_env_int(env)?),
220            },
221            // market 由 daemon auto-derive
222            inner: Some(realtime_asset_log::GetBizGroupReq { market: None }),
223        },
224    };
225    let body = req.encode_to_vec();
226    let resp: realtime_asset_log::DaemonGetBizGroupRsp = dispatch(
227        gateway,
228        "futucli-biz-group",
229        proto_id::TRD_GET_BIZ_GROUP,
230        body,
231    )
232    .await?;
233    if resp.ret_type != 0 {
234        bail!(
235            "GetBizGroup failed: ret_type={} ret_msg={:?}",
236            resp.ret_type,
237            resp.ret_msg
238        );
239    }
240    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
241    Ok(())
242}
243
244// ============================================================================
245// v1.4.95 U2-D: per-account margin info — cmd 3101/3102/3107
246// ============================================================================
247
248/// `futucli margin-info` — per-account margin info (HK/US/CN_AH)
249pub async fn run_margin_info(
250    gateway: &str,
251    env: &str,
252    acc_id: u64,
253    market: &str,
254    format: OutputFormat,
255) -> Result<()> {
256    if acc_id == 0 {
257        bail!("--acc-id 必填");
258    }
259    let market_clean = market.trim();
260    if market_clean.is_empty() {
261        bail!("--market 必填: HK / US / CN_AH");
262    }
263    let req = risk_user_account_info::DaemonGetMarginInfoReq {
264        c2s: risk_user_account_info::daemon_get_margin_info_req::C2s {
265            header: risk_user_account_info::DaemonMarginInfoHeader {
266                acc_id,
267                trd_env: Some(parse_env_int(env)?),
268                market: market_clean.to_string(),
269            },
270            inner: None,
271        },
272    };
273    let body = req.encode_to_vec();
274    let resp: risk_user_account_info::DaemonGetMarginInfoRsp = dispatch(
275        gateway,
276        "futucli-margin-info",
277        proto_id::TRD_GET_MARGIN_INFO,
278        body,
279    )
280    .await?;
281    if resp.ret_type != 0 {
282        bail!(
283            "GetMarginInfo failed: ret_type={} ret_msg={:?}",
284            resp.ret_type,
285            resp.ret_msg
286        );
287    }
288    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
289    Ok(())
290}
291
292// ============================================================================
293// v1.4.95 U2-A: account compliance flag — cmd 5281
294// ============================================================================
295
296/// `futucli account-flag` — 账户合规标志查询
297pub async fn run_account_flag(
298    gateway: &str,
299    env: &str,
300    acc_id: u64,
301    flag_id: u32,
302    format: OutputFormat,
303) -> Result<()> {
304    if acc_id == 0 {
305        bail!("--acc-id 必填");
306    }
307    if flag_id == 0 {
308        bail!(
309            "--flag-id 必填 (常用值: 5=US 期权确认, 22=衍生品风批, 10=基金 KYC, \
310             16=PDT, 23=美股 OTC; 详见 proto 头部完整 36+ 项列表)"
311        );
312    }
313    let req = account_flag::DaemonGetAccountFlagReq {
314        c2s: account_flag::daemon_get_account_flag_req::C2s {
315            header: account_flag::DaemonGetAccountFlagHeader {
316                acc_id,
317                trd_env: Some(parse_env_int(env)?),
318                flag_id,
319            },
320        },
321    };
322    let body = req.encode_to_vec();
323    let resp: account_flag::DaemonGetAccountFlagRsp = dispatch(
324        gateway,
325        "futucli-account-flag",
326        proto_id::TRD_GET_ACCOUNT_FLAG,
327        body,
328    )
329    .await?;
330    if resp.ret_type != 0 {
331        bail!(
332            "GetAccountFlag failed: ret_type={} ret_msg={:?}",
333            resp.ret_type,
334            resp.ret_msg
335        );
336    }
337    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
338    Ok(())
339}
340
341// ============================================================================
342// v1.4.95 U2-B: bond holdings + trade prep — cmd 9373/9374/9375/10043/10057
343// ============================================================================
344
345/// 共用 helper: parse market (HK/US/SG only)
346fn validate_bond_market(market: &str) -> Result<String> {
347    let m = market.trim();
348    if m.is_empty() {
349        bail!("--market 必填: HK / US / SG (债券业务仅 3 市场)");
350    }
351    let upper = m.to_ascii_uppercase();
352    match upper.as_str() {
353        "HK" | "US" | "USA" | "SG" | "SG_UNIVERSAL" => Ok(upper),
354        _ => bail!(
355            "unsupported market {m:?} (supported: HK / US / SG; \
356             仅 HK/US/SG 债券账户有数据)"
357        ),
358    }
359}
360
361/// `futucli bond-total-asset` — 账户债券总持仓
362pub async fn run_bond_total_asset(
363    gateway: &str,
364    env: &str,
365    acc_id: u64,
366    market: &str,
367    format: OutputFormat,
368) -> Result<()> {
369    if acc_id == 0 {
370        bail!("--acc-id 必填");
371    }
372    let market_upper = validate_bond_market(market)?;
373    let req = bond_client_view::DaemonGetBondTotalAssetReq {
374        c2s: bond_client_view::daemon_get_bond_total_asset_req::C2s {
375            header: bond_client_view::DaemonBondHeader {
376                acc_id,
377                trd_env: Some(parse_env_int(env)?),
378                market: market_upper,
379            },
380        },
381    };
382    let body = req.encode_to_vec();
383    let resp: bond_client_view::DaemonGetBondTotalAssetRsp = dispatch(
384        gateway,
385        "futucli-bond-total-asset",
386        proto_id::TRD_GET_BOND_TOTAL_ASSET,
387        body,
388    )
389    .await?;
390    if resp.ret_type != 0 {
391        bail!(
392            "GetBondTotalAsset failed: ret_type={} ret_msg={:?}",
393            resp.ret_type,
394            resp.ret_msg
395        );
396    }
397    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
398    Ok(())
399}
400
401/// `futucli bond-single-asset` — 单只债券持仓
402pub async fn run_bond_single_asset(
403    gateway: &str,
404    env: &str,
405    acc_id: u64,
406    market: &str,
407    symbol: &str,
408    format: OutputFormat,
409) -> Result<()> {
410    if acc_id == 0 {
411        bail!("--acc-id 必填");
412    }
413    if symbol.trim().is_empty() {
414        bail!("--symbol 必填 (债券代码)");
415    }
416    let market_upper = validate_bond_market(market)?;
417    let req = bond_client_view::DaemonGetBondSingleAssetReq {
418        c2s: bond_client_view::daemon_get_bond_single_asset_req::C2s {
419            header: bond_client_view::DaemonBondHeader {
420                acc_id,
421                trd_env: Some(parse_env_int(env)?),
422                market: market_upper,
423            },
424            symbol: symbol.to_string(),
425        },
426    };
427    let body = req.encode_to_vec();
428    let resp: bond_client_view::DaemonGetBondSingleAssetRsp = dispatch(
429        gateway,
430        "futucli-bond-single-asset",
431        proto_id::TRD_GET_BOND_SINGLE_ASSET,
432        body,
433    )
434    .await?;
435    if resp.ret_type != 0 {
436        bail!(
437            "GetBondSingleAsset failed: ret_type={} ret_msg={:?}",
438            resp.ret_type,
439            resp.ret_msg
440        );
441    }
442    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
443    Ok(())
444}
445
446/// `futucli bond-position-list` — 债券持仓列表
447pub async fn run_bond_position_list(
448    gateway: &str,
449    env: &str,
450    acc_id: u64,
451    market: &str,
452    format: OutputFormat,
453) -> Result<()> {
454    if acc_id == 0 {
455        bail!("--acc-id 必填");
456    }
457    let market_upper = validate_bond_market(market)?;
458    let req = bond_client_view::DaemonGetBondPositionListReq {
459        c2s: bond_client_view::daemon_get_bond_position_list_req::C2s {
460            header: bond_client_view::DaemonBondHeader {
461                acc_id,
462                trd_env: Some(parse_env_int(env)?),
463                market: market_upper,
464            },
465        },
466    };
467    let body = req.encode_to_vec();
468    let resp: bond_client_view::DaemonGetBondPositionListRsp = dispatch(
469        gateway,
470        "futucli-bond-position-list",
471        proto_id::TRD_GET_BOND_POSITION_LIST,
472        body,
473    )
474    .await?;
475    if resp.ret_type != 0 {
476        bail!(
477            "GetBondPositionList failed: ret_type={} ret_msg={:?}",
478            resp.ret_type,
479            resp.ret_msg
480        );
481    }
482    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
483    Ok(())
484}
485
486/// `futucli bond-answer-state` — 是否需答题
487pub async fn run_bond_answer_state(
488    gateway: &str,
489    env: &str,
490    acc_id: u64,
491    market: &str,
492    symbol: &str,
493    format: OutputFormat,
494) -> Result<()> {
495    if acc_id == 0 {
496        bail!("--acc-id 必填");
497    }
498    if symbol.trim().is_empty() {
499        bail!("--symbol 必填 (债券 symbol, 类似 11000018)");
500    }
501    let market_upper = validate_bond_market(market)?;
502    let req = bond_client_view::DaemonGetBondAnswerStateReq {
503        c2s: bond_client_view::daemon_get_bond_answer_state_req::C2s {
504            header: bond_client_view::DaemonBondHeader {
505                acc_id,
506                trd_env: Some(parse_env_int(env)?),
507                market: market_upper,
508            },
509            symbol: symbol.to_string(),
510        },
511    };
512    let body = req.encode_to_vec();
513    let resp: bond_client_view::DaemonGetBondAnswerStateRsp = dispatch(
514        gateway,
515        "futucli-bond-answer-state",
516        proto_id::TRD_GET_BOND_ANSWER_STATE,
517        body,
518    )
519    .await?;
520    if resp.ret_type != 0 {
521        bail!(
522            "GetBondAnswerState failed: ret_type={} ret_msg={:?}",
523            resp.ret_type,
524            resp.ret_msg
525        );
526    }
527    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
528    Ok(())
529}
530
531/// `futucli bond-trade-reminder` — 交易提醒
532pub async fn run_bond_trade_reminder(
533    gateway: &str,
534    env: &str,
535    acc_id: u64,
536    market: &str,
537    symbol: &str,
538    format: OutputFormat,
539) -> Result<()> {
540    if acc_id == 0 {
541        bail!("--acc-id 必填");
542    }
543    if symbol.trim().is_empty() {
544        bail!("--symbol 必填");
545    }
546    let market_upper = validate_bond_market(market)?;
547    let req = bond_client_view::DaemonGetBondTradeReminderReq {
548        c2s: bond_client_view::daemon_get_bond_trade_reminder_req::C2s {
549            header: bond_client_view::DaemonBondHeader {
550                acc_id,
551                trd_env: Some(parse_env_int(env)?),
552                market: market_upper,
553            },
554            symbol: symbol.to_string(),
555        },
556    };
557    let body = req.encode_to_vec();
558    let resp: bond_client_view::DaemonGetBondTradeReminderRsp = dispatch(
559        gateway,
560        "futucli-bond-trade-reminder",
561        proto_id::TRD_GET_BOND_TRADE_REMINDER,
562        body,
563    )
564    .await?;
565    if resp.ret_type != 0 {
566        bail!(
567            "GetBondTradeReminder failed: ret_type={} ret_msg={:?}",
568            resp.ret_type,
569            resp.ret_msg
570        );
571    }
572    print_json_pretty(format, &resp.s2c.and_then(|s| s.inner))?;
573    Ok(())
574}
575
576#[cfg(test)]
577mod tests;