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