Skip to main content

futucli/cmd/account/
list.rs

1use std::path::Path;
2
3#[cfg(unix)]
4use std::io::Read;
5
6#[cfg(unix)]
7use anyhow::Context;
8use anyhow::{Result, bail};
9use serde::Serialize;
10use tabled::{Table, Tabled, settings::Style};
11
12use crate::cmd::account_view::{
13    acc_role_label, acc_status_label, acc_type_label, account_display_label,
14    account_market_list_label, display_security_firm_label, env_label, market_label,
15    security_firm_label, visible_card_num,
16};
17use crate::common::connect_gateway;
18use crate::output::OutputFormat;
19use futu_core::account_locator;
20
21use super::parse_trd_market;
22
23#[derive(Clone, Tabled)]
24struct AccRow {
25    #[tabled(rename = "Acc ID")]
26    acc_id: String,
27    #[tabled(rename = "Card Num")]
28    card: String,
29    #[tabled(rename = "Env")]
30    env: String,
31    #[tabled(rename = "Broker")]
32    broker: String,
33    #[tabled(rename = "Type")]
34    acc_type: String,
35    #[tabled(rename = "Status")]
36    status: String,
37    #[tabled(rename = "Label")]
38    label: String,
39    #[tabled(rename = "Markets")]
40    markets: String,
41}
42
43#[derive(Tabled)]
44struct AccGroupedRow {
45    #[tabled(rename = "Acc ID")]
46    acc_id: String,
47    #[tabled(rename = "Card Num")]
48    card: String,
49    #[tabled(rename = "Env")]
50    env: String,
51    #[tabled(rename = "Type")]
52    acc_type: String,
53    #[tabled(rename = "Status")]
54    status: String,
55    #[tabled(rename = "Label")]
56    label: String,
57    #[tabled(rename = "Markets")]
58    markets: String,
59}
60
61#[derive(Serialize)]
62pub(super) struct AccJson {
63    /// Keep account ids as strings in machine-readable CLI output.
64    ///
65    /// FTAPI uses uint64 account ids, but many downstream JSON consumers
66    /// (browser devtools, spreadsheet importers, JS scripts) round integers
67    /// above 2^53. The table output already uses `to_string()`; JSON follows
68    /// the same lossless presentation here.
69    pub(super) acc_id: String,
70    pub(super) trd_env: i32,
71    pub(super) env_label: &'static str,
72    pub(super) trd_market_auth_list: Vec<i32>,
73    pub(super) trd_market_auth_labels: Vec<&'static str>,
74    pub(super) acc_type: Option<i32>,
75    pub(super) acc_type_label: Option<&'static str>,
76    pub(super) card_num: Option<String>,
77    pub(super) security_firm: Option<i32>,
78    pub(super) security_firm_label: Option<&'static str>,
79    pub(super) sim_acc_type: Option<i32>,
80    pub(super) acc_status: Option<i32>,
81    pub(super) acc_status_label: Option<&'static str>,
82    pub(super) acc_role: Option<i32>,
83    pub(super) acc_role_label: Option<&'static str>,
84    pub(super) acc_label: Option<String>,
85    pub(super) acc_label_label: Option<String>,
86    pub(super) competition_acc_name: Option<String>,
87    pub(super) jp_acc_type: Vec<i32>,
88}
89
90pub(super) fn app_visible_card_num_resolution(
91    accs: &[futu_trd::account::TrdAcc],
92    card_num: &str,
93) -> Result<account_locator::CardNumResolution> {
94    Ok(account_locator::resolve_card_num_in_records(
95        accs, card_num, None,
96    )?)
97}
98
99#[cfg(unix)]
100// A decimal u64 needs at most 20 digits; 128 leaves room for a trailing line
101// ending while bounding reads from a concurrently modified local file.
102const MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES: u64 = 128;
103
104/// Read an account id without placing the value in process argv.
105///
106/// On Unix the opened descriptor itself is checked after `O_NOFOLLOW`: it must
107/// be an owner-only (`0600`) regular file owned by the effective user, with a
108/// single hard link. This ordering avoids a path `stat`/open TOCTOU window.
109/// Other platforms fail closed because this exact owner/mode/no-follow contract
110/// has no portable `std` equivalent.
111#[cfg(unix)]
112pub(crate) fn read_private_account_id_file(path: &Path) -> Result<u64> {
113    use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
114
115    let mut options = std::fs::OpenOptions::new();
116    options.read(true);
117    options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW);
118
119    let file = options
120        .open(path)
121        .with_context(|| format!("cannot securely open --acc-id-file {}", path.display()))?;
122    let meta = file
123        .metadata()
124        .context("cannot inspect opened --acc-id-file")?;
125    if !meta.is_file() {
126        bail!("--acc-id-file must be a regular file");
127    }
128    if meta.len() == 0 || meta.len() > MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES {
129        bail!("--acc-id-file must contain 1..=128 bytes");
130    }
131
132    if meta.mode() & 0o7777 != 0o600 {
133        bail!("--acc-id-file must have exact mode 0600");
134    }
135    // SAFETY: geteuid has no preconditions and only reads process identity.
136    let effective_uid = unsafe { libc::geteuid() };
137    if meta.uid() != effective_uid {
138        bail!("--acc-id-file must be owned by the current user");
139    }
140    if meta.nlink() != 1 {
141        bail!("--acc-id-file must have exactly one hard link");
142    }
143
144    let mut bytes = Vec::with_capacity(meta.len() as usize);
145    file.take(MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES + 1)
146        .read_to_end(&mut bytes)
147        .context("cannot read --acc-id-file")?;
148    if bytes.len() as u64 > MAX_PRIVATE_ACCOUNT_ID_FILE_BYTES {
149        bail!("--acc-id-file exceeds 128 bytes");
150    }
151    let value = std::str::from_utf8(&bytes)
152        .map(str::trim)
153        .ok()
154        .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte.is_ascii_digit()))
155        .and_then(|value| value.parse::<u64>().ok())
156        .filter(|value| *value > 0)
157        .ok_or_else(|| {
158            anyhow::anyhow!("--acc-id-file must contain one positive decimal account id")
159        })?;
160    Ok(value)
161}
162
163#[cfg(not(unix))]
164pub(crate) fn read_private_account_id_file(_path: &Path) -> Result<u64> {
165    bail!(
166        "--acc-id-file is supported only on Unix, where owner and exact mode 0600 can be verified"
167    )
168}
169
170pub async fn resolve_account_locator(
171    gateway: &str,
172    acc_id: Option<u64>,
173    acc_id_file: Option<&Path>,
174    card_num: Option<&str>,
175    command: &str,
176) -> Result<u64> {
177    let acc_id = match acc_id_file {
178        Some(path) => {
179            if acc_id.is_some() || card_num.is_some() {
180                bail!(
181                    "{command}: --acc-id-file cannot be combined with --acc-id, a positional account id, or --card-num"
182                );
183            }
184            Some(read_private_account_id_file(path)?)
185        }
186        None => acc_id,
187    };
188    let Some(card_num) = card_num else {
189        return acc_id.ok_or_else(|| {
190            anyhow::anyhow!(
191                "{command}: 需要 --acc-id <ACC_ID>、--acc-id-file <MODE_0600_FILE> 或 --card-num <CARD_NUM>"
192            )
193        });
194    };
195
196    let (client, _push_rx) = connect_gateway(gateway, "futucli-card-num-resolve").await?;
197    let raw_accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
198    let app_visible_accs = futu_trd::account::app_visible_accounts(raw_accs);
199    let resolution = app_visible_card_num_resolution(&app_visible_accs, card_num)?;
200
201    let resolved = match resolution {
202        account_locator::CardNumResolution::NotFound => bail!(
203            "{command}: card_num 在 App 可见账户集合中找不到。请运行 `futucli account` \
204             确认 Card Num,或改用 `--acc-id`;排障时可用 `futucli account --all` 查看 raw discovery"
205        ),
206        account_locator::CardNumResolution::Resolved(only) => only,
207        account_locator::CardNumResolution::Ambiguous(many) => bail!(
208            "{command}: card_num 匹配 {} 个账户 ({}),请改用 16 位完整卡号或 `--acc-id`",
209            many.len(),
210            many.iter()
211                .map(u64::to_string)
212                .collect::<Vec<_>>()
213                .join(", ")
214        ),
215    };
216
217    if let Some(explicit) = acc_id
218        && explicit != resolved
219    {
220        bail!(
221            "{command}: --acc-id ({explicit}) 与 --card-num 解析结果 ({resolved}) 不一致;\
222             请只传一个,或确认它们指向同一账户"
223        );
224    }
225
226    Ok(resolved)
227}
228
229pub(super) fn parse_account_market_filter(s: &str) -> Result<Option<i32>> {
230    match s.trim().to_ascii_lowercase().as_str() {
231        "" | "all" | "*" | "none" => Ok(None),
232        _ => Ok(Some(parse_trd_market(s)? as i32)),
233    }
234}
235
236pub(super) fn parse_account_security_firm_filter(s: &str) -> Result<Option<i32>> {
237    let normalized = s.trim().to_ascii_lowercase().replace(['_', '-'], "");
238    let firm = match normalized.as_str() {
239        "" | "all" | "*" | "none" => return Ok(None),
240        "futuhk" | "futusecurities" | "hk" | "1" => 1,
241        "futuinc" | "futuus" | "us" | "moomoo" | "mm" | "2" => 2,
242        "futusg" | "sg" | "3" => 3,
243        "futuau" | "au" | "4" => 4,
244        "futuca" | "ca" | "5" => 5,
245        "futumy" | "my" | "6" => 6,
246        "futujp" | "jp" | "7" => 7,
247        other => bail!(
248            "unknown security firm {other:?} \
249             (FutuHK|FutuInc|FutuUS|FutuSG|FutuAU|FutuCA|FutuMY|FutuJP|hk|us|sg|au|ca|my|jp|1..7|all)"
250        ),
251    };
252    Ok(Some(firm))
253}
254
255pub(super) fn account_matches_sdk_filter(
256    a: &futu_trd::account::TrdAcc,
257    market_filter: Option<i32>,
258    security_firm_filter: Option<i32>,
259) -> bool {
260    // Official Python SDK applies `filter_trdmarket` and `security_firm`
261    // locally after Trd_GetAccList. Sim accounts are environment-level demo
262    // rows and may carry security_firm=0/None, but SDK HK/US account panels
263    // still keep the matching sim market rows. Therefore broker filtering is
264    // only meaningful for real rows; market filtering remains mandatory when
265    // requested.
266    let market_ok = match market_filter {
267        Some(market) => a.trd_market_auth_list.contains(&market),
268        None => true,
269    };
270    let firm_ok = match (security_firm_filter, a.trd_env, a.security_firm) {
271        (Some(_), env, _) if env != 1 => true,
272        (Some(expected), _, Some(actual)) => actual == expected,
273        (Some(_), _, None) => true,
274        (None, _, _) => true,
275    };
276    market_ok && firm_ok
277}
278
279fn acc_group_label(row: &AccRow) -> &'static str {
280    if row.env == "simulate" {
281        "模拟账户"
282    } else if row.status == "active" {
283        "真实账户"
284    } else {
285        "已禁用账户"
286    }
287}
288
289fn print_account_grouped_tables(rows: &[AccRow]) -> std::io::Result<()> {
290    if rows.is_empty() {
291        println!("(empty)");
292        return Ok(());
293    }
294
295    let mut broker_order: Vec<&str> = Vec::new();
296    for row in rows {
297        if !broker_order.iter().any(|b| *b == row.broker) {
298            broker_order.push(&row.broker);
299        }
300    }
301
302    for (broker_idx, broker) in broker_order.iter().enumerate() {
303        if broker_idx > 0 {
304            println!();
305        }
306        println!("=== {broker} ===");
307        for group in ["真实账户", "模拟账户", "已禁用账户"] {
308            let group_rows = rows
309                .iter()
310                .filter(|row| row.broker == *broker && acc_group_label(row) == group)
311                .map(|row| AccGroupedRow {
312                    acc_id: row.acc_id.clone(),
313                    card: row.card.clone(),
314                    env: row.env.clone(),
315                    acc_type: row.acc_type.clone(),
316                    status: row.status.clone(),
317                    label: row.label.clone(),
318                    markets: row.markets.clone(),
319                })
320                .collect::<Vec<_>>();
321            if group_rows.is_empty() {
322                continue;
323            }
324            println!("-- {group} ({}) --", group_rows.len());
325            let mut table = Table::new(&group_rows);
326            table.with(Style::rounded());
327            println!("{table}");
328        }
329    }
330    Ok(())
331}
332
333pub async fn list_accounts(
334    gateway: &str,
335    format: OutputFormat,
336    market: Option<&str>,
337    security_firm: Option<&str>,
338    all: bool,
339) -> Result<()> {
340    let (client, _push_rx) = connect_gateway(gateway, "futucli-acc-list").await?;
341    // CLI `account` is a user-facing discovery view for selecting usable
342    // `acc_id` values. The daemon returns raw discovery for routing and
343    // diagnostics; by default CLI projects that to the App-visible account set
344    // (for example crypto / equity-incentive rows stay visible, futures-only
345    // rows wrapped under a comprehensive account stay hidden). `--all` shows
346    // raw discovery for troubleshooting.
347    let mut accs = futu_trd::account::get_acc_list_for_account_discovery(&client).await?;
348    if !all {
349        accs = futu_trd::account::app_visible_accounts(accs);
350    }
351    if market.is_some() || security_firm.is_some() {
352        let market_filter = match market {
353            Some(m) => parse_account_market_filter(m)?,
354            None => None,
355        };
356        let security_firm_filter = match security_firm {
357            Some(firm) => parse_account_security_firm_filter(firm)?,
358            None => None,
359        };
360        accs.retain(|a| account_matches_sdk_filter(a, market_filter, security_firm_filter));
361    }
362
363    let rows: Vec<AccRow> = accs
364        .iter()
365        .map(|a| AccRow {
366            acc_id: a.acc_id.to_string(),
367            card: visible_card_num(a).unwrap_or_else(|| "-".into()),
368            env: env_label(a.trd_env).to_string(),
369            broker: display_security_firm_label(a),
370            acc_type: a
371                .acc_type
372                .map(|v| acc_type_label(v).to_string())
373                .unwrap_or_else(|| "-".into()),
374            status: a
375                .acc_status
376                .map(|v| acc_status_label(v).to_string())
377                .unwrap_or_else(|| "-".into()),
378            label: account_display_label(a),
379            markets: account_market_list_label(a),
380        })
381        .collect();
382
383    let jsons: Vec<AccJson> = accs
384        .iter()
385        .map(|a| AccJson {
386            acc_id: a.acc_id.to_string(),
387            trd_env: a.trd_env,
388            env_label: env_label(a.trd_env),
389            trd_market_auth_list: a.trd_market_auth_list.clone(),
390            trd_market_auth_labels: a
391                .trd_market_auth_list
392                .iter()
393                .map(|m| market_label(*m))
394                .collect(),
395            acc_type: a.acc_type,
396            acc_type_label: a.acc_type.map(acc_type_label),
397            card_num: visible_card_num(a),
398            security_firm: a.security_firm,
399            security_firm_label: a.security_firm.map(security_firm_label),
400            sim_acc_type: a.sim_acc_type,
401            acc_status: a.acc_status,
402            acc_status_label: a.acc_status.map(acc_status_label),
403            acc_role: a.acc_role,
404            acc_role_label: a.acc_role.map(acc_role_label),
405            acc_label: a.acc_label.clone(),
406            acc_label_label: a
407                .acc_label
408                .as_deref()
409                .map(crate::cmd::account_view::account_special_label),
410            competition_acc_name: a.competition_acc_name.clone(),
411            jp_acc_type: a.jp_acc_type.clone(),
412        })
413        .collect();
414
415    match format {
416        OutputFormat::Table => print_account_grouped_tables(&rows)?,
417        _ => format.print_rows(&rows, &jsons)?,
418    }
419    Ok(())
420}