Skip to main content

futucli/cmd/
trade_ext.rs

1//! `futucli` 交易扩展命令(v1.4.25):place-order / modify-order /
2//! cancel-order / reconfirm-order / history-orders / history-deals / max-qtys
3//!
4//! 设计原则:
5//! - **place-order 强制 `--confirm`**:防误操作 / 防复制粘贴事故
6//! - **所有命令要求 gateway 已 unlock**(写操作路径,network 路径里会 err)
7//! - **sim 环境默认**:env 没显式传时**默认 simulate**,减少实盘误触
8//! - **表格输出 + JSON 输出双栈**:和现有 `account.rs` 一致
9//!
10//! 对齐 Futu 官方 Python SDK(`FutunnOpen/py-futu-api`):
11//! - place-order → `OpenTradeContext.place_order`
12//! - modify-order → `OpenTradeContext.modify_order`
13//! - cancel-order → `OpenTradeContext.modify_order(op=CANCEL)`
14//! - reconfirm-order → `OpenTradeContext.reconfirm_order`
15//! - history-orders → `OpenTradeContext.history_order_list_query`
16//! - history-deals → `OpenTradeContext.history_deal_list_query`
17//! - max-qtys → `OpenTradeContext.acctradinginfo_query`
18
19use 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
73// ===== place-order =====
74
75pub 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    // v1.4.53 F1 条件单字段
95    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    // v1.4.41 P3.6 修: auto key 从 random UUID 改成参数 hash(deterministic)
168    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    // 安全闸:real env 必须 --confirm,防复制粘贴事故
196    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        // v1.4.53 F1 条件单
241        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    // v1.4.92 D1: 错误时尽量给用户 actionable hint(不改 error chain,纯增量 stderr)
252    let result = match place_order_with_options_and_identity(&client, &params, &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
279// ===== modify-order / cancel-order =====
280
281pub 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    // v1.4.41 P3.6 修: ModifyOrder auto key 用 (acc_id, order_id, op, qty, price) hash
299    // market 和 order_id 组合已经 deterministic
300    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: "",       // modify 不用 code
306            side: input.op, // op 作 side 字段(反正进 hash)
307            qty: input.qty.unwrap_or(0.0),
308            price: input.price,
309            order_type: &resolved_order_id.idempotency_component, // 用订单身份作差异源
310            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    // v1.4.92 D1: 错误时尝试给 actionable hint(不改 exit code / error chain)
340    let ret_order_id = match modify_order(&client, &params).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    // v1.4.92 D1: 错误时尝试给 actionable hint
431    let ret_order_id = match modify_order(&client, &params).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
504/// v1.4.30 P2: 订阅账户推送(订单/成交变更)
505/// 解析逗号分隔的 acc_id 列表。dispatch arm 收薄: 把 CSV → Vec<u64> 解析下推到此处。
506fn 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
528/// v1.4.113: 取消订阅账户推送(订单/成交变更)。
529pub 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
544/// v1.4.30:全部撤单(对齐 py-futu-api `cancel_all_order`)
545///
546/// 原理:modify_order proto 带 `for_all=true` + `op=Cancel` + `order_id=0`。
547/// `market` 为 None 时服务端按账户全市场撤(内部填 `TrdMarket::HK` 占位但
548/// 不加 trd_market 约束——当前 Rust TrdHeader 必填 trd_market,所以 None
549/// 时要求用户明示一个市场。真要跨市场撤,用多条命令分别撤)。
550pub 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    // trd_market 必填(底层 TrdHeader 不允许空);default HK
564    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, &params).await?;
588    println!(
589        "✅ cancel_all_order ok: acc_id={} env={:?} market={:?}",
590        acc_id, env_p, market_p
591    );
592    Ok(())
593}