Skip to main content

futucli/cmd/
trade_check.rs

1use anyhow::{Context, Result, bail};
2use serde::Serialize;
3use tabled::Tabled;
4
5use crate::output::OutputFormat;
6
7use super::trade_ext::{MaxQtysCommand, run_max_qtys};
8
9#[derive(Debug, Clone, Copy)]
10pub(crate) struct TradeCheckInput<'a> {
11    pub(crate) market: &'a str,
12    pub(crate) code: &'a str,
13    pub(crate) env: &'a str,
14    pub(crate) acc_id: Option<u64>,
15    pub(crate) card_num: Option<&'a str>,
16    pub(crate) order_type: &'a str,
17    pub(crate) price: f64,
18    pub(crate) jp_acc_type: Option<i32>,
19}
20
21pub struct TradeCheckCommand<'a> {
22    pub gateway: &'a str,
23    pub market: &'a str,
24    pub env: &'a str,
25    pub acc_id: u64,
26    pub card_num: Option<&'a str>,
27    pub order_type: &'a str,
28    pub code: &'a str,
29    pub price: f64,
30    pub jp_acc_type: Option<i32>,
31    pub output: OutputFormat,
32}
33
34#[derive(Debug, Clone, Serialize)]
35pub(crate) struct TradeCheckPlan {
36    pub(crate) market: String,
37    pub(crate) code: String,
38    pub(crate) symbol: String,
39    pub(crate) local_code: String,
40    pub(crate) env: String,
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub(crate) acc_id: Option<u64>,
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub(crate) card_num: Option<String>,
45    pub(crate) order_type: String,
46    pub(crate) price: f64,
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub(crate) jp_acc_type: Option<i32>,
49    pub(crate) steps: Vec<TradeCheckStep>,
50    pub(crate) findings: Vec<TradeCheckFinding>,
51}
52
53#[derive(Debug, Clone, Serialize, Tabled)]
54pub(crate) struct TradeCheckStep {
55    #[tabled(rename = "Step")]
56    pub(crate) name: &'static str,
57    #[tabled(rename = "Symbol")]
58    pub(crate) symbol: String,
59    #[tabled(rename = "ReadOnly")]
60    pub(crate) read_only: bool,
61    #[tabled(rename = "Action")]
62    pub(crate) action: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub(crate) struct TradeCheckFinding {
67    pub(crate) level: &'static str,
68    pub(crate) message: String,
69}
70
71pub async fn run_trade_check(input: TradeCheckCommand<'_>) -> Result<()> {
72    let plan = build_trade_check_plan(TradeCheckInput {
73        market: input.market,
74        code: input.code,
75        env: input.env,
76        acc_id: Some(input.acc_id),
77        card_num: input.card_num,
78        order_type: input.order_type,
79        price: input.price,
80        jp_acc_type: input.jp_acc_type,
81    })?;
82
83    match input.output {
84        OutputFormat::Table | OutputFormat::Markdown => {
85            println!("futucli trade-check");
86            println!(
87                "market={} code={} env={} acc_id={} price={}",
88                plan.market, plan.code, plan.env, input.acc_id, plan.price
89            );
90            println!();
91            input.output.print_rows(&plan.steps, &plan.steps)?;
92            println!();
93            println!("Findings:");
94            for finding in &plan.findings {
95                println!("- [{}] {}", finding.level, finding.message);
96            }
97            println!();
98            println!("== static-warmup ==");
99            super::static_info::run(
100                input.gateway,
101                std::slice::from_ref(&plan.symbol),
102                input.output,
103            )
104            .await?;
105            println!();
106            println!("== max-qtys ==");
107            run_max_qtys(MaxQtysCommand {
108                gateway: input.gateway,
109                env: input.env,
110                acc_id: input.acc_id,
111                market: input.market,
112                order_type: input.order_type,
113                code: &plan.local_code,
114                price: input.price,
115                jp_acc_type: input.jp_acc_type,
116                output: input.output,
117            })
118            .await
119        }
120        OutputFormat::Json => {
121            println!("{}", serde_json::to_string_pretty(&plan)?);
122            Ok(())
123        }
124        OutputFormat::Jsonl => {
125            println!("{}", serde_json::to_string(&plan)?);
126            Ok(())
127        }
128    }
129}
130
131pub(crate) fn build_trade_check_plan(input: TradeCheckInput<'_>) -> Result<TradeCheckPlan> {
132    if input.market.trim().is_empty() {
133        bail!("trade-check --market must not be empty");
134    }
135    if input.code.trim().is_empty() {
136        bail!("trade-check --code must not be empty");
137    }
138    if input.price < 0.0 {
139        bail!("trade-check --price must be >= 0");
140    }
141    let market = input.market.trim().to_ascii_uppercase();
142    let (symbol, local_code, prefix) = normalize_symbol_and_code(&market, input.code)?;
143    let mut findings = Vec::new();
144    if let Some(prefix) = prefix
145        && prefix != market
146    {
147        findings.push(TradeCheckFinding {
148            level: "WARN",
149            message: format!(
150                "market/code mismatch: --market {market} but --code prefix is {prefix}"
151            ),
152        });
153    }
154    if market == "JP" && input.jp_acc_type.is_none() {
155        findings.push(TradeCheckFinding {
156            level: "WARN",
157            message: "JP trade preflight: pass --jp-acc-type when backend/account requires a FutuJP sub-account".to_string(),
158        });
159    }
160    if input.acc_id.is_none() && input.card_num.is_none() {
161        findings.push(TradeCheckFinding {
162            level: "INFO",
163            message: "no --acc-id/--card-num in plan; dispatch will resolve default visible account before execution".to_string(),
164        });
165    }
166    if findings.is_empty() {
167        findings.push(TradeCheckFinding {
168            level: "OK",
169            message: "preflight plan has no local warnings".to_string(),
170        });
171    }
172
173    Ok(TradeCheckPlan {
174        market,
175        code: input.code.to_string(),
176        symbol: symbol.clone(),
177        local_code: local_code.clone(),
178        env: input.env.to_string(),
179        acc_id: input.acc_id,
180        card_num: input.card_num.map(str::to_string),
181        order_type: input.order_type.to_string(),
182        price: input.price,
183        jp_acc_type: input.jp_acc_type,
184        steps: vec![
185            TradeCheckStep {
186                name: "static-warmup",
187                symbol: symbol.clone(),
188                read_only: true,
189                action: "request static-info so daemon can fill on-demand security metadata"
190                    .to_string(),
191            },
192            TradeCheckStep {
193                name: "max-qtys",
194                symbol,
195                read_only: true,
196                action: format!(
197                    "request max-trd-qtys with code={local_code}, order_type={}",
198                    input.order_type
199                ),
200            },
201        ],
202        findings,
203    })
204}
205
206fn normalize_symbol_and_code(market: &str, code: &str) -> Result<(String, String, Option<String>)> {
207    let trimmed = code.trim();
208    let normalized: Result<(String, String, Option<String>)> =
209        if let Some((prefix, local)) = trimmed.split_once('.') {
210            let prefix = prefix.trim().to_ascii_uppercase();
211            let local = local.trim();
212            if prefix.is_empty() || local.is_empty() {
213                bail!("invalid MARKET.CODE: {trimmed}");
214            }
215            Ok((format!("{prefix}.{local}"), local.to_string(), Some(prefix)))
216        } else {
217            Ok((format!("{market}.{trimmed}"), trimmed.to_string(), None))
218        };
219    normalized.with_context(|| format!("normalize trade-check code {code:?}"))
220}
221
222#[cfg(test)]
223#[path = "trade_check_tests.rs"]
224mod trade_check_tests;