1use anyhow::{Context, Result, bail};
20
21use crate::cmd::account::{parse_trd_env, parse_trd_market_for_write};
22use crate::common::connect_gateway;
23use crate::output::OutputFormat;
24
25mod cash_flow;
26mod hints;
27mod history;
28mod idempotency;
29mod margin_fee;
30mod max_qtys;
31mod parsers;
32mod write_output;
33
34#[cfg(test)]
35mod tests;
36
37pub use cash_flow::{AccCashFlowRangeCommand, run_acc_cash_flow, run_acc_cash_flow_range};
38pub use history::{
39 HistoryDealsCommand, HistoryOrdersCommand, run_history_deals, run_history_orders,
40};
41pub use margin_fee::{run_margin_ratio, run_order_fee};
42pub use max_qtys::{MaxQtysCommand, run_max_qtys};
43
44#[cfg(test)]
45pub(crate) use cash_flow::acc_cash_flow_advance_day;
46pub(crate) use hints::emit_trade_hint_if_known;
47#[cfg(test)]
48pub(crate) use hints::translate_trade_ret_msg;
49#[cfg(test)]
50pub(crate) use history::validate_history_time_range;
51pub(crate) use idempotency::{IdempotencyParams, resolve_auto_idempotency_key};
52pub(crate) use parsers::{
53 parse_modify_op, parse_numeric_order_id_arg, parse_order_type, parse_trd_side,
54 resolve_order_id_arg,
55};
56#[cfg(test)]
57pub(crate) use write_output::render_trade_write_success;
58pub(crate) use write_output::{TradeWriteSuccess, emit_trade_write_success};
59
60use futu_trd::misc::reconfirm_order;
61use futu_trd::order::{modify_order, place_order_with_options};
62use futu_trd::types::{
63 ModifyOrderOp, ModifyOrderParams, PlaceOrderOptions, PlaceOrderParams, TrdEnv, TrdHeader,
64};
65
66pub struct PlaceOrderCommand<'a> {
69 pub gateway: &'a str,
70 pub env: &'a str,
71 pub acc_id: u64,
72 pub market: &'a str,
73 pub side: &'a str,
74 pub order_type: &'a str,
75 pub code: &'a str,
76 pub qty: f64,
77 pub price: Option<f64>,
78 pub time_in_force: Option<&'a str>,
79 pub fill_outside_rth: bool,
80 pub session: Option<&'a str>,
81 pub expire_time: Option<&'a str>,
82 pub jp_acc_type: Option<i32>,
83 pub confirm: bool,
84 pub idempotency_key: Option<String>,
85 pub stop_price: Option<f64>,
87 pub trail_type: Option<i32>,
88 pub trail_value: Option<f64>,
89 pub trail_spread: Option<f64>,
90 pub output: OutputFormat,
91}
92
93fn parse_place_time_in_force(value: Option<&str>) -> Result<Option<i32>> {
94 let Some(raw) = value else {
95 return Ok(None);
96 };
97 let trimmed = raw.trim();
98 if trimmed.is_empty() {
99 return Ok(None);
100 }
101 let parsed = match trimmed.to_ascii_uppercase().as_str() {
102 "DAY" => 0,
103 "GTC" => 1,
104 "IOC" => 2,
105 "GTD" => 3,
106 other => other.parse::<i32>().with_context(|| {
107 format!("invalid --time-in-force {trimmed:?}; use DAY|GTC|IOC|GTD or 0|1|2|3")
108 })?,
109 };
110 if matches!(parsed, 0..=3) {
111 Ok(Some(parsed))
112 } else {
113 bail!("invalid --time-in-force {trimmed:?}; use DAY|GTC|IOC|GTD or 0|1|2|3")
114 }
115}
116
117fn parse_place_session(value: Option<&str>) -> Result<Option<i32>> {
118 let Some(raw) = value else {
119 return Ok(None);
120 };
121 let trimmed = raw.trim();
122 if trimmed.is_empty() {
123 return Ok(None);
124 }
125 let parsed = match trimmed.to_ascii_uppercase().as_str() {
126 "NONE" => 0,
127 "RTH" => 1,
128 "ETH" | "EXTENDED" => 2,
129 "ALL" | "ALL_DAY" => 3,
130 "OVERNIGHT" | "NIGHT" => 4,
131 other => other.parse::<i32>().with_context(|| {
132 format!("invalid --session {trimmed:?}; use NONE|RTH|ETH|ALL|OVERNIGHT or 0|1|2|3|4")
133 })?,
134 };
135 if matches!(parsed, 0..=4) {
136 Ok(Some(parsed))
137 } else {
138 bail!("invalid --session {trimmed:?}; use NONE|RTH|ETH|ALL|OVERNIGHT or 0|1|2|3|4")
139 }
140}
141
142pub async fn run_place_order(input: PlaceOrderCommand<'_>) -> Result<()> {
143 let idempotency_key = resolve_auto_idempotency_key(
145 input.idempotency_key,
146 &IdempotencyParams {
147 acc_id: input.acc_id,
148 market: input.market,
149 code: input.code,
150 side: input.side,
151 qty: input.qty,
152 price: input.price,
153 order_type: input.order_type,
154 },
155 );
156 let env_p = parse_trd_env(input.env)?;
157 let market_p = parse_trd_market_for_write(input.market)?;
158 let side_p = parse_trd_side(input.side)?;
159 let order_type_p = parse_order_type(input.order_type)?;
160 let time_in_force = parse_place_time_in_force(input.time_in_force)?;
161 let session = parse_place_session(input.session)?;
162
163 if matches!(env_p, TrdEnv::Real) && !input.confirm {
165 bail!(
166 "real-env place_order requires --confirm for safety. \
167 Re-run with --confirm after double-checking all params. \
168 (Or use --env simulate for paper trading.)"
169 );
170 }
171
172 let placing_msg = format!(
173 "placing {} {:?} × {} @ {} {:?} (env={:?}, acc={}, market={:?}, code={}, tif={:?}, fill_outside_rth={}, session={:?})",
174 input.order_type,
175 side_p,
176 input.qty,
177 input.price.unwrap_or(0.0),
178 order_type_p,
179 env_p,
180 input.acc_id,
181 market_p,
182 input.code,
183 time_in_force,
184 input.fill_outside_rth,
185 session
186 );
187 if matches!(input.output, OutputFormat::Table) {
188 println!("{placing_msg}");
189 } else {
190 eprintln!("{placing_msg}");
191 }
192
193 let params = PlaceOrderParams {
194 header: TrdHeader {
195 trd_env: env_p,
196 acc_id: input.acc_id,
197 trd_market: market_p,
198 jp_acc_type: input.jp_acc_type,
199 },
200 trd_side: side_p,
201 order_type: order_type_p,
202 code: input.code.to_string(),
203 qty: input.qty,
204 price: input.price,
205 adjust_price: None,
206 adjust_side_and_limit: None,
207 idempotency_key,
208 aux_price: input.stop_price,
210 trail_type: input.trail_type,
211 trail_value: input.trail_value,
212 trail_spread: input.trail_spread,
213 };
214 let options = PlaceOrderOptions {
215 time_in_force,
216 fill_outside_rth: input.fill_outside_rth.then_some(true),
217 session,
218 expire_time: input.expire_time.map(str::to_string),
219 };
220
221 let (client, _push_rx) = connect_gateway(input.gateway, "futucli-place-order")
222 .await
223 .context("connect gateway")?;
224 let result = match place_order_with_options(&client, ¶ms, &options).await {
226 Ok(r) => r,
227 Err(e) => {
228 let wrapped = anyhow::Error::from(e).context("place_order RPC");
229 emit_trade_hint_if_known(&wrapped);
230 return Err(wrapped);
231 }
232 };
233
234 emit_trade_write_success(
235 input.output,
236 TradeWriteSuccess {
237 operation: "place_order",
238 order_id: result.order_id,
239 returned_order_id: None,
240 },
241 )?;
242 if matches!(input.output, OutputFormat::Table) {
243 println!(
244 " (use `futucli order --market {} --acc-id {} --env {}` to verify)",
245 input.market, input.acc_id, input.env
246 );
247 }
248 Ok(())
249}
250
251pub struct ModifyOrderCommand<'a> {
254 pub gateway: &'a str,
255 pub env: &'a str,
256 pub acc_id: u64,
257 pub market: &'a str,
258 pub order_id: String,
259 pub op: &'a str,
260 pub qty: Option<f64>,
261 pub price: Option<f64>,
262 pub jp_acc_type: Option<i32>,
263 pub confirm: bool,
264 pub idempotency_key: Option<String>,
265 pub output: OutputFormat,
266}
267
268pub async fn run_modify_order(input: ModifyOrderCommand<'_>) -> Result<()> {
269 let resolved_order_id = resolve_order_id_arg(&input.order_id)?;
270 let idempotency_key = resolve_auto_idempotency_key(
273 input.idempotency_key,
274 &IdempotencyParams {
275 acc_id: input.acc_id,
276 market: input.market,
277 code: "", side: input.op, qty: input.qty.unwrap_or(0.0),
280 price: input.price,
281 order_type: &resolved_order_id.idempotency_component, },
283 );
284 let env_p = parse_trd_env(input.env)?;
285 let market_p = parse_trd_market_for_write(input.market)?;
286 let op_p = parse_modify_op(input.op)?;
287
288 if matches!(env_p, TrdEnv::Real) && !input.confirm {
289 bail!("real-env modify_order requires --confirm for safety");
290 }
291
292 let params = ModifyOrderParams {
293 header: TrdHeader {
294 trd_env: env_p,
295 acc_id: input.acc_id,
296 trd_market: market_p,
297 jp_acc_type: input.jp_acc_type,
298 },
299 order_id: resolved_order_id.order_id,
300 order_id_ex: resolved_order_id.order_id_ex.clone(),
301 modify_order_op: op_p,
302 qty: input.qty,
303 price: input.price,
304 for_all: None,
305 idempotency_key,
306 };
307
308 let (client, _push_rx) = connect_gateway(input.gateway, "futucli-trade-ext").await?;
309 let ret_order_id = match modify_order(&client, ¶ms).await {
311 Ok(r) => r,
312 Err(e) => {
313 let wrapped = anyhow::Error::from(e).context("modify_order RPC");
314 emit_trade_hint_if_known(&wrapped);
315 return Err(wrapped);
316 }
317 };
318 emit_trade_write_success(
319 input.output,
320 TradeWriteSuccess {
321 operation: "modify_order",
322 order_id: if resolved_order_id.order_id != 0 {
323 resolved_order_id.order_id
324 } else {
325 ret_order_id
326 },
327 returned_order_id: Some(ret_order_id),
328 },
329 )?;
330 Ok(())
331}
332
333pub struct CancelOrderCommand<'a> {
334 pub gateway: &'a str,
335 pub env: &'a str,
336 pub acc_id: u64,
337 pub market: &'a str,
338 pub order_id: String,
339 pub jp_acc_type: Option<i32>,
340 pub confirm: bool,
341 pub idempotency_key: Option<String>,
342 pub output: OutputFormat,
343}
344
345pub async fn run_cancel_order(input: CancelOrderCommand<'_>) -> Result<()> {
346 let resolved_order_id = resolve_order_id_arg(&input.order_id)?;
347 let env_p = parse_trd_env(input.env)?;
348 let market_p = parse_trd_market_for_write(input.market)?;
349
350 if matches!(env_p, TrdEnv::Real) && !input.confirm {
351 bail!("real-env cancel_order requires --confirm for safety");
352 }
353
354 let header = TrdHeader {
355 trd_env: env_p,
356 acc_id: input.acc_id,
357 trd_market: market_p,
358 jp_acc_type: input.jp_acc_type,
359 };
360 let (client, _push_rx) = connect_gateway(input.gateway, "futucli-trade-ext").await?;
361 let params = ModifyOrderParams {
362 header: header.clone(),
363 order_id: resolved_order_id.order_id,
364 order_id_ex: resolved_order_id.order_id_ex.clone(),
365 modify_order_op: ModifyOrderOp::Cancel,
366 qty: None,
367 price: None,
368 for_all: None,
369 idempotency_key: input.idempotency_key,
370 };
371 let ret_order_id = match modify_order(&client, ¶ms).await {
373 Ok(id) => id,
374 Err(e) => {
375 let wrapped = anyhow::Error::from(e).context("cancel_order RPC");
376 emit_trade_hint_if_known(&wrapped);
377 return Err(wrapped);
378 }
379 };
380 emit_trade_write_success(
381 input.output,
382 TradeWriteSuccess {
383 operation: "cancel_order",
384 order_id: if resolved_order_id.order_id != 0 {
385 resolved_order_id.order_id
386 } else {
387 ret_order_id
388 },
389 returned_order_id: None,
390 },
391 )?;
392 Ok(())
393}
394
395pub struct ReconfirmOrderCommand<'a> {
396 pub gateway: &'a str,
397 pub env: &'a str,
398 pub acc_id: u64,
399 pub market: &'a str,
400 pub order_id: String,
401 pub reason: i32,
402 pub jp_acc_type: Option<i32>,
403 pub confirm: bool,
404 pub output: OutputFormat,
405}
406
407pub async fn run_reconfirm_order(input: ReconfirmOrderCommand<'_>) -> Result<()> {
408 let parsed_order_id = parse_numeric_order_id_arg(&input.order_id, "--order-id")?;
409 let env_p = parse_trd_env(input.env)?;
410 let market_p = parse_trd_market_for_write(input.market)?;
411
412 if matches!(env_p, TrdEnv::Real) && !input.confirm {
413 bail!("real-env reconfirm_order requires --confirm for safety");
414 }
415
416 let header = TrdHeader {
417 trd_env: env_p,
418 acc_id: input.acc_id,
419 trd_market: market_p,
420 jp_acc_type: input.jp_acc_type,
421 };
422 let (client, _push_rx) = connect_gateway(input.gateway, "futucli-trade-ext").await?;
423 let ret_order_id = match reconfirm_order(&client, &header, parsed_order_id, input.reason).await
424 {
425 Ok(id) => id,
426 Err(e) => {
427 let wrapped = anyhow::Error::from(e).context("reconfirm_order RPC");
428 emit_trade_hint_if_known(&wrapped);
429 return Err(wrapped);
430 }
431 };
432 emit_trade_write_success(
433 input.output,
434 TradeWriteSuccess {
435 operation: "reconfirm_order",
436 order_id: parsed_order_id,
437 returned_order_id: Some(ret_order_id),
438 },
439 )?;
440 Ok(())
441}
442
443fn parse_acc_id_csv(s: &str) -> Result<Vec<u64>> {
446 s.split(',')
447 .map(|x| x.trim().parse::<u64>())
448 .collect::<std::result::Result<Vec<_>, _>>()
449 .map_err(|e| anyhow::anyhow!("invalid acc id: {e}"))
450}
451
452pub async fn run_sub_acc_push(
453 gateway: &str,
454 acc_ids: &str,
455 _format: crate::output::OutputFormat,
456) -> Result<()> {
457 let acc_ids = parse_acc_id_csv(acc_ids)?;
458 if acc_ids.is_empty() {
459 bail!("need at least one acc_id");
460 }
461 let (client, _rx) = connect_gateway(gateway, "futucli-sub-acc-push").await?;
462 futu_trd::misc::sub_acc_push(&client, &acc_ids).await?;
463 println!("✅ sub_acc_push ok: {acc_ids:?}");
464 Ok(())
465}
466
467pub async fn run_unsub_acc_push(
469 gateway: &str,
470 acc_ids: &str,
471 _format: crate::output::OutputFormat,
472) -> Result<()> {
473 let acc_ids = parse_acc_id_csv(acc_ids)?;
474 if acc_ids.is_empty() {
475 bail!("need at least one acc_id");
476 }
477 let (client, _rx) = connect_gateway(gateway, "futucli-unsub-acc-push").await?;
478 futu_trd::misc::unsub_acc_push(&client, &acc_ids).await?;
479 println!("✅ unsub_acc_push ok: {acc_ids:?}");
480 Ok(())
481}
482
483pub async fn run_cancel_all_order(
490 gateway: &str,
491 acc_id: u64,
492 env: &str,
493 market: Option<&str>,
494 jp_acc_type: Option<i32>,
495 confirm: bool,
496 _format: crate::output::OutputFormat,
497) -> Result<()> {
498 let env_p = parse_trd_env(env)?;
499 if matches!(env_p, TrdEnv::Real) && !confirm {
500 bail!("real-env cancel_all_order requires --confirm for safety");
501 }
502 let market_p = match market {
504 Some(m) => parse_trd_market_for_write(m)?,
505 None => {
506 bail!("--market required (HK|US|CN|HKCC); per-account all-markets cancel not wired")
507 }
508 };
509 let header = TrdHeader {
510 trd_env: env_p,
511 acc_id,
512 trd_market: market_p,
513 jp_acc_type,
514 };
515 let params = ModifyOrderParams {
516 header: header.clone(),
517 order_id: 0,
518 order_id_ex: None,
519 modify_order_op: ModifyOrderOp::Cancel,
520 qty: None,
521 price: None,
522 for_all: Some(true),
523 idempotency_key: None,
524 };
525 let (client, _push_rx) = connect_gateway(gateway, "futucli-trade-ext").await?;
526 modify_order(&client, ¶ms).await?;
527 println!(
528 "✅ cancel_all_order ok: acc_id={} env={:?} market={:?}",
529 acc_id, env_p, market_p
530 );
531 Ok(())
532}