Skip to main content

futucli/cmd/account/
list.rs

1use anyhow::{Result, bail};
2use serde::Serialize;
3use tabled::{Table, Tabled, settings::Style};
4
5use crate::cmd::account_view::{
6    acc_role_label, acc_status_label, acc_type_label, account_display_label,
7    account_market_list_label, display_security_firm_label, env_label, market_label,
8    security_firm_label, visible_card_num,
9};
10use crate::common::connect_gateway;
11use crate::output::OutputFormat;
12use futu_core::account_locator;
13
14use super::parse_trd_market;
15
16#[derive(Clone, Tabled)]
17struct AccRow {
18    #[tabled(rename = "Acc ID")]
19    acc_id: String,
20    #[tabled(rename = "Card Num")]
21    card: String,
22    #[tabled(rename = "Env")]
23    env: String,
24    #[tabled(rename = "Broker")]
25    broker: String,
26    #[tabled(rename = "Type")]
27    acc_type: String,
28    #[tabled(rename = "Status")]
29    status: String,
30    #[tabled(rename = "Label")]
31    label: String,
32    #[tabled(rename = "Markets")]
33    markets: String,
34}
35
36#[derive(Tabled)]
37struct AccGroupedRow {
38    #[tabled(rename = "Acc ID")]
39    acc_id: String,
40    #[tabled(rename = "Card Num")]
41    card: String,
42    #[tabled(rename = "Env")]
43    env: String,
44    #[tabled(rename = "Type")]
45    acc_type: String,
46    #[tabled(rename = "Status")]
47    status: String,
48    #[tabled(rename = "Label")]
49    label: String,
50    #[tabled(rename = "Markets")]
51    markets: String,
52}
53
54#[derive(Serialize)]
55pub(super) struct AccJson {
56    /// Keep account ids as strings in machine-readable CLI output.
57    ///
58    /// FTAPI uses uint64 account ids, but many downstream JSON consumers
59    /// (browser devtools, spreadsheet importers, JS scripts) round integers
60    /// above 2^53. The table output already uses `to_string()`; JSON follows
61    /// the same lossless presentation here.
62    pub(super) acc_id: String,
63    pub(super) trd_env: i32,
64    pub(super) env_label: &'static str,
65    pub(super) trd_market_auth_list: Vec<i32>,
66    pub(super) trd_market_auth_labels: Vec<&'static str>,
67    pub(super) acc_type: Option<i32>,
68    pub(super) acc_type_label: Option<&'static str>,
69    pub(super) card_num: Option<String>,
70    pub(super) security_firm: Option<i32>,
71    pub(super) security_firm_label: Option<&'static str>,
72    pub(super) sim_acc_type: Option<i32>,
73    pub(super) acc_status: Option<i32>,
74    pub(super) acc_status_label: Option<&'static str>,
75    pub(super) acc_role: Option<i32>,
76    pub(super) acc_role_label: Option<&'static str>,
77    pub(super) acc_label: Option<String>,
78    pub(super) acc_label_label: Option<String>,
79    pub(super) competition_acc_name: Option<String>,
80    pub(super) jp_acc_type: Vec<i32>,
81}
82
83pub(super) fn app_visible_card_num_resolution(
84    accs: &[futu_trd::account::TrdAcc],
85    card_num: &str,
86) -> Result<account_locator::CardNumResolution> {
87    Ok(account_locator::resolve_card_num_in_records(
88        accs, card_num, None,
89    )?)
90}
91
92pub async fn resolve_account_locator(
93    gateway: &str,
94    acc_id: Option<u64>,
95    card_num: Option<&str>,
96    command: &str,
97) -> Result<u64> {
98    let Some(card_num) = card_num else {
99        return acc_id.ok_or_else(|| {
100            anyhow::anyhow!("{command}: 需要 --acc-id <ACC_ID> 或 --card-num <CARD_NUM>")
101        });
102    };
103
104    let (client, _push_rx) = connect_gateway(gateway, "futucli-card-num-resolve").await?;
105    let raw_accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
106    let app_visible_accs = futu_trd::account::app_visible_accounts(raw_accs);
107    let resolution = app_visible_card_num_resolution(&app_visible_accs, card_num)?;
108
109    let resolved = match resolution {
110        account_locator::CardNumResolution::NotFound => bail!(
111            "{command}: card_num 在 App 可见账户集合中找不到。请运行 `futucli account` \
112             确认 Card Num,或改用 `--acc-id`;排障时可用 `futucli account --all` 查看 raw discovery"
113        ),
114        account_locator::CardNumResolution::Resolved(only) => only,
115        account_locator::CardNumResolution::Ambiguous(many) => bail!(
116            "{command}: card_num 匹配 {} 个账户 ({}),请改用 16 位完整卡号或 `--acc-id`",
117            many.len(),
118            many.iter()
119                .map(u64::to_string)
120                .collect::<Vec<_>>()
121                .join(", ")
122        ),
123    };
124
125    if let Some(explicit) = acc_id
126        && explicit != resolved
127    {
128        bail!(
129            "{command}: --acc-id ({explicit}) 与 --card-num 解析结果 ({resolved}) 不一致;\
130             请只传一个,或确认它们指向同一账户"
131        );
132    }
133
134    Ok(resolved)
135}
136
137pub(super) fn parse_account_market_filter(s: &str) -> Result<Option<i32>> {
138    match s.trim().to_ascii_lowercase().as_str() {
139        "" | "all" | "*" | "none" => Ok(None),
140        _ => Ok(Some(parse_trd_market(s)? as i32)),
141    }
142}
143
144pub(super) fn parse_account_security_firm_filter(s: &str) -> Result<Option<i32>> {
145    let normalized = s.trim().to_ascii_lowercase().replace(['_', '-'], "");
146    let firm = match normalized.as_str() {
147        "" | "all" | "*" | "none" => return Ok(None),
148        "futuhk" | "futusecurities" | "hk" | "1" => 1,
149        "futuinc" | "futuus" | "us" | "moomoo" | "mm" | "2" => 2,
150        "futusg" | "sg" | "3" => 3,
151        "futuau" | "au" | "4" => 4,
152        "futuca" | "ca" | "5" => 5,
153        "futumy" | "my" | "6" => 6,
154        "futujp" | "jp" | "7" => 7,
155        other => bail!(
156            "unknown security firm {other:?} \
157             (FutuHK|FutuInc|FutuUS|FutuSG|FutuAU|FutuCA|FutuMY|FutuJP|hk|us|sg|au|ca|my|jp|1..7|all)"
158        ),
159    };
160    Ok(Some(firm))
161}
162
163pub(super) fn account_matches_sdk_filter(
164    a: &futu_trd::account::TrdAcc,
165    market_filter: Option<i32>,
166    security_firm_filter: Option<i32>,
167) -> bool {
168    // Official Python SDK applies `filter_trdmarket` and `security_firm`
169    // locally after Trd_GetAccList. Sim accounts are environment-level demo
170    // rows and may carry security_firm=0/None, but SDK HK/US account panels
171    // still keep the matching sim market rows. Therefore broker filtering is
172    // only meaningful for real rows; market filtering remains mandatory when
173    // requested.
174    let market_ok = match market_filter {
175        Some(market) => a.trd_market_auth_list.contains(&market),
176        None => true,
177    };
178    let firm_ok = match (security_firm_filter, a.trd_env, a.security_firm) {
179        (Some(_), env, _) if env != 1 => true,
180        (Some(expected), _, Some(actual)) => actual == expected,
181        (Some(_), _, None) => true,
182        (None, _, _) => true,
183    };
184    market_ok && firm_ok
185}
186
187fn acc_group_label(row: &AccRow) -> &'static str {
188    if row.env == "simulate" {
189        "模拟账户"
190    } else if row.status == "active" {
191        "真实账户"
192    } else {
193        "已禁用账户"
194    }
195}
196
197fn print_account_grouped_tables(rows: &[AccRow]) -> std::io::Result<()> {
198    if rows.is_empty() {
199        println!("(empty)");
200        return Ok(());
201    }
202
203    let mut broker_order: Vec<&str> = Vec::new();
204    for row in rows {
205        if !broker_order.iter().any(|b| *b == row.broker) {
206            broker_order.push(&row.broker);
207        }
208    }
209
210    for (broker_idx, broker) in broker_order.iter().enumerate() {
211        if broker_idx > 0 {
212            println!();
213        }
214        println!("=== {broker} ===");
215        for group in ["真实账户", "模拟账户", "已禁用账户"] {
216            let group_rows = rows
217                .iter()
218                .filter(|row| row.broker == *broker && acc_group_label(row) == group)
219                .map(|row| AccGroupedRow {
220                    acc_id: row.acc_id.clone(),
221                    card: row.card.clone(),
222                    env: row.env.clone(),
223                    acc_type: row.acc_type.clone(),
224                    status: row.status.clone(),
225                    label: row.label.clone(),
226                    markets: row.markets.clone(),
227                })
228                .collect::<Vec<_>>();
229            if group_rows.is_empty() {
230                continue;
231            }
232            println!("-- {group} ({}) --", group_rows.len());
233            let mut table = Table::new(&group_rows);
234            table.with(Style::rounded());
235            println!("{table}");
236        }
237    }
238    Ok(())
239}
240
241pub async fn list_accounts(
242    gateway: &str,
243    format: OutputFormat,
244    market: Option<&str>,
245    security_firm: Option<&str>,
246    all: bool,
247) -> Result<()> {
248    let (client, _push_rx) = connect_gateway(gateway, "futucli-acc-list").await?;
249    // CLI `account` is a user-facing discovery view for selecting usable
250    // `acc_id` values. The daemon returns raw discovery for routing and
251    // diagnostics; by default CLI projects that to the App-visible account set
252    // (for example crypto / equity-incentive rows stay visible, futures-only
253    // rows wrapped under a comprehensive account stay hidden). `--all` shows
254    // raw discovery for troubleshooting.
255    let mut accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
256    if !all {
257        accs = futu_trd::account::app_visible_accounts(accs);
258    }
259    if market.is_some() || security_firm.is_some() {
260        let market_filter = match market {
261            Some(m) => parse_account_market_filter(m)?,
262            None => None,
263        };
264        let security_firm_filter = match security_firm {
265            Some(firm) => parse_account_security_firm_filter(firm)?,
266            None => None,
267        };
268        accs.retain(|a| account_matches_sdk_filter(a, market_filter, security_firm_filter));
269    }
270
271    let rows: Vec<AccRow> = accs
272        .iter()
273        .map(|a| AccRow {
274            acc_id: a.acc_id.to_string(),
275            card: visible_card_num(a).unwrap_or_else(|| "-".into()),
276            env: env_label(a.trd_env).to_string(),
277            broker: display_security_firm_label(a),
278            acc_type: a
279                .acc_type
280                .map(|v| acc_type_label(v).to_string())
281                .unwrap_or_else(|| "-".into()),
282            status: a
283                .acc_status
284                .map(|v| acc_status_label(v).to_string())
285                .unwrap_or_else(|| "-".into()),
286            label: account_display_label(a),
287            markets: account_market_list_label(a),
288        })
289        .collect();
290
291    let jsons: Vec<AccJson> = accs
292        .iter()
293        .map(|a| AccJson {
294            acc_id: a.acc_id.to_string(),
295            trd_env: a.trd_env,
296            env_label: env_label(a.trd_env),
297            trd_market_auth_list: a.trd_market_auth_list.clone(),
298            trd_market_auth_labels: a
299                .trd_market_auth_list
300                .iter()
301                .map(|m| market_label(*m))
302                .collect(),
303            acc_type: a.acc_type,
304            acc_type_label: a.acc_type.map(acc_type_label),
305            card_num: visible_card_num(a),
306            security_firm: a.security_firm,
307            security_firm_label: a.security_firm.map(security_firm_label),
308            sim_acc_type: a.sim_acc_type,
309            acc_status: a.acc_status,
310            acc_status_label: a.acc_status.map(acc_status_label),
311            acc_role: a.acc_role,
312            acc_role_label: a.acc_role.map(acc_role_label),
313            acc_label: a.acc_label.clone(),
314            acc_label_label: a
315                .acc_label
316                .as_deref()
317                .map(crate::cmd::account_view::account_special_label),
318            competition_acc_name: a.competition_acc_name.clone(),
319            jp_acc_type: a.jp_acc_type.clone(),
320        })
321        .collect();
322
323    match format {
324        OutputFormat::Table => print_account_grouped_tables(&rows)?,
325        _ => format.print_rows(&rows, &jsons)?,
326    }
327    Ok(())
328}