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;