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 market 113/123
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    match m {
39        TrdMarket::HKFund => bail!(
40            "trd market HKFUND (113) 仅支持 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             用主市场 HK, daemon 自动按持仓 broker 路由. v1.4.102 codex 27 F7 fix"
44        ),
45        TrdMarket::USFund => bail!(
46            "trd market USFUND (123) 仅支持 view-only read commands; \
47             write commands 用主市场 US. v1.4.102 codex 27 F7 fix"
48        ),
49        _ => Ok(m),
50    }
51}
52
53pub fn parse_trd_market(s: &str) -> Result<TrdMarket> {
54    // v1.4.93 BUG-001 fix (S level ship-blocker): 9 variants 对齐
55    // `Trd_Common.proto::TrdMarket` + MCP schema. 也接 int 值 (per Trd_Common.proto).
56    // 5 国 (SG/AU/JP/MY/CA) + Futures=5 在 v1.4.86-90 只 4 variants 时挂.
57    //
58    // v1.4.102 fund-market handoff (per pitfall #54 schema-runtime-parser sync):
59    // 加 HKFUND=113 / USFUND=123 view-only 融资融券 / 基金账户.
60    let trimmed = s.trim();
61    let upper = trimmed.to_ascii_uppercase();
62    let m = match upper.as_str() {
63        "HK" | "1" => TrdMarket::HK,
64        "US" | "2" => TrdMarket::US,
65        "CN" | "3" => TrdMarket::CN,
66        "HKCC" | "4" => TrdMarket::HKCC,
67        "FUTURES" | "5" => TrdMarket::Futures,
68        "SG" | "6" => TrdMarket::SG,
69        "AU" | "8" => TrdMarket::AU,
70        "JP" | "15" => TrdMarket::JP,
71        "MY" | "111" => TrdMarket::MY,
72        "CA" | "112" => TrdMarket::CA,
73        "HKFUND" | "HK_FUND" | "113" => TrdMarket::HKFund,
74        "USFUND" | "US_FUND" | "123" => TrdMarket::USFund,
75        other => bail!(
76            "unknown trd market {other:?} \
77             (HK|US|CN|HKCC|FUTURES|SG|AU|JP|MY|CA|HKFUND|USFUND \
78             or int 1/2/3/4/5/6/8/15/111/112/113/123 per Trd_Common.proto)"
79        ),
80    };
81    Ok(m)
82}
83
84pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
85    let e = match s.trim().to_ascii_lowercase().as_str() {
86        "simulate" | "sim" => TrdEnv::Simulate,
87        "real" => TrdEnv::Real,
88        other => bail!("unknown trd env {other:?} (real|simulate)"),
89    };
90    Ok(e)
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 (eli 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 (deger 反馈 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 (deger 反馈 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 (deger 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 (deger 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(futu_trd::market::trd_market_label)
179        .unwrap_or("?")
180}
181
182pub async fn funds(
183    gateway: &str,
184    env: &str,
185    acc_id: u64,
186    market: Option<&str>,
187    currency: Option<&str>,
188    format: OutputFormat,
189) -> Result<()> {
190    // v1.4.106 ergonomics: --market 改 optional. 不传时 trd_market 设
191    // `TrdMarket::Unknown=0`, daemon `GetFundsHandler` 按 `acc_id` cache 推断
192    // (lookup `acc.trd_market` 作 currency derive 兜底; 不依赖 header.trd_market
193    // 做主路由 filter, 见 `crates/futu-gateway-trd/src/handlers/trd/query.rs:88+`).
194    // 普通账户 (HK-only/US-only) 主市场由 cache.acc_entry.trd_market 决定;
195    // 综合账户 (uniCardNum 非空) acc_id 路径已经 cross-market view, market
196    // 参数对结果无影响.
197    let trd_market = match market {
198        Some(m) => parse_trd_market(m)?,
199        None => TrdMarket::Unknown,
200    };
201    let header = build_header(parse_trd_env(env)?, acc_id, trd_market);
202    let (client, _push_rx) = connect_gateway(gateway, "futucli-funds").await?;
203
204    // v1.4.103 (deger P1): parse currency if provided, pass to backend.
205    let currency_int: Option<i32> = match currency {
206        Some(s) => Some(currency::parse_currency_label(s)?),
207        None => None,
208    };
209
210    let f = futu_trd::account::get_funds_with_currency(&client, &header, currency_int).await?;
211
212    // FX sanity check lives in the shared trade-read domain; CLI only decides
213    // whether to print it on stderr and include it in JSON output.
214    let currency_warning = read_plan::funds_currency_mismatch_warning(currency_int, f.currency);
215    if let Some(ref warn) = currency_warning {
216        eprintln!("⚠️  {warn}");
217    }
218
219    // v1.4.96 BUG #012: 显示账户主币种 (与 REST/MCP 对齐)
220    let currency = currency::known_currency_label(f.currency);
221    // v1.4.106 codex 1612 Candidate A: `Cash` label 太泛, 用户误把
222    // top-level summary cash 当作"所有 cash_info_list 跨币种相加".
223    // 改 `CashSummary(<cur>)` 让 user 知道这是 backend 直传的单一币种
224    // summary, 不等于 cash_info_list.sum() (跨币种不能无汇率相加). backend
225    // 未下 top-level currency 时不伪造 `?` 标签,避免让用户误以为整张表
226    // 已有明确币种。
227    let cash_summary_label: String = currency
228        .map(|cur| format!("CashSummary({cur})"))
229        .unwrap_or_else(|| "CashSummary".to_string());
230    let mut rows = vec![
231        FundsRow {
232            name: "Power",
233            value: format!("{:.2}", f.power),
234        },
235        FundsRow {
236            name: "TotalAssets",
237            value: format!("{:.2}", f.total_assets),
238        },
239        FundsRow {
240            name: Box::leak(cash_summary_label.into_boxed_str()),
241            value: format!("{:.2}", f.cash),
242        },
243        FundsRow {
244            name: "MarketVal",
245            value: format!("{:.2}", f.market_val),
246        },
247        FundsRow {
248            name: "FrozenCash",
249            value: format!("{:.2}", f.frozen_cash),
250        },
251        FundsRow {
252            name: "DebtCash",
253            value: format!("{:.2}", f.debt_cash),
254        },
255        FundsRow {
256            name: "AvlWithdrawalCash",
257            value: format!("{:.2}", f.avl_withdrawal_cash),
258        },
259    ];
260    // v1.4.96 BUG #012: 加 Currency 列 (-) 当 backend 未返时
261    rows.push(FundsRow {
262        name: "Currency",
263        value: currency
264            .map(|s| s.to_string())
265            .unwrap_or_else(|| "-".into()),
266    });
267    if let Some(value) = f.crypto_mv {
268        rows.push(FundsRow {
269            name: "CryptoMv",
270            value: format!("{value:.2}"),
271        });
272    }
273    if let Some(value) = f.exposure_level {
274        rows.push(FundsRow {
275            name: "ExposureLevel",
276            value: value.to_string(),
277        });
278    }
279    if let Some(value) = f.exposure_limit {
280        rows.push(FundsRow {
281            name: "ExposureLimit",
282            value: format!("{value:.2}"),
283        });
284    }
285    if let Some(value) = f.used_limit {
286        rows.push(FundsRow {
287            name: "UsedLimit",
288            value: format!("{value:.2}"),
289        });
290    }
291    if let Some(value) = f.remaining_limit {
292        rows.push(FundsRow {
293            name: "RemainingLimit",
294            value: format!("{value:.2}"),
295        });
296    }
297
298    // v1.4.103 (deger 反馈 P1): 综合账户多币种 / 多市场细分.
299    // 当 cash_info_list / market_info_list 非空 (综合账户) 时, 展开 sub-rows
300    // 让用户看到细分数据 — 之前只显示 7 字段 top-level, 综合账户用户根本不知
301    // 道 USD market 下面有 208K USD cash / 1.05M USD assets.
302    if !f.cash_info_list.is_empty() {
303        rows.push(FundsRow {
304            name: "── CashByCurrency ──",
305            value: String::new(),
306        });
307        for ci in &f.cash_info_list {
308            let cur_str = currency::known_currency_label(ci.currency).unwrap_or("?");
309            rows.push(FundsRow {
310                name: Box::leak(format!("  {} cash", cur_str).into_boxed_str()),
311                value: format!("{:.2}", ci.cash.unwrap_or(0.0)),
312            });
313            let ncp = ci.net_cash_power.unwrap_or(0.0);
314            if ncp.abs() > 0.001 {
315                rows.push(FundsRow {
316                    name: Box::leak(format!("  {} netCashPower", cur_str).into_boxed_str()),
317                    value: format!("{:.2}", ncp),
318                });
319            }
320        }
321    }
322    if !f.market_info_list.is_empty() {
323        rows.push(FundsRow {
324            name: "── AssetsByMarket ──",
325            value: String::new(),
326        });
327        for mi in &f.market_info_list {
328            // 只显示非零 assets (省得过长)
329            let assets = mi.assets.unwrap_or(0.0);
330            if assets.abs() < 0.001 {
331                continue;
332            }
333            let mkt_str = trd_market_int_to_str(mi.trd_market);
334            rows.push(FundsRow {
335                name: Box::leak(format!("  {} assets", mkt_str).into_boxed_str()),
336                value: format!("{:.2}", assets),
337            });
338        }
339    }
340
341    // v1.4.103 (deger P1): JSON output 也含细分 list (与表格视图一致).
342    let cash_info_jsons: Vec<CashInfoJson> = f
343        .cash_info_list
344        .iter()
345        .map(|ci| CashInfoJson {
346            currency: currency::known_currency_label(ci.currency).unwrap_or("UNKNOWN"),
347            cash: ci.cash.unwrap_or(0.0),
348            available_balance: ci.available_balance.unwrap_or(0.0),
349            net_cash_power: ci.net_cash_power.unwrap_or(0.0),
350        })
351        .collect();
352    let market_info_jsons: Vec<MarketInfoJson> = f
353        .market_info_list
354        .iter()
355        .map(|mi| MarketInfoJson {
356            market: trd_market_int_to_str(mi.trd_market),
357            assets: mi.assets.unwrap_or(0.0),
358        })
359        .collect();
360    let jsons = vec![FundsJson {
361        power: f.power,
362        total_assets: f.total_assets,
363        cash: f.cash,
364        market_val: f.market_val,
365        frozen_cash: f.frozen_cash,
366        debt_cash: f.debt_cash,
367        avl_withdrawal_cash: f.avl_withdrawal_cash,
368        crypto_mv: f.crypto_mv,
369        exposure_level: f.exposure_level,
370        exposure_limit: f.exposure_limit,
371        used_limit: f.used_limit,
372        remaining_limit: f.remaining_limit,
373        currency,
374        cash_info_list: cash_info_jsons,
375        market_info_list: market_info_jsons,
376        currency_warning,
377    }];
378
379    format.print_rows(&rows, &jsons)?;
380    Ok(())
381}
382
383// ========== position list ==========
384
385#[derive(Tabled)]
386struct PosRow {
387    #[tabled(rename = "Code")]
388    code: String,
389    #[tabled(rename = "Name")]
390    name: String,
391    #[tabled(rename = "Qty")]
392    qty: String,
393    #[tabled(rename = "Sellable")]
394    sellable: String,
395    #[tabled(rename = "Cost")]
396    cost: String,
397    #[tabled(rename = "Price")]
398    price: String,
399    #[tabled(rename = "Val")]
400    val: String,
401    #[tabled(rename = "PL")]
402    pl: String,
403    #[tabled(rename = "PL%")]
404    pl_pct: String,
405}
406
407#[derive(Serialize)]
408struct PosJson {
409    position_id: u64,
410    position_side: i32,
411    code: String,
412    name: String,
413    qty: f64,
414    can_sell_qty: f64,
415    price: f64,
416    cost_price: f64,
417    val: f64,
418    pl_val: f64,
419    pl_ratio: f64,
420}
421
422pub async fn positions(
423    gateway: &str,
424    env: &str,
425    acc_id: u64,
426    market: &str,
427    format: OutputFormat,
428) -> Result<()> {
429    let header = build_header(parse_trd_env(env)?, acc_id, parse_trd_market(market)?);
430    let (client, _push_rx) = connect_gateway(gateway, "futucli-position").await?;
431    let list = futu_trd::account::get_position_list_with_filter_market(
432        &client,
433        &header,
434        Some(header.trd_market as i32),
435    )
436    .await?;
437
438    let rows: Vec<PosRow> = list
439        .iter()
440        .map(|p| PosRow {
441            code: p.code.clone(),
442            name: p.name.clone(),
443            qty: format!("{:.0}", p.qty),
444            sellable: format!("{:.0}", p.can_sell_qty),
445            cost: format!("{:.3}", p.cost_price),
446            price: format!("{:.3}", p.price),
447            val: format!("{:.2}", p.val),
448            pl: format!("{:.2}", p.pl_val),
449            pl_pct: format_pl_ratio_percent(p.pl_ratio),
450        })
451        .collect();
452
453    let jsons: Vec<PosJson> = list
454        .iter()
455        .map(|p| PosJson {
456            position_id: p.position_id,
457            position_side: p.position_side,
458            code: p.code.clone(),
459            name: p.name.clone(),
460            qty: p.qty,
461            can_sell_qty: p.can_sell_qty,
462            price: p.price,
463            cost_price: p.cost_price,
464            val: p.val,
465            pl_val: p.pl_val,
466            pl_ratio: p.pl_ratio,
467        })
468        .collect();
469
470    format.print_rows(&rows, &jsons)?;
471    Ok(())
472}
473
474// ========== order list ==========
475
476#[derive(Tabled)]
477struct OrderRow {
478    #[tabled(rename = "OrderID")]
479    order_id: String,
480    #[tabled(rename = "Code")]
481    code: String,
482    #[tabled(rename = "Side")]
483    side: String,
484    #[tabled(rename = "Type")]
485    order_type: i32,
486    #[tabled(rename = "Status")]
487    status: i32,
488    #[tabled(rename = "Qty")]
489    qty: String,
490    #[tabled(rename = "Price")]
491    price: String,
492    #[tabled(rename = "FillQty")]
493    fill_qty: String,
494    #[tabled(rename = "FillAvg")]
495    fill_avg: String,
496    #[tabled(rename = "Updated")]
497    update_time: String,
498}
499
500#[derive(Serialize)]
501struct OrderJson {
502    order_id: u64,
503    order_id_ex: String,
504    trd_side: i32,
505    order_type: i32,
506    order_status: i32,
507    code: String,
508    name: String,
509    qty: f64,
510    price: f64,
511    create_time: String,
512    update_time: String,
513    fill_qty: f64,
514    fill_avg_price: f64,
515    last_err_msg: String,
516}
517
518fn trd_side_label(d: i32) -> &'static str {
519    match d {
520        1 => "BUY",
521        2 => "SELL",
522        3 => "SELL_SHORT",
523        4 => "BUY_BACK",
524        _ => "?",
525    }
526}
527
528pub async fn orders(
529    gateway: &str,
530    env: &str,
531    acc_id: u64,
532    market: &str,
533    format: OutputFormat,
534) -> Result<()> {
535    let header = build_header(
536        parse_trd_env(env)?,
537        acc_id,
538        parse_trd_market_for_write(market)?,
539    );
540    let (client, _push_rx) = connect_gateway(gateway, "futucli-order").await?;
541    let list = futu_trd::query::get_order_list(&client, &header).await?;
542
543    let rows: Vec<OrderRow> = list
544        .iter()
545        .map(|o| OrderRow {
546            order_id: o.order_id.to_string(),
547            code: o.code.clone(),
548            side: trd_side_label(o.trd_side).to_string(),
549            order_type: o.order_type,
550            status: o.order_status,
551            qty: format!("{:.0}", o.qty),
552            price: format!("{:.3}", o.price),
553            fill_qty: format!("{:.0}", o.fill_qty),
554            fill_avg: format!("{:.3}", o.fill_avg_price),
555            update_time: o.update_time.clone(),
556        })
557        .collect();
558
559    let jsons: Vec<OrderJson> = list
560        .iter()
561        .map(|o| OrderJson {
562            order_id: o.order_id,
563            order_id_ex: o.order_id_ex.clone(),
564            trd_side: o.trd_side,
565            order_type: o.order_type,
566            order_status: o.order_status,
567            code: o.code.clone(),
568            name: o.name.clone(),
569            qty: o.qty,
570            price: o.price,
571            create_time: o.create_time.clone(),
572            update_time: o.update_time.clone(),
573            fill_qty: o.fill_qty,
574            fill_avg_price: o.fill_avg_price,
575            last_err_msg: o.last_err_msg.clone(),
576        })
577        .collect();
578
579    format.print_rows(&rows, &jsons)?;
580    Ok(())
581}
582
583// ========== deal (fill) list ==========
584
585#[derive(Tabled)]
586struct DealRow {
587    #[tabled(rename = "FillID")]
588    fill_id: String,
589    #[tabled(rename = "OrderID")]
590    order_id: String,
591    #[tabled(rename = "Code")]
592    code: String,
593    #[tabled(rename = "Side")]
594    side: String,
595    #[tabled(rename = "Qty")]
596    qty: String,
597    #[tabled(rename = "Price")]
598    price: String,
599    #[tabled(rename = "Time")]
600    time: String,
601}
602
603#[derive(Serialize)]
604struct DealJson {
605    fill_id: u64,
606    fill_id_ex: String,
607    order_id: u64,
608    trd_side: i32,
609    code: String,
610    name: String,
611    qty: f64,
612    price: f64,
613    create_time: String,
614}
615
616pub async fn deals(
617    gateway: &str,
618    env: &str,
619    acc_id: u64,
620    market: &str,
621    format: OutputFormat,
622) -> Result<()> {
623    let header = build_header(
624        parse_trd_env(env)?,
625        acc_id,
626        parse_trd_market_for_write(market)?,
627    );
628    let (client, _push_rx) = connect_gateway(gateway, "futucli-deal").await?;
629    let list = futu_trd::query::get_order_fill_list(&client, &header).await?;
630
631    let rows: Vec<DealRow> = list
632        .iter()
633        .map(|f| DealRow {
634            fill_id: f.fill_id.to_string(),
635            order_id: f.order_id.to_string(),
636            code: f.code.clone(),
637            side: trd_side_label(f.trd_side).to_string(),
638            qty: format!("{:.0}", f.qty),
639            price: format!("{:.3}", f.price),
640            time: f.create_time.clone(),
641        })
642        .collect();
643
644    let jsons: Vec<DealJson> = list
645        .iter()
646        .map(|f| DealJson {
647            fill_id: f.fill_id,
648            fill_id_ex: f.fill_id_ex.clone(),
649            order_id: f.order_id,
650            trd_side: f.trd_side,
651            code: f.code.clone(),
652            name: f.name.clone(),
653            qty: f.qty,
654            price: f.price,
655            create_time: f.create_time.clone(),
656        })
657        .collect();
658
659    format.print_rows(&rows, &jsons)?;
660    Ok(())
661}