Skip to main content

futucli/cmd/
account.rs

1//! `futucli account` / `position` / `order` / `deal` — 只读账户查询
2//!
3//! 本模块只承载账户/持仓/订单/成交查询;下单、改单、撤单等写命令在
4//! `trade_ext.rs`,并由 CLI 层的 `--confirm` / env / scope guard 控制误操作风险。
5
6use anyhow::{Result, bail};
7use serde::Serialize;
8use tabled::Tabled;
9
10use crate::common::connect_gateway;
11use crate::output::OutputFormat;
12use futu_trd::{
13    currency, read_plan,
14    types::{TrdEnv, TrdHeader, TrdMarket},
15};
16
17mod list;
18#[cfg(test)]
19mod tests;
20
21#[cfg(test)]
22use list::{
23    AccJson, account_matches_sdk_filter, app_visible_card_num_resolution,
24    parse_account_market_filter, parse_account_security_firm_filter,
25};
26pub use list::{list_accounts, resolve_account_locator};
27
28// ========== 共享:参数解析 ==========
29
30/// v1.4.102 codex 27 F7 (P1) fix: write path 专用 parser, 显式拒 fund market.
31///
32/// `place-order` / `modify-order` / `cancel-order` / `cancel-all-order` 等
33/// CLI 写命令应用此 fn (不直接用 `parse_trd_market`). fund markets
34/// 仅 view-only read endpoints 支持. read 命令 (`positions` / `funds` /
35/// `cash-log` 等) 仍用 `parse_trd_market`.
36pub fn parse_trd_market_for_write(s: &str) -> Result<TrdMarket> {
37    let m = parse_trd_market(s)?;
38    if let Some(label) = futu_trd::market::canonical_fund_trd_market_label(m) {
39        bail!(
40            "trd market {label} 仅支持 view-only read commands \
41             (positions/funds/cash-log/history-orders/history-fills); \
42             write commands (place-order/modify-order/cancel-order/cancel-all-order) \
43             用对应主市场, daemon 自动按持仓 broker 路由. v1.4.102 audit 27 F7 fix"
44        )
45    }
46    Ok(m)
47}
48
49pub fn parse_trd_market(s: &str) -> Result<TrdMarket> {
50    futu_trd::market::parse_trd_market(s).ok_or_else(|| {
51        anyhow::anyhow!(
52            "unknown trd market {:?} ({})",
53            s.trim().to_ascii_uppercase(),
54            futu_trd::market::TRD_MARKET_PARSE_CHOICES
55        )
56    })
57}
58
59pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
60    futu_trd::parsing::parse_trd_env(s).ok_or_else(|| {
61        anyhow::anyhow!(
62            "unknown trd env {:?} ({})",
63            s.trim().to_ascii_lowercase(),
64            futu_trd::parsing::TRD_ENV_PARSE_CHOICES
65        )
66    })
67}
68
69fn build_header(env: TrdEnv, acc_id: u64, market: TrdMarket) -> TrdHeader {
70    TrdHeader {
71        trd_env: env,
72        acc_id,
73        trd_market: market,
74        jp_acc_type: None,
75    }
76}
77
78fn format_pl_ratio_percent(ratio_value: f64) -> String {
79    // Gateway/JSON keep C++ APIServer numeric `Position.plRatio` unchanged.
80    // CLI shows the user-facing percent form: `0.6078` -> `+60.78%`.
81    let percent = ratio_value * 100.0;
82    if percent > 0.0 {
83        format!("+{percent:.2}%")
84    } else {
85        format!("{percent:.2}%")
86    }
87}
88
89// ========== account funds ==========
90
91#[derive(Tabled)]
92struct FundsRow {
93    #[tabled(rename = "Metric")]
94    name: &'static str,
95    #[tabled(rename = "Value")]
96    value: String,
97}
98
99#[derive(Serialize)]
100struct FundsJson {
101    power: f64,
102    total_assets: f64,
103    cash: f64,
104    market_val: f64,
105    frozen_cash: f64,
106    debt_cash: f64,
107    avl_withdrawal_cash: f64,
108    #[serde(skip_serializing_if = "Option::is_none")]
109    crypto_mv: Option<f64>,
110    #[serde(skip_serializing_if = "Option::is_none")]
111    exposure_level: Option<i32>,
112    #[serde(skip_serializing_if = "Option::is_none")]
113    exposure_limit: Option<f64>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    used_limit: Option<f64>,
116    #[serde(skip_serializing_if = "Option::is_none")]
117    remaining_limit: Option<f64>,
118    /// v1.4.96 BUG #012 hotfix (external reviewer double-tester report 2026-04-26):
119    /// 账户主币种 (HKD / USD / CNH / 等), 之前 CLI 漏打印, 与 REST `/api/funds` +
120    /// MCP `futu_get_funds` 3-surface 不一致.
121    #[serde(skip_serializing_if = "Option::is_none")]
122    currency: Option<&'static str>,
123    /// v1.4.103 (external reviewer 反馈 P1): 综合账户多币种 cash 细分.
124    /// `[{currency: "USD", cash: 208532.79, available_balance: ..., net_cash_power: ...}, ...]`
125    #[serde(skip_serializing_if = "Vec::is_empty")]
126    cash_info_list: Vec<CashInfoJson>,
127    /// v1.4.103 (external reviewer 反馈 P1): 综合账户多市场 assets 细分.
128    /// `[{market: "US", assets: 8151509.8}, ...]`
129    #[serde(skip_serializing_if = "Vec::is_empty")]
130    market_info_list: Vec<MarketInfoJson>,
131    /// 用户显式传 currency, 但 backend 按账户基准币种返回时的提示。
132    #[serde(skip_serializing_if = "Option::is_none")]
133    currency_warning: Option<String>,
134}
135
136/// v1.4.103 (external reviewer P1): 单币种 cash detail (for cash_info_list).
137#[derive(Serialize)]
138struct CashInfoJson {
139    currency: &'static str,
140    cash: f64,
141    available_balance: f64,
142    net_cash_power: f64,
143}
144
145/// v1.4.103 (external reviewer P1): 单市场 assets detail (for market_info_list).
146#[derive(Serialize)]
147struct MarketInfoJson {
148    market: &'static str,
149    assets: f64,
150}
151
152/// v1.4.103: trd_market enum int → 字符串.
153fn trd_market_int_to_str(m: Option<i32>) -> &'static str {
154    m.and_then(futu_trd::market::trd_market_label)
155        .unwrap_or("?")
156}
157
158pub async fn funds(
159    gateway: &str,
160    env: &str,
161    acc_id: u64,
162    market: Option<&str>,
163    currency: Option<&str>,
164    format: OutputFormat,
165) -> Result<()> {
166    // v1.4.106 ergonomics: --market 改 optional. 不传时 trd_market 设
167    // `TrdMarket::Unknown=0`, daemon `GetFundsHandler` 按 `acc_id` cache 推断
168    // (lookup `acc.trd_market` 作 currency derive 兜底; 不依赖 header.trd_market
169    // 做主路由 filter, 见 `crates/futu-gateway-trd/src/handlers/trd/query.rs:88+`).
170    // 普通账户 (HK-only/US-only) 主市场由 cache.acc_entry.trd_market 决定;
171    // 综合账户 (uniCardNum 非空) acc_id 路径已经 cross-market view, market
172    // 参数对结果无影响.
173    let trd_market = match market {
174        Some(m) => parse_trd_market(m)?,
175        None => TrdMarket::Unknown,
176    };
177    let header = build_header(parse_trd_env(env)?, acc_id, trd_market);
178    let (client, _push_rx) = connect_gateway(gateway, "futucli-funds").await?;
179
180    // v1.4.103 (external reviewer P1): parse currency if provided, pass to backend.
181    let currency_int: Option<i32> = match currency {
182        Some(s) => Some(currency::parse_currency_label(s)?),
183        None => None,
184    };
185
186    let f = futu_trd::account::get_funds_with_currency(&client, &header, currency_int).await?;
187
188    // FX sanity check lives in the shared trade-read domain; CLI only decides
189    // whether to print it on stderr and include it in JSON output.
190    let currency_warning = read_plan::funds_currency_mismatch_warning(currency_int, f.currency);
191    if let Some(ref warn) = currency_warning {
192        eprintln!("⚠️  {warn}");
193    }
194
195    // v1.4.96 BUG #012: 显示账户主币种 (与 REST/MCP 对齐)
196    let currency = currency::known_currency_label(f.currency);
197    // v1.4.106 codex 1612 Candidate A: `Cash` label 太泛, 用户误把
198    // top-level summary cash 当作"所有 cash_info_list 跨币种相加".
199    // 改 `CashSummary(<cur>)` 让 user 知道这是 backend 直传的单一币种
200    // summary, 不等于 cash_info_list.sum() (跨币种不能无汇率相加). backend
201    // 未下 top-level currency 时不伪造 `?` 标签,避免让用户误以为整张表
202    // 已有明确币种。
203    let cash_summary_label: String = currency
204        .map(|cur| format!("CashSummary({cur})"))
205        .unwrap_or_else(|| "CashSummary".to_string());
206    let mut rows = vec![
207        FundsRow {
208            name: "Power",
209            value: format!("{:.2}", f.power),
210        },
211        FundsRow {
212            name: "TotalAssets",
213            value: format!("{:.2}", f.total_assets),
214        },
215        FundsRow {
216            name: Box::leak(cash_summary_label.into_boxed_str()),
217            value: format!("{:.2}", f.cash),
218        },
219        FundsRow {
220            name: "MarketVal",
221            value: format!("{:.2}", f.market_val),
222        },
223        FundsRow {
224            name: "FrozenCash",
225            value: format!("{:.2}", f.frozen_cash),
226        },
227        FundsRow {
228            name: "DebtCash",
229            value: format!("{:.2}", f.debt_cash),
230        },
231        FundsRow {
232            name: "AvlWithdrawalCash",
233            value: format!("{:.2}", f.avl_withdrawal_cash),
234        },
235    ];
236    // v1.4.96 BUG #012: 加 Currency 列 (-) 当 backend 未返时
237    rows.push(FundsRow {
238        name: "Currency",
239        value: currency
240            .map(|s| s.to_string())
241            .unwrap_or_else(|| "-".into()),
242    });
243    if let Some(value) = f.crypto_mv {
244        rows.push(FundsRow {
245            name: "CryptoMv",
246            value: format!("{value:.2}"),
247        });
248    }
249    if let Some(value) = f.exposure_level {
250        rows.push(FundsRow {
251            name: "ExposureLevel",
252            value: value.to_string(),
253        });
254    }
255    if let Some(value) = f.exposure_limit {
256        rows.push(FundsRow {
257            name: "ExposureLimit",
258            value: format!("{value:.2}"),
259        });
260    }
261    if let Some(value) = f.used_limit {
262        rows.push(FundsRow {
263            name: "UsedLimit",
264            value: format!("{value:.2}"),
265        });
266    }
267    if let Some(value) = f.remaining_limit {
268        rows.push(FundsRow {
269            name: "RemainingLimit",
270            value: format!("{value:.2}"),
271        });
272    }
273
274    // v1.4.103 (external reviewer 反馈 P1): 综合账户多币种 / 多市场细分.
275    // 当 cash_info_list / market_info_list 非空 (综合账户) 时, 展开 sub-rows
276    // 让用户看到细分数据 — 之前只显示 7 字段 top-level, 综合账户用户根本不知
277    // 道 USD market 下面有 208K USD cash / 1.05M USD assets.
278    if !f.cash_info_list.is_empty() {
279        rows.push(FundsRow {
280            name: "── CashByCurrency ──",
281            value: String::new(),
282        });
283        for ci in &f.cash_info_list {
284            let cur_str = currency::known_currency_label(ci.currency).unwrap_or("?");
285            rows.push(FundsRow {
286                name: Box::leak(format!("  {} cash", cur_str).into_boxed_str()),
287                value: format!("{:.2}", ci.cash.unwrap_or(0.0)),
288            });
289            let ncp = ci.net_cash_power.unwrap_or(0.0);
290            if ncp.abs() > 0.001 {
291                rows.push(FundsRow {
292                    name: Box::leak(format!("  {} netCashPower", cur_str).into_boxed_str()),
293                    value: format!("{:.2}", ncp),
294                });
295            }
296        }
297    }
298    if !f.market_info_list.is_empty() {
299        rows.push(FundsRow {
300            name: "── AssetsByMarket ──",
301            value: String::new(),
302        });
303        for mi in &f.market_info_list {
304            // 只显示非零 assets (省得过长)
305            let assets = mi.assets.unwrap_or(0.0);
306            if assets.abs() < 0.001 {
307                continue;
308            }
309            let mkt_str = trd_market_int_to_str(mi.trd_market);
310            rows.push(FundsRow {
311                name: Box::leak(format!("  {} assets", mkt_str).into_boxed_str()),
312                value: format!("{:.2}", assets),
313            });
314        }
315    }
316
317    // v1.4.103 (external reviewer P1): JSON output 也含细分 list (与表格视图一致).
318    let cash_info_jsons: Vec<CashInfoJson> = f
319        .cash_info_list
320        .iter()
321        .map(|ci| CashInfoJson {
322            currency: currency::known_currency_label(ci.currency).unwrap_or("UNKNOWN"),
323            cash: ci.cash.unwrap_or(0.0),
324            available_balance: ci.available_balance.unwrap_or(0.0),
325            net_cash_power: ci.net_cash_power.unwrap_or(0.0),
326        })
327        .collect();
328    let market_info_jsons: Vec<MarketInfoJson> = f
329        .market_info_list
330        .iter()
331        .map(|mi| MarketInfoJson {
332            market: trd_market_int_to_str(mi.trd_market),
333            assets: mi.assets.unwrap_or(0.0),
334        })
335        .collect();
336    let jsons = vec![FundsJson {
337        power: f.power,
338        total_assets: f.total_assets,
339        cash: f.cash,
340        market_val: f.market_val,
341        frozen_cash: f.frozen_cash,
342        debt_cash: f.debt_cash,
343        avl_withdrawal_cash: f.avl_withdrawal_cash,
344        crypto_mv: f.crypto_mv,
345        exposure_level: f.exposure_level,
346        exposure_limit: f.exposure_limit,
347        used_limit: f.used_limit,
348        remaining_limit: f.remaining_limit,
349        currency,
350        cash_info_list: cash_info_jsons,
351        market_info_list: market_info_jsons,
352        currency_warning,
353    }];
354
355    format.print_rows(&rows, &jsons)?;
356    Ok(())
357}
358
359// ========== position list ==========
360
361#[derive(Tabled)]
362struct PosRow {
363    #[tabled(rename = "Code")]
364    code: String,
365    #[tabled(rename = "Name")]
366    name: String,
367    #[tabled(rename = "Qty")]
368    qty: String,
369    #[tabled(rename = "Sellable")]
370    sellable: String,
371    #[tabled(rename = "Cost")]
372    cost: String,
373    #[tabled(rename = "Price")]
374    price: String,
375    #[tabled(rename = "Val")]
376    val: String,
377    #[tabled(rename = "PL")]
378    pl: String,
379    #[tabled(rename = "PL%")]
380    pl_pct: String,
381}
382
383#[derive(Serialize)]
384struct PosJson {
385    position_id: u64,
386    position_side: i32,
387    code: String,
388    name: String,
389    qty: f64,
390    can_sell_qty: f64,
391    price: f64,
392    cost_price: f64,
393    val: f64,
394    pl_val: f64,
395    pl_ratio: f64,
396}
397
398pub async fn positions(
399    gateway: &str,
400    env: &str,
401    acc_id: u64,
402    market: &str,
403    currency_arg: Option<&str>,
404    option_strategy_view: bool,
405    format: OutputFormat,
406) -> Result<()> {
407    let header = build_header(parse_trd_env(env)?, acc_id, parse_trd_market(market)?);
408    let (client, _push_rx) = connect_gateway(gateway, "futucli-position").await?;
409    let currency_int = match currency_arg {
410        Some(s) => Some(currency::parse_currency_label(s)?),
411        None => None,
412    };
413    let list = futu_trd::account::get_position_list_with_options(
414        &client,
415        &header,
416        futu_trd::account::PositionListOptions {
417            filter_market: Some(header.trd_market as i32),
418            currency: currency_int,
419            option_strategy_view: option_strategy_view.then_some(true),
420        },
421    )
422    .await?;
423
424    let rows: Vec<PosRow> = list
425        .iter()
426        .map(|p| PosRow {
427            code: p.code.clone(),
428            name: p.name.clone(),
429            qty: format!("{:.0}", p.qty),
430            sellable: format!("{:.0}", p.can_sell_qty),
431            cost: format!("{:.3}", p.cost_price),
432            price: format!("{:.3}", p.price),
433            val: format!("{:.2}", p.val),
434            pl: format!("{:.2}", p.pl_val),
435            pl_pct: format_pl_ratio_percent(p.pl_ratio),
436        })
437        .collect();
438
439    let jsons: Vec<PosJson> = list
440        .iter()
441        .map(|p| PosJson {
442            position_id: p.position_id,
443            position_side: p.position_side,
444            code: p.code.clone(),
445            name: p.name.clone(),
446            qty: p.qty,
447            can_sell_qty: p.can_sell_qty,
448            price: p.price,
449            cost_price: p.cost_price,
450            val: p.val,
451            pl_val: p.pl_val,
452            pl_ratio: p.pl_ratio,
453        })
454        .collect();
455
456    format.print_rows(&rows, &jsons)?;
457    Ok(())
458}
459
460// ========== order list ==========
461
462#[derive(Tabled)]
463struct OrderRow {
464    #[tabled(rename = "OrderID")]
465    order_id: String,
466    #[tabled(rename = "Code")]
467    code: String,
468    #[tabled(rename = "Side")]
469    side: String,
470    #[tabled(rename = "Type")]
471    order_type: i32,
472    #[tabled(rename = "Status")]
473    status: i32,
474    #[tabled(rename = "Qty")]
475    qty: String,
476    #[tabled(rename = "Price")]
477    price: String,
478    #[tabled(rename = "FillQty")]
479    fill_qty: String,
480    #[tabled(rename = "FillAvg")]
481    fill_avg: String,
482    #[tabled(rename = "Updated")]
483    update_time: String,
484}
485
486#[derive(Serialize)]
487struct OrderJson {
488    order_id: u64,
489    order_id_ex: String,
490    trd_side: i32,
491    order_type: i32,
492    order_status: i32,
493    code: String,
494    name: String,
495    qty: f64,
496    price: f64,
497    create_time: String,
498    update_time: String,
499    fill_qty: f64,
500    fill_avg_price: f64,
501    last_err_msg: String,
502}
503
504fn trd_side_label(d: i32) -> &'static str {
505    match d {
506        1 => "BUY",
507        2 => "SELL",
508        3 => "SELL_SHORT",
509        4 => "BUY_BACK",
510        _ => "?",
511    }
512}
513
514pub async fn orders(
515    gateway: &str,
516    env: &str,
517    acc_id: u64,
518    market: &str,
519    format: OutputFormat,
520) -> Result<()> {
521    let header = build_header(
522        parse_trd_env(env)?,
523        acc_id,
524        parse_trd_market_for_write(market)?,
525    );
526    let (client, _push_rx) = connect_gateway(gateway, "futucli-order").await?;
527    let list = futu_trd::query::get_order_list(&client, &header).await?;
528
529    let rows: Vec<OrderRow> = list
530        .iter()
531        .map(|o| OrderRow {
532            order_id: o.order_id.to_string(),
533            code: o.code.clone(),
534            side: trd_side_label(o.trd_side).to_string(),
535            order_type: o.order_type,
536            status: o.order_status,
537            qty: format!("{:.0}", o.qty),
538            price: format!("{:.3}", o.price),
539            fill_qty: format!("{:.0}", o.fill_qty),
540            fill_avg: format!("{:.3}", o.fill_avg_price),
541            update_time: o.update_time.clone(),
542        })
543        .collect();
544
545    let jsons: Vec<OrderJson> = list
546        .iter()
547        .map(|o| OrderJson {
548            order_id: o.order_id,
549            order_id_ex: o.order_id_ex.clone(),
550            trd_side: o.trd_side,
551            order_type: o.order_type,
552            order_status: o.order_status,
553            code: o.code.clone(),
554            name: o.name.clone(),
555            qty: o.qty,
556            price: o.price,
557            create_time: o.create_time.clone(),
558            update_time: o.update_time.clone(),
559            fill_qty: o.fill_qty,
560            fill_avg_price: o.fill_avg_price,
561            last_err_msg: o.last_err_msg.clone(),
562        })
563        .collect();
564
565    format.print_rows(&rows, &jsons)?;
566    Ok(())
567}
568
569// ========== deal (fill) list ==========
570
571#[derive(Tabled)]
572struct DealRow {
573    #[tabled(rename = "FillID")]
574    fill_id: String,
575    #[tabled(rename = "OrderID")]
576    order_id: String,
577    #[tabled(rename = "Code")]
578    code: String,
579    #[tabled(rename = "Side")]
580    side: String,
581    #[tabled(rename = "Qty")]
582    qty: String,
583    #[tabled(rename = "Price")]
584    price: String,
585    #[tabled(rename = "Time")]
586    time: String,
587}
588
589#[derive(Serialize)]
590struct DealJson {
591    fill_id: u64,
592    fill_id_ex: String,
593    order_id: u64,
594    trd_side: i32,
595    code: String,
596    name: String,
597    qty: f64,
598    price: f64,
599    create_time: String,
600}
601
602pub async fn deals(
603    gateway: &str,
604    env: &str,
605    acc_id: u64,
606    market: &str,
607    format: OutputFormat,
608) -> Result<()> {
609    let header = build_header(
610        parse_trd_env(env)?,
611        acc_id,
612        parse_trd_market_for_write(market)?,
613    );
614    let (client, _push_rx) = connect_gateway(gateway, "futucli-deal").await?;
615    let list = futu_trd::query::get_order_fill_list(&client, &header).await?;
616
617    let rows: Vec<DealRow> = list
618        .iter()
619        .map(|f| DealRow {
620            fill_id: f.fill_id.to_string(),
621            order_id: f.order_id.to_string(),
622            code: f.code.clone(),
623            side: trd_side_label(f.trd_side).to_string(),
624            qty: format!("{:.0}", f.qty),
625            price: format!("{:.3}", f.price),
626            time: f.create_time.clone(),
627        })
628        .collect();
629
630    let jsons: Vec<DealJson> = list
631        .iter()
632        .map(|f| DealJson {
633            fill_id: f.fill_id,
634            fill_id_ex: f.fill_id_ex.clone(),
635            order_id: f.order_id,
636            trd_side: f.trd_side,
637            code: f.code.clone(),
638            name: f.name.clone(),
639            qty: f.qty,
640            price: f.price,
641            create_time: f.create_time.clone(),
642        })
643        .collect();
644
645    format.print_rows(&rows, &jsons)?;
646    Ok(())
647}