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