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