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};
62use futu_trd::types::{ModifyOrderOp, ModifyOrderParams, PlaceOrderParams, TrdEnv, TrdHeader};
63
64// ===== place-order =====
65
66pub struct PlaceOrderCommand<'a> {
67    pub gateway: &'a str,
68    pub env: &'a str,
69    pub acc_id: u64,
70    pub market: &'a str,
71    pub side: &'a str,
72    pub order_type: &'a str,
73    pub code: &'a str,
74    pub qty: f64,
75    pub price: Option<f64>,
76    pub jp_acc_type: Option<i32>,
77    pub confirm: bool,
78    pub idempotency_key: Option<String>,
79    // v1.4.53 F1 条件单字段
80    pub stop_price: Option<f64>,
81    pub trail_type: Option<i32>,
82    pub trail_value: Option<f64>,
83    pub trail_spread: Option<f64>,
84    pub output: OutputFormat,
85}
86
87pub async fn run_place_order(input: PlaceOrderCommand<'_>) -> Result<()> {
88    // v1.4.41 P3.6 修: auto key 从 random UUID 改成参数 hash(deterministic)
89    let idempotency_key = resolve_auto_idempotency_key(
90        input.idempotency_key,
91        &IdempotencyParams {
92            acc_id: input.acc_id,
93            market: input.market,
94            code: input.code,
95            side: input.side,
96            qty: input.qty,
97            price: input.price,
98            order_type: input.order_type,
99        },
100    );
101    let env_p = parse_trd_env(input.env)?;
102    let market_p = parse_trd_market_for_write(input.market)?;
103    let side_p = parse_trd_side(input.side)?;
104    let order_type_p = parse_order_type(input.order_type)?;
105
106    // 安全闸:real env 必须 --confirm,防复制粘贴事故
107    if matches!(env_p, TrdEnv::Real) && !input.confirm {
108        bail!(
109            "real-env place_order requires --confirm for safety. \
110             Re-run with --confirm after double-checking all params. \
111             (Or use --env simulate for paper trading.)"
112        );
113    }
114
115    let placing_msg = format!(
116        "placing {} {:?} × {} @ {} {:?} (env={:?}, acc={}, market={:?}, code={})",
117        input.order_type,
118        side_p,
119        input.qty,
120        input.price.unwrap_or(0.0),
121        order_type_p,
122        env_p,
123        input.acc_id,
124        market_p,
125        input.code
126    );
127    if matches!(input.output, OutputFormat::Table) {
128        println!("{placing_msg}");
129    } else {
130        eprintln!("{placing_msg}");
131    }
132
133    let params = PlaceOrderParams {
134        header: TrdHeader {
135            trd_env: env_p,
136            acc_id: input.acc_id,
137            trd_market: market_p,
138            jp_acc_type: input.jp_acc_type,
139        },
140        trd_side: side_p,
141        order_type: order_type_p,
142        code: input.code.to_string(),
143        qty: input.qty,
144        price: input.price,
145        adjust_price: None,
146        adjust_side_and_limit: None,
147        idempotency_key,
148        // v1.4.53 F1 条件单
149        aux_price: input.stop_price,
150        trail_type: input.trail_type,
151        trail_value: input.trail_value,
152        trail_spread: input.trail_spread,
153    };
154
155    let (client, _push_rx) = connect_gateway(input.gateway, "futucli-place-order")
156        .await
157        .context("connect gateway")?;
158    // v1.4.92 D1: 错误时尽量给用户 actionable hint(不改 error chain,纯增量 stderr)
159    let result = match place_order(&client, &params).await {
160        Ok(r) => r,
161        Err(e) => {
162            let wrapped = anyhow::Error::from(e).context("place_order RPC");
163            emit_trade_hint_if_known(&wrapped);
164            return Err(wrapped);
165        }
166    };
167
168    emit_trade_write_success(
169        input.output,
170        TradeWriteSuccess {
171            operation: "place_order",
172            order_id: result.order_id,
173            returned_order_id: None,
174        },
175    )?;
176    if matches!(input.output, OutputFormat::Table) {
177        println!(
178            "   (use `futucli order --market {} --acc-id {} --env {}` to verify)",
179            input.market, input.acc_id, input.env
180        );
181    }
182    Ok(())
183}
184
185// ===== modify-order / cancel-order =====
186
187pub struct ModifyOrderCommand<'a> {
188    pub gateway: &'a str,
189    pub env: &'a str,
190    pub acc_id: u64,
191    pub market: &'a str,
192    pub order_id: String,
193    pub op: &'a str,
194    pub qty: Option<f64>,
195    pub price: Option<f64>,
196    pub jp_acc_type: Option<i32>,
197    pub confirm: bool,
198    pub idempotency_key: Option<String>,
199    pub output: OutputFormat,
200}
201
202pub async fn run_modify_order(input: ModifyOrderCommand<'_>) -> Result<()> {
203    let resolved_order_id = resolve_order_id_arg(&input.order_id)?;
204    // v1.4.41 P3.6 修: ModifyOrder auto key 用 (acc_id, order_id, op, qty, price) hash
205    // market 和 order_id 组合已经 deterministic
206    let idempotency_key = resolve_auto_idempotency_key(
207        input.idempotency_key,
208        &IdempotencyParams {
209            acc_id: input.acc_id,
210            market: input.market,
211            code: "",       // modify 不用 code
212            side: input.op, // op 作 side 字段(反正进 hash)
213            qty: input.qty.unwrap_or(0.0),
214            price: input.price,
215            order_type: &resolved_order_id.idempotency_component, // 用订单身份作差异源
216        },
217    );
218    let env_p = parse_trd_env(input.env)?;
219    let market_p = parse_trd_market_for_write(input.market)?;
220    let op_p = parse_modify_op(input.op)?;
221
222    if matches!(env_p, TrdEnv::Real) && !input.confirm {
223        bail!("real-env modify_order requires --confirm for safety");
224    }
225
226    let params = ModifyOrderParams {
227        header: TrdHeader {
228            trd_env: env_p,
229            acc_id: input.acc_id,
230            trd_market: market_p,
231            jp_acc_type: input.jp_acc_type,
232        },
233        order_id: resolved_order_id.order_id,
234        order_id_ex: resolved_order_id.order_id_ex.clone(),
235        modify_order_op: op_p,
236        qty: input.qty,
237        price: input.price,
238        for_all: None,
239        idempotency_key,
240    };
241
242    let (client, _push_rx) = connect_gateway(input.gateway, "futucli-trade-ext").await?;
243    // v1.4.92 D1: 错误时尝试给 actionable hint(不改 exit code / error chain)
244    let ret_order_id = match modify_order(&client, &params).await {
245        Ok(r) => r,
246        Err(e) => {
247            let wrapped = anyhow::Error::from(e).context("modify_order RPC");
248            emit_trade_hint_if_known(&wrapped);
249            return Err(wrapped);
250        }
251    };
252    emit_trade_write_success(
253        input.output,
254        TradeWriteSuccess {
255            operation: "modify_order",
256            order_id: if resolved_order_id.order_id != 0 {
257                resolved_order_id.order_id
258            } else {
259                ret_order_id
260            },
261            returned_order_id: Some(ret_order_id),
262        },
263    )?;
264    Ok(())
265}
266
267pub struct CancelOrderCommand<'a> {
268    pub gateway: &'a str,
269    pub env: &'a str,
270    pub acc_id: u64,
271    pub market: &'a str,
272    pub order_id: String,
273    pub jp_acc_type: Option<i32>,
274    pub confirm: bool,
275    pub idempotency_key: Option<String>,
276    pub output: OutputFormat,
277}
278
279pub async fn run_cancel_order(input: CancelOrderCommand<'_>) -> Result<()> {
280    let resolved_order_id = resolve_order_id_arg(&input.order_id)?;
281    let env_p = parse_trd_env(input.env)?;
282    let market_p = parse_trd_market_for_write(input.market)?;
283
284    if matches!(env_p, TrdEnv::Real) && !input.confirm {
285        bail!("real-env cancel_order requires --confirm for safety");
286    }
287
288    let header = TrdHeader {
289        trd_env: env_p,
290        acc_id: input.acc_id,
291        trd_market: market_p,
292        jp_acc_type: input.jp_acc_type,
293    };
294    let (client, _push_rx) = connect_gateway(input.gateway, "futucli-trade-ext").await?;
295    let params = ModifyOrderParams {
296        header: header.clone(),
297        order_id: resolved_order_id.order_id,
298        order_id_ex: resolved_order_id.order_id_ex.clone(),
299        modify_order_op: ModifyOrderOp::Cancel,
300        qty: None,
301        price: None,
302        for_all: None,
303        idempotency_key: input.idempotency_key,
304    };
305    // v1.4.92 D1: 错误时尝试给 actionable hint
306    let ret_order_id = match modify_order(&client, &params).await {
307        Ok(id) => id,
308        Err(e) => {
309            let wrapped = anyhow::Error::from(e).context("cancel_order RPC");
310            emit_trade_hint_if_known(&wrapped);
311            return Err(wrapped);
312        }
313    };
314    emit_trade_write_success(
315        input.output,
316        TradeWriteSuccess {
317            operation: "cancel_order",
318            order_id: if resolved_order_id.order_id != 0 {
319                resolved_order_id.order_id
320            } else {
321                ret_order_id
322            },
323            returned_order_id: None,
324        },
325    )?;
326    Ok(())
327}
328
329pub struct ReconfirmOrderCommand<'a> {
330    pub gateway: &'a str,
331    pub env: &'a str,
332    pub acc_id: u64,
333    pub market: &'a str,
334    pub order_id: String,
335    pub reason: i32,
336    pub jp_acc_type: Option<i32>,
337    pub confirm: bool,
338    pub output: OutputFormat,
339}
340
341pub async fn run_reconfirm_order(input: ReconfirmOrderCommand<'_>) -> Result<()> {
342    let parsed_order_id = parse_numeric_order_id_arg(&input.order_id, "--order-id")?;
343    let env_p = parse_trd_env(input.env)?;
344    let market_p = parse_trd_market_for_write(input.market)?;
345
346    if matches!(env_p, TrdEnv::Real) && !input.confirm {
347        bail!("real-env reconfirm_order requires --confirm for safety");
348    }
349
350    let header = TrdHeader {
351        trd_env: env_p,
352        acc_id: input.acc_id,
353        trd_market: market_p,
354        jp_acc_type: input.jp_acc_type,
355    };
356    let (client, _push_rx) = connect_gateway(input.gateway, "futucli-trade-ext").await?;
357    let ret_order_id = match reconfirm_order(&client, &header, parsed_order_id, input.reason).await
358    {
359        Ok(id) => id,
360        Err(e) => {
361            let wrapped = anyhow::Error::from(e).context("reconfirm_order RPC");
362            emit_trade_hint_if_known(&wrapped);
363            return Err(wrapped);
364        }
365    };
366    emit_trade_write_success(
367        input.output,
368        TradeWriteSuccess {
369            operation: "reconfirm_order",
370            order_id: parsed_order_id,
371            returned_order_id: Some(ret_order_id),
372        },
373    )?;
374    Ok(())
375}
376
377/// v1.4.30 P2: 订阅账户推送(订单/成交变更)
378pub async fn run_sub_acc_push(
379    gateway: &str,
380    acc_ids: &[u64],
381    _format: crate::output::OutputFormat,
382) -> Result<()> {
383    if acc_ids.is_empty() {
384        bail!("need at least one acc_id");
385    }
386    let (client, _rx) = connect_gateway(gateway, "futucli-sub-acc-push").await?;
387    futu_trd::misc::sub_acc_push(&client, acc_ids).await?;
388    println!("✅ sub_acc_push ok: {acc_ids:?}");
389    Ok(())
390}
391
392/// v1.4.30:全部撤单(对齐 py-futu-api `cancel_all_order`)
393///
394/// 原理:modify_order proto 带 `for_all=true` + `op=Cancel` + `order_id=0`。
395/// `market` 为 None 时服务端按账户全市场撤(内部填 `TrdMarket::HK` 占位但
396/// 不加 trd_market 约束——当前 Rust TrdHeader 必填 trd_market,所以 None
397/// 时要求用户明示一个市场。真要跨市场撤,用多条命令分别撤)。
398pub async fn run_cancel_all_order(
399    gateway: &str,
400    acc_id: u64,
401    env: &str,
402    market: Option<&str>,
403    jp_acc_type: Option<i32>,
404    confirm: bool,
405    _format: crate::output::OutputFormat,
406) -> Result<()> {
407    let env_p = parse_trd_env(env)?;
408    if matches!(env_p, TrdEnv::Real) && !confirm {
409        bail!("real-env cancel_all_order requires --confirm for safety");
410    }
411    // trd_market 必填(底层 TrdHeader 不允许空);default HK
412    let market_p = match market {
413        Some(m) => parse_trd_market_for_write(m)?,
414        None => {
415            bail!("--market required (HK|US|CN|HKCC); per-account all-markets cancel not wired")
416        }
417    };
418    let header = TrdHeader {
419        trd_env: env_p,
420        acc_id,
421        trd_market: market_p,
422        jp_acc_type,
423    };
424    let params = ModifyOrderParams {
425        header: header.clone(),
426        order_id: 0,
427        order_id_ex: None,
428        modify_order_op: ModifyOrderOp::Cancel,
429        qty: None,
430        price: None,
431        for_all: Some(true),
432        idempotency_key: None,
433    };
434    let (client, _push_rx) = connect_gateway(gateway, "futucli-trade-ext").await?;
435    modify_order(&client, &params).await?;
436    println!(
437        "✅ cancel_all_order ok: acc_id={} env={:?} market={:?}",
438        acc_id, env_p, market_p
439    );
440    Ok(())
441}