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