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, PlaceOrderOptions, PlaceOrderParams, TrdEnv, TrdHeader,
70};
71
72// ===== place-order =====
73
74pub 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    // v1.4.53 F1 条件单字段
94    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    // v1.4.41 P3.6 修: auto key 从 random UUID 改成参数 hash(deterministic)
167    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    // 安全闸:real env 必须 --confirm,防复制粘贴事故
189    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        // v1.4.53 F1 条件单
234        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    // v1.4.92 D1: 错误时尽量给用户 actionable hint(不改 error chain,纯增量 stderr)
245    let result = match place_order_with_options_and_identity(&client, &params, &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
272// ===== modify-order / cancel-order =====
273
274pub 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    // v1.4.41 P3.6 修: ModifyOrder auto key 用 (acc_id, order_id, op, qty, price) hash
292    // market 和 order_id 组合已经 deterministic
293    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: "",       // modify 不用 code
299            side: input.op, // op 作 side 字段(反正进 hash)
300            qty: input.qty.unwrap_or(0.0),
301            price: input.price,
302            order_type: &resolved_order_id.idempotency_component, // 用订单身份作差异源
303            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    // v1.4.92 D1: 错误时尝试给 actionable hint(不改 exit code / error chain)
333    let ret_order_id = match modify_order(&client, &params).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    // v1.4.92 D1: 错误时尝试给 actionable hint
424    let ret_order_id = match modify_order(&client, &params).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
497/// v1.4.30 P2: 订阅账户推送(订单/成交变更)
498/// 解析逗号分隔的 acc_id 列表。dispatch arm 收薄: 把 CSV → Vec<u64> 解析下推到此处。
499fn 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
521/// v1.4.113: 取消订阅账户推送(订单/成交变更)。
522pub 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
537/// v1.4.30:全部撤单(对齐 py-futu-api `cancel_all_order`)
538///
539/// 原理:modify_order proto 带 `for_all=true` + `op=Cancel` + `order_id=0`。
540/// `market` 为 None 时服务端按账户全市场撤(内部填 `TrdMarket::HK` 占位但
541/// 不加 trd_market 约束——当前 Rust TrdHeader 必填 trd_market,所以 None
542/// 时要求用户明示一个市场。真要跨市场撤,用多条命令分别撤)。
543pub 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    // trd_market 必填(底层 TrdHeader 不允许空);default HK
557    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, &params).await?;
581    println!(
582        "✅ cancel_all_order ok: acc_id={} env={:?} market={:?}",
583        acc_id, env_p, market_p
584    );
585    Ok(())
586}