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 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
66// ===== place-order =====
67
68pub 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    // v1.4.53 F1 条件单字段
86    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    // v1.4.41 P3.6 修: auto key 从 random UUID 改成参数 hash(deterministic)
144    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    // 安全闸:real env 必须 --confirm,防复制粘贴事故
164    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        // v1.4.53 F1 条件单
209        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    // v1.4.92 D1: 错误时尽量给用户 actionable hint(不改 error chain,纯增量 stderr)
225    let result = match place_order_with_options(&client, &params, &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
251// ===== modify-order / cancel-order =====
252
253pub 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    // v1.4.41 P3.6 修: ModifyOrder auto key 用 (acc_id, order_id, op, qty, price) hash
271    // market 和 order_id 组合已经 deterministic
272    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: "",       // modify 不用 code
278            side: input.op, // op 作 side 字段(反正进 hash)
279            qty: input.qty.unwrap_or(0.0),
280            price: input.price,
281            order_type: &resolved_order_id.idempotency_component, // 用订单身份作差异源
282        },
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    // v1.4.92 D1: 错误时尝试给 actionable hint(不改 exit code / error chain)
310    let ret_order_id = match modify_order(&client, &params).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    // v1.4.92 D1: 错误时尝试给 actionable hint
372    let ret_order_id = match modify_order(&client, &params).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
443/// v1.4.30 P2: 订阅账户推送(订单/成交变更)
444/// 解析逗号分隔的 acc_id 列表。dispatch arm 收薄: 把 CSV → Vec<u64> 解析下推到此处。
445fn 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
467/// v1.4.113: 取消订阅账户推送(订单/成交变更)。
468pub 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
483/// v1.4.30:全部撤单(对齐 py-futu-api `cancel_all_order`)
484///
485/// 原理:modify_order proto 带 `for_all=true` + `op=Cancel` + `order_id=0`。
486/// `market` 为 None 时服务端按账户全市场撤(内部填 `TrdMarket::HK` 占位但
487/// 不加 trd_market 约束——当前 Rust TrdHeader 必填 trd_market,所以 None
488/// 时要求用户明示一个市场。真要跨市场撤,用多条命令分别撤)。
489pub 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    // trd_market 必填(底层 TrdHeader 不允许空);default HK
503    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, &params).await?;
527    println!(
528        "✅ cancel_all_order ok: acc_id={} env={:?} market={:?}",
529        acc_id, env_p, market_p
530    );
531    Ok(())
532}