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