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