Skip to main content

futu_mcp/handlers/
trade_write.rs

1//! 交易写 handler:place / modify / cancel / reconfirm
2//!
3//! 本模块的函数本身不做权限检查;调用前由 tools.rs 根据 ServerState 的
4//! `enable_trading` / `allow_real_trading` 做前置守卫。
5
6use std::sync::Arc;
7
8use anyhow::{Result, bail};
9use futu_core::account_locator;
10use futu_net::client::FutuClient;
11use futu_trd::types::{
12    ModifyOrderOp, ModifyOrderParams, OrderType, PlaceOrderParams, TrdEnv, TrdHeader, TrdMarket,
13    TrdSide,
14};
15use serde::Serialize;
16
17/// v1.4.105 D12 (Phase 2): pure fn — 给定 (account list, card_num) 返 matched
18/// acc_id Vec. 与 daemon `TrdCache::find_acc_ids_by_card_num` 行为等价.
19///
20/// 4 位 → 末尾 suffix match (card_num + uni_card_num 都看)
21/// 16 位 → 完整 equal match
22/// 其他 → 空 Vec (caller 应已 validate; 此处容错)
23///
24/// 返 sorted + deduped Vec.
25///
26/// **v1.4.106 codex round 2 F1 case 2 (P1) fix**: 加 `caller_allowed_acc_ids`
27/// snapshot 参数. caller 受限 key (`allowed_acc_ids = Some(set)`) 时, match
28/// 结果先按 snapshot 交集过滤 — 不在 snapshot 中的 acc_id 视作"对该 caller 不
29/// 存在", 防 enumeration via 1-match/0-match/N-match timing 差异.
30///
31/// `None` (full key) → 不做交集过滤 (向后兼容老 master key 行为).
32/// `Some(set)` 空集 → 与 `KeyRecord` / `Limits` contract 一致,视同不限制;
33/// deny-all 使用 fail-closed sentinel `{0}`。
34pub(crate) fn match_card_num_in_accounts(
35    accs: &[futu_trd::account::TrdAcc],
36    card_num: &str,
37    caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
38) -> Vec<u64> {
39    account_locator::match_card_num_in_records(accs, card_num, caller_allowed_acc_ids)
40        .unwrap_or_default()
41}
42
43/// v1.4.105 D12 (Phase 2): client-side card_num → acc_id resolution via daemon
44/// GetAccList RPC. 调用 [`match_card_num_in_accounts`] 做 string match.
45///
46/// 返 Result<u64, String> — Err 是 user-facing message (tool_err 直接 wrap).
47/// - empty / non-digit / 长度非 4/16 → format error (前置 check)
48/// - 0 match → "card_num not found in account list"
49/// - 多 match → "card_num matched N accounts (ambiguous)"
50/// - 1 match → Ok(acc_id)
51///
52/// **v1.4.106 codex round 2 F1 case 2 (P1) fix**: 加 `caller_allowed_acc_ids`
53/// snapshot 参数. 受限 key 调用时, match 仅在 snapshot 内做 — 不在 snapshot
54/// 的 acc_id 视作"对该 caller 不存在", 防 timing-based enumeration 跨 key.
55///
56/// **error message 设计**: 受限 key 看到的 "0 match" 不告诉用户 daemon 总
57/// 账户数 (即 `accs.len()`), 改告 caller-visible 账户数 (即 snapshot size).
58/// 防 daemon-level account count leak.
59pub async fn resolve_card_num_via_get_acc_list(
60    client: &Arc<FutuClient>,
61    card_num: &str,
62    caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
63) -> std::result::Result<u64, String> {
64    let trimmed = match account_locator::validate_card_num_query(card_num) {
65        Ok(v) => v,
66        Err(e) => {
67            return Err(format!(
68                "card_num 格式无效 — 必须 4 位末尾 (App 显示) 或 16 位完整, 纯数字; got len={}",
69                e.len()
70            ));
71        }
72    };
73    let accs = futu_trd::account::get_acc_list_for_account_discovery(client)
74        .await
75        .map_err(|e| format!("get_acc_list (resolve card_num) failed: {e}"))?;
76    let matches = match_card_num_in_accounts(&accs, trimmed, caller_allowed_acc_ids);
77    // 受限 key 的 caller-visible 账户数 = snapshot ∩ daemon-known.
78    // 用于 0-match 错误信息, 不暴露 daemon 总账户数.
79    let visible_count = accs
80        .iter()
81        .filter(|a| account_locator::acc_id_visible_to_caller(a.acc_id, caller_allowed_acc_ids))
82        .count();
83    match account_locator::CardNumResolution::from_acc_ids(matches) {
84        account_locator::CardNumResolution::NotFound => Err(format!(
85            "card_num '{}' 找不到对应账户 (你这个 key 可见 {} 个账户). 检查 daemon 是否登录正确平台 (futunn vs moomoo) + card_num 是否正确 + key 的 allowed_acc_ids 配置",
86            account_locator::redact_card_num(trimmed),
87            visible_count
88        )),
89        account_locator::CardNumResolution::Resolved(only) => Ok(only),
90        account_locator::CardNumResolution::Ambiguous(many) => Err(format!(
91            "card_num '{}' 匹配 {} 个账户 (ambiguous) — 4 位 suffix 在多账户下可能碰撞. 改用 16 位完整卡号 (`futu_list_accounts` 查 card_num 字段)或直接传 acc_id",
92            account_locator::redact_card_num(trimmed),
93            many.len()
94        )),
95    }
96}
97
98/// v1.4.105 D12 (Phase 2): 解析 acc_id from (acc_id, card_num) 二选一输入.
99///
100/// 行为契约 (与 REST `extract_and_resolve_card_num_into_acc_id` 等价语义):
101/// - acc_id != 0 + card_num=None → 用 acc_id (兼容老 client)
102/// - acc_id == 0 + card_num=Some → resolve via GetAccList
103/// - acc_id == 0 + card_num=None → reject (二选一必填)
104/// - acc_id != 0 + card_num=Some → resolve, 校验一致 (resolved == acc_id), 不一致 reject
105///
106/// **v1.4.105 D12 contract-hardening 补丁** (用户审查后要求): 加 `allowed_card_nums`
107/// 参数. caller key 配 `allowed_card_nums` 非空时, user 传 card_num 字符串
108/// 必须 ∈ 白名单 (string-level reject before resolve). 不在 → Err (loud,
109/// "你这个 key 不允许 card_num X").
110///
111/// 与 REST `extract_and_resolve_card_num_into_acc_id_with_resolver` 行为对称.
112///
113/// **v1.4.106 codex round 2 F1 case 2 (P1) fix**: 加 `caller_allowed_acc_ids`
114/// snapshot 参数. 受限 key 调用时, daemon GetAccList 返回的账户列表先按
115/// snapshot 交集过滤 — 不在 snapshot 的 acc_id 视作"对该 caller 不存在".
116/// 防 enumeration: 受限 key 用 4-digit suffix 探测其他用户卡号时, 0-match /
117/// 1-match / N-match timing 不再泄漏 daemon-level 账户存在性.
118///
119/// **`caller_allowed_acc_ids` 语义**:
120/// - `None`: full key (master 模式 / scope 关 / KeyRecord.allowed_acc_ids None)
121///   → 不做交集过滤, 行为同 v1.4.105
122/// - `Some(empty)`: 与 KeyRecord / Limits contract 一致, 等价不限制
123/// - `Some(non_empty_set)`: 受限 key, match 仅在 set 内做
124pub async fn resolve_acc_id_with_card_num(
125    client: &Arc<FutuClient>,
126    acc_id: u64,
127    card_num: Option<&str>,
128    allowed_card_nums: Option<&[String]>,
129    caller_allowed_acc_ids: Option<&std::collections::HashSet<u64>>,
130) -> std::result::Result<u64, String> {
131    match card_num {
132        None => {
133            if acc_id == 0 {
134                Err(
135                    "either acc_id or card_num is required — pass acc_id (call futu_list_accounts to discover) or card_num (4-digit App suffix or 16-digit full)".to_string(),
136                )
137            } else {
138                Ok(acc_id)
139            }
140        }
141        Some(cn) => {
142            // v1.4.105 D12 contract-hardening 补丁: string-level allowed_card_nums
143            // whitelist 校验 (resolve 前). 跟 REST 端 helper 行为对称, 跟
144            // KeyStore::expand 路径互补 (后者 acc_id-level silent enforce).
145            if let Some(allowed) = allowed_card_nums
146                && !allowed.is_empty()
147            {
148                let trimmed = cn.trim();
149                if !account_locator::card_num_allowed_by_whitelist(trimmed, allowed) {
150                    return Err(
151                        "card_num 不在你这个 API key 的 allowed_card_nums 白名单里. \
152                         检查 keys.json 你的 key 配置, 或改用 acc_id 直接传."
153                            .to_string(),
154                    );
155                }
156            }
157            let resolved =
158                resolve_card_num_via_get_acc_list(client, cn, caller_allowed_acc_ids).await?;
159            if acc_id == 0 || acc_id == resolved {
160                Ok(resolved)
161            } else {
162                Err(format!(
163                    "acc_id ({acc_id}) and card_num resolution ({resolved}) mismatch — pass only one or ensure they reference the same account"
164                ))
165            }
166        }
167    }
168}
169
170// ========== 枚举解析(复用只读 handler 的字符串约定) ==========
171
172pub fn parse_trd_market(s: &str) -> Result<TrdMarket> {
173    // v1.4.93 BUG-001 fix (S level ship-blocker): 9 variants 对齐
174    // `Trd_Common.proto::TrdMarket` + MCP schema. 也接 int 值 (per Trd_Common.proto).
175    // 5 国 (SG/AU/JP/MY/CA) + Futures=5 在 v1.4.86-90 只 4 variants 时挂.
176    //
177    // v1.4.102 codex 26 F1 (P1) — write path 拒 HKFUND=113 / USFUND=123:
178    // fund market view-only 真机 verify 仅覆盖 read endpoint. 写路径
179    // (place/modify/cancel order) 接 113/123 → 风险: 用户 fund 账户当主账户
180    // 下单 → backend silent 误路由 / 错误覆盖. read path 仍接 113/123 (见
181    // `crates/futu-mcp/src/handlers/trade.rs::parse_trd_market`).
182    let trimmed = s.trim();
183    let upper = trimmed.to_ascii_uppercase();
184    let m = match upper.as_str() {
185        "HK" | "1" => TrdMarket::HK,
186        "US" | "2" => TrdMarket::US,
187        "CN" | "3" => TrdMarket::CN,
188        "HKCC" | "4" => TrdMarket::HKCC,
189        "FUTURES" | "5" => TrdMarket::Futures,
190        "SG" | "6" => TrdMarket::SG,
191        "AU" | "8" => TrdMarket::AU,
192        "JP" | "15" => TrdMarket::JP,
193        "MY" | "111" => TrdMarket::MY,
194        "CA" | "112" => TrdMarket::CA,
195        // v1.4.102 codex 26 F1 (P1): write path 显式拒 fund market.
196        "HKFUND" | "HK_FUND" | "113" => bail!(
197            "trd market HKFUND (113) 仅支持 view-only read endpoints \
198             (positions/funds/cash-log/history-orders/history-fills); write 路径 \
199             (place_order/modify_order/cancel_order) 用主市场 HK=1, daemon 自动按 \
200             持仓 broker 路由. v1.4.102 codex 26 F1 fix"
201        ),
202        "USFUND" | "US_FUND" | "123" => bail!(
203            "trd market USFUND (123) 仅支持 view-only read endpoints \
204             (positions/funds/cash-log/history-orders/history-fills); write 路径 \
205             (place_order/modify_order/cancel_order) 用主市场 US=2, daemon 自动按 \
206             持仓 broker 路由. v1.4.102 codex 26 F1 fix"
207        ),
208        other => bail!(
209            "unknown trd market {other:?} \
210             (write path 接 HK|US|CN|HKCC|FUTURES|SG|AU|JP|MY|CA \
211             or int 1/2/3/4/5/6/8/15/111/112 per Trd_Common.proto). \
212             fund market HKFUND/USFUND 仅 read path 支持."
213        ),
214    };
215    Ok(m)
216}
217
218pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
219    let e = match s.trim().to_ascii_lowercase().as_str() {
220        "simulate" | "sim" => TrdEnv::Simulate,
221        "real" => TrdEnv::Real,
222        other => bail!("unknown trd env {other:?} (real|simulate)"),
223    };
224    Ok(e)
225}
226
227pub fn parse_trd_side(s: &str) -> Result<TrdSide> {
228    let v = match s.trim().to_ascii_uppercase().as_str() {
229        "BUY" => TrdSide::Buy,
230        "SELL" => TrdSide::Sell,
231        "SELL_SHORT" | "SHORT" => TrdSide::SellShort,
232        "BUY_BACK" | "COVER" => TrdSide::BuyBack,
233        other => bail!("unknown trd side {other:?} (BUY|SELL|SELL_SHORT|BUY_BACK)"),
234    };
235    Ok(v)
236}
237
238pub fn parse_order_type(s: &str) -> Result<OrderType> {
239    // v1.4.93 BUG-001 fix (S level ship-blocker): 17 variants 对齐
240    // `Trd_Common.proto::OrderType` + MCP schema + futu-trd::types::OrderType 完整 enum.
241    // v1.4.53 条件单 (Stop/StopLimit/MIT/LIT/TrailingStop*) + v1.4.85 algo
242    // (TWAP/VWAP) 在 v1.4.86-90 schema-only 时挂. backend `map_order_type`
243    // (futu-gateway/src/handlers/trd/place_order.rs) 已支持全 17.
244    let trimmed = s.trim();
245    let upper = trimmed.to_ascii_uppercase();
246    let v = match upper.as_str() {
247        "NORMAL" | "LIMIT" | "1" => OrderType::Normal,
248        "MARKET" | "2" => OrderType::Market,
249        "ABSOLUTE_LIMIT" | "ABSOLUTELIMIT" | "5" => OrderType::AbsoluteLimit,
250        "AUCTION" | "6" => OrderType::Auction,
251        "AUCTION_LIMIT" | "AUCTIONLIMIT" | "7" => OrderType::AuctionLimit,
252        "SPECIAL_LIMIT" | "SPECIALLIMIT" | "8" => OrderType::SpecialLimit,
253        "SPECIAL_LIMIT_ALL" | "SPECIALLIMITALL" | "9" => OrderType::SpecialLimitAll,
254        // v1.4.53 F1 条件单 (10-15)
255        "STOP" | "10" => OrderType::Stop,
256        "STOP_LIMIT" | "STOP-LIMIT" | "STOPLIMIT" | "11" => OrderType::StopLimit,
257        "MIT" | "MARKET_IF_TOUCHED" | "MARKETIFTOUCHED" | "12" => OrderType::MarketifTouched,
258        "LIT" | "LIMIT_IF_TOUCHED" | "LIMITIFTOUCHED" | "13" => OrderType::LimitifTouched,
259        "TRAIL" | "TRAILING_STOP" | "TRAILING-STOP" | "TRAILINGSTOP" | "14" => {
260            OrderType::TrailingStop
261        }
262        "TRAIL_LIMIT" | "TRAILING_STOP_LIMIT" | "TRAILINGSTOPLIMIT" | "15" => {
263            OrderType::TrailingStopLimit
264        }
265        // v1.4.85 algo (16-19)
266        "TWAP_MARKET" | "TWAPMARKET" | "16" => OrderType::TwapMarket,
267        "TWAP_LIMIT" | "TWAPLIMIT" | "17" => OrderType::TwapLimit,
268        "VWAP_MARKET" | "VWAPMARKET" | "18" => OrderType::VwapMarket,
269        "VWAP_LIMIT" | "VWAPLIMIT" | "19" => OrderType::VwapLimit,
270        other => bail!(
271            "unknown order type {other:?} \
272             (NORMAL|MARKET|ABSOLUTE_LIMIT|AUCTION|AUCTION_LIMIT|\
273             SPECIAL_LIMIT|SPECIAL_LIMIT_ALL|STOP|STOP_LIMIT|MIT|LIT|\
274             TRAILING_STOP|TRAILING_STOP_LIMIT|TWAP_MARKET|TWAP_LIMIT|\
275             VWAP_MARKET|VWAP_LIMIT \
276             or int 1/2/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19 per Trd_Common.proto)"
277        ),
278    };
279    Ok(v)
280}
281
282pub fn parse_modify_op(s: &str) -> Result<ModifyOrderOp> {
283    let v = match s.trim().to_ascii_uppercase().as_str() {
284        "NORMAL" | "MODIFY" => ModifyOrderOp::Normal,
285        "CANCEL" => ModifyOrderOp::Cancel,
286        "DISABLE" => ModifyOrderOp::Disable,
287        "ENABLE" => ModifyOrderOp::Enable,
288        "DELETE" => ModifyOrderOp::Delete,
289        other => bail!("unknown modify op {other:?} (NORMAL|CANCEL|DISABLE|ENABLE|DELETE)"),
290    };
291    Ok(v)
292}
293
294fn build_header(env: &str, acc_id: u64, market: &str) -> Result<TrdHeader> {
295    Ok(TrdHeader {
296        trd_env: parse_trd_env(env)?,
297        acc_id,
298        trd_market: parse_trd_market(market)?,
299        jp_acc_type: None,
300    })
301}
302
303// ========== place ==========
304
305#[derive(Serialize)]
306struct PlaceOut {
307    order_id: u64,
308    env: &'static str,
309    market: String,
310    acc_id: u64,
311    side: String,
312    order_type: String,
313    code: String,
314    qty: f64,
315    price: Option<f64>,
316}
317
318pub struct PlaceOrderInput<'a> {
319    pub env: &'a str,
320    pub acc_id: u64,
321    pub market: &'a str,
322    pub side: &'a str,
323    pub order_type: &'a str,
324    pub code: &'a str,
325    pub qty: f64,
326    pub price: Option<f64>,
327    pub idempotency_key: Option<String>,
328    // v1.4.53 F1 条件单
329    pub stop_price: Option<f64>,
330    pub trail_type: Option<i32>,
331    pub trail_value: Option<f64>,
332    pub trail_spread: Option<f64>,
333}
334
335pub async fn place_order(client: &Arc<FutuClient>, input: PlaceOrderInput<'_>) -> Result<String> {
336    let header = build_header(input.env, input.acc_id, input.market)?;
337    let trd_side = parse_trd_side(input.side)?;
338    let ord_type = parse_order_type(input.order_type)?;
339
340    let params = PlaceOrderParams {
341        header: header.clone(),
342        trd_side,
343        order_type: ord_type,
344        code: input.code.to_string(),
345        qty: input.qty,
346        price: input.price,
347        adjust_price: None,
348        adjust_side_and_limit: None,
349        idempotency_key: input.idempotency_key,
350        // v1.4.53 F1 条件单:透传 stop_price / trail_* 到 futu_trd
351        aux_price: input.stop_price,
352        trail_type: input.trail_type,
353        trail_value: input.trail_value,
354        trail_spread: input.trail_spread,
355    };
356    let res = futu_trd::order::place_order(client, &params).await?;
357
358    let out = PlaceOut {
359        order_id: res.order_id,
360        env: match header.trd_env {
361            TrdEnv::Simulate => "simulate",
362            TrdEnv::Real => "real",
363            _ => "unknown",
364        },
365        market: input.market.to_ascii_uppercase(),
366        acc_id: input.acc_id,
367        side: input.side.to_ascii_uppercase(),
368        order_type: input.order_type.to_ascii_uppercase(),
369        code: input.code.to_string(),
370        qty: input.qty,
371        price: input.price,
372    };
373    Ok(serde_json::to_string_pretty(&out)?)
374}
375
376// ========== modify ==========
377
378#[derive(Serialize)]
379struct ModifyOut {
380    order_id: u64,
381    op: String,
382    env: &'static str,
383    qty: Option<f64>,
384    price: Option<f64>,
385}
386
387pub struct ModifyOrderInput<'a> {
388    pub env: &'a str,
389    pub acc_id: u64,
390    pub market: &'a str,
391    pub order_id: &'a str,
392    pub op: &'a str,
393    pub qty: Option<f64>,
394    pub price: Option<f64>,
395    pub idempotency_key: Option<String>,
396}
397
398struct ResolvedOrderIdArg {
399    order_id: u64,
400    order_id_ex: Option<String>,
401}
402
403fn resolve_order_id_arg(raw: &str) -> Result<ResolvedOrderIdArg> {
404    let trimmed = raw.trim();
405    if trimmed.is_empty() {
406        bail!("order_id must not be empty");
407    }
408
409    // C++ APIServer_Trd_ModifyOrder.cpp hashes orderIDEx into orderID before
410    // local lookup/validation; clients may pass either numeric orderID or FU/FH
411    // orderIDEx.
412    if trimmed.bytes().all(|b| b.is_ascii_digit()) {
413        return Ok(ResolvedOrderIdArg {
414            order_id: trimmed.parse::<u64>()?,
415            order_id_ex: None,
416        });
417    }
418
419    Ok(ResolvedOrderIdArg {
420        order_id: 0,
421        order_id_ex: Some(trimmed.to_string()),
422    })
423}
424
425fn parse_numeric_order_id_arg(raw: &str, field: &str) -> Result<u64> {
426    let trimmed = raw.trim();
427    if trimmed.is_empty() {
428        bail!("{field} must not be empty");
429    }
430    if !trimmed.bytes().all(|b| b.is_ascii_digit()) {
431        bail!(
432            "{field} for futu_reconfirm_order must be numeric FTAPI order_id; \
433             orderIDEx is not supported by Trd_ReconfirmOrder"
434        );
435    }
436    Ok(trimmed.parse::<u64>()?)
437}
438
439pub async fn modify_order(client: &Arc<FutuClient>, input: ModifyOrderInput<'_>) -> Result<String> {
440    let header = build_header(input.env, input.acc_id, input.market)?;
441    let mop = parse_modify_op(input.op)?;
442    let resolved_order_id = resolve_order_id_arg(input.order_id)?;
443
444    let params = ModifyOrderParams {
445        header: header.clone(),
446        order_id: resolved_order_id.order_id,
447        order_id_ex: resolved_order_id.order_id_ex,
448        modify_order_op: mop,
449        qty: input.qty,
450        price: input.price,
451        for_all: None,
452        idempotency_key: input.idempotency_key,
453    };
454    let returned_id = futu_trd::order::modify_order(client, &params).await?;
455
456    let out = ModifyOut {
457        order_id: returned_id,
458        op: input.op.to_ascii_uppercase(),
459        env: match header.trd_env {
460            TrdEnv::Simulate => "simulate",
461            TrdEnv::Real => "real",
462            _ => "unknown",
463        },
464        qty: input.qty,
465        price: input.price,
466    };
467    Ok(serde_json::to_string_pretty(&out)?)
468}
469
470// ========== cancel ==========
471
472#[derive(Serialize)]
473struct CancelOut {
474    order_id: u64,
475    op: &'static str,
476    env: &'static str,
477}
478
479pub async fn cancel_order(
480    client: &Arc<FutuClient>,
481    env: &str,
482    acc_id: u64,
483    market: &str,
484    order_id: &str,
485    idempotency_key: Option<String>,
486) -> Result<String> {
487    let header = build_header(env, acc_id, market)?;
488    let resolved_order_id = resolve_order_id_arg(order_id)?;
489    // v1.4.39: cancel_order 本质是 modify_order 的 shortcut。走 modify_order 路径
490    // 以支持 idempotency_key(`futu_trd::order::cancel_order` helper 不接 key)。
491    let params = ModifyOrderParams {
492        header: header.clone(),
493        order_id: resolved_order_id.order_id,
494        order_id_ex: resolved_order_id.order_id_ex,
495        modify_order_op: futu_trd::types::ModifyOrderOp::Cancel,
496        qty: None,
497        price: None,
498        for_all: None,
499        idempotency_key,
500    };
501    let returned_id = futu_trd::order::modify_order(client, &params).await?;
502    let out = CancelOut {
503        order_id: returned_id,
504        op: "CANCEL",
505        env: match header.trd_env {
506            TrdEnv::Simulate => "simulate",
507            TrdEnv::Real => "real",
508            _ => "unknown",
509        },
510    };
511    Ok(serde_json::to_string_pretty(&out)?)
512}
513
514// ========== reconfirm ==========
515
516#[derive(Serialize)]
517struct ReconfirmOut {
518    order_id: u64,
519    reason: i32,
520    env: &'static str,
521}
522
523pub struct ReconfirmOrderInput<'a> {
524    pub env: &'a str,
525    pub acc_id: u64,
526    pub market: &'a str,
527    pub order_id: &'a str,
528    pub reason: i32,
529}
530
531pub async fn reconfirm_order(
532    client: &Arc<FutuClient>,
533    input: ReconfirmOrderInput<'_>,
534) -> Result<String> {
535    let header = build_header(input.env, input.acc_id, input.market)?;
536    let order_id = parse_numeric_order_id_arg(input.order_id, "order_id")?;
537    let returned_id =
538        futu_trd::misc::reconfirm_order(client, &header, order_id, input.reason).await?;
539    let out = ReconfirmOut {
540        order_id: returned_id,
541        reason: input.reason,
542        env: match header.trd_env {
543            TrdEnv::Simulate => "simulate",
544            TrdEnv::Real => "real",
545            _ => "unknown",
546        },
547    };
548    Ok(serde_json::to_string_pretty(&out)?)
549}
550
551#[derive(Serialize)]
552struct CancelAllOut {
553    op: &'static str,
554    env: &'static str,
555    acc_id: u64,
556    market: String,
557}
558
559/// 全部撤单。内部用 ModifyOrder proto 带 for_all=true + op=Cancel + order_id=0。
560/// 风险提示:立即撤销该账户**指定市场**(market 空时全账户)所有 pending
561/// 订单,不可恢复。
562pub async fn cancel_all_order(
563    client: &Arc<FutuClient>,
564    env: &str,
565    acc_id: u64,
566    market: &str,
567) -> Result<String> {
568    let header = build_header(env, acc_id, market)?;
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    futu_trd::order::modify_order(client, &params).await?;
580    let out = CancelAllOut {
581        op: "CANCEL_ALL",
582        env: match header.trd_env {
583            TrdEnv::Simulate => "simulate",
584            TrdEnv::Real => "real",
585            _ => "unknown",
586        },
587        acc_id,
588        market: market.to_string(),
589    };
590    Ok(serde_json::to_string_pretty(&out)?)
591}
592
593// ========== 环境守卫 ==========
594
595/// 判断给定的 env 字符串是否指向真实环境。
596pub fn is_real_env(env: &str) -> bool {
597    matches!(env.trim().to_ascii_lowercase().as_str(), "real")
598}
599
600// ========== v1.4.93 BUG-001 Tests (runtime parser 9 + 17) ==========
601
602#[cfg(test)]
603mod tests;