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/v1.4.111: 对齐 `Trd_Common.proto::TrdMarket` 官方非 fund 值。
174    //
175    // v1.4.102/v1.4.111 — write path 拒 fund markets:
176    // fund market view-only 真机 verify 仅覆盖 read endpoint. 写路径
177    // (place/modify/cancel order) 接 fund market → 风险: 用户 fund 账户当主账户
178    // 下单 → backend silent 误路由 / 错误覆盖. read path 仍接 113/123 (见
179    // `crates/futu-mcp/src/handlers/trade.rs::parse_trd_market`).
180    let trimmed = s.trim();
181    let upper = trimmed.to_ascii_uppercase();
182    let m = match upper.as_str() {
183        "HK" | "1" => TrdMarket::HK,
184        "US" | "2" => TrdMarket::US,
185        "CN" | "3" => TrdMarket::CN,
186        "HKCC" | "4" => TrdMarket::HKCC,
187        "FUTURES" | "5" => TrdMarket::Futures,
188        "SG" | "6" => TrdMarket::SG,
189        "CRYPTO" | "7" => TrdMarket::Crypto,
190        "AU" | "8" => TrdMarket::AU,
191        "FUTURES_SIMULATE_HK" | "FUTURESSIMULATEHK" | "10" => TrdMarket::FuturesSimulateHK,
192        "FUTURES_SIMULATE_US" | "FUTURESSIMULATEUS" | "11" => TrdMarket::FuturesSimulateUS,
193        "FUTURES_SIMULATE_SG" | "FUTURESSIMULATESG" | "12" => TrdMarket::FuturesSimulateSG,
194        "FUTURES_SIMULATE_JP" | "FUTURESSIMULATEJP" | "13" => TrdMarket::FuturesSimulateJP,
195        "JP" | "15" => TrdMarket::JP,
196        "MY" | "111" => TrdMarket::MY,
197        "CA" | "112" => TrdMarket::CA,
198        "HKFUND" | "HK_FUND" | "113" => return bail_fund_market("HKFund"),
199        "USFUND" | "US_FUND" | "123" => return bail_fund_market("USFund"),
200        "SGFUND" | "SG_FUND" | "124" => return bail_fund_market("SGFund"),
201        "MYFUND" | "MY_FUND" | "125" => return bail_fund_market("MYFund"),
202        "JPFUND" | "JP_FUND" | "126" => return bail_fund_market("JPFund"),
203        other => bail!(
204            "unknown trd market {other:?} \
205             (write path 接 HK|US|CN|HKCC|FUTURES|SG|CRYPTO|AU|FUTURES_SIMULATE_HK|\
206             FUTURES_SIMULATE_US|FUTURES_SIMULATE_SG|FUTURES_SIMULATE_JP|JP|MY|CA \
207             or official non-fund TrdMarket int). fund markets 仅 read path 支持."
208        ),
209    };
210    Ok(m)
211}
212
213fn bail_fund_market(label: &'static str) -> Result<TrdMarket> {
214    bail!(
215        "trd market {label} 仅支持 view-only read endpoints \
216         (positions/funds/cash-log/history-orders/history-fills); write 路径 \
217         (place_order/modify_order/cancel_order) 用对应主市场, daemon 自动按 \
218         持仓 broker 路由. v1.4.102 audit 26 F1 fix"
219    )
220}
221
222pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
223    let e = match s.trim().to_ascii_lowercase().as_str() {
224        "simulate" | "sim" => TrdEnv::Simulate,
225        "real" => TrdEnv::Real,
226        other => bail!("unknown trd env {other:?} (real|simulate)"),
227    };
228    Ok(e)
229}
230
231pub fn parse_trd_side(s: &str) -> Result<TrdSide> {
232    let v = match s.trim().to_ascii_uppercase().as_str() {
233        "BUY" => TrdSide::Buy,
234        "SELL" => TrdSide::Sell,
235        "SELL_SHORT" | "SHORT" => TrdSide::SellShort,
236        "BUY_BACK" | "COVER" => TrdSide::BuyBack,
237        other => bail!("unknown trd side {other:?} (BUY|SELL|SELL_SHORT|BUY_BACK)"),
238    };
239    Ok(v)
240}
241
242pub fn parse_order_type(s: &str) -> Result<OrderType> {
243    // v1.4.93 BUG-001 fix (S level ship-blocker): 17 variants 对齐
244    // `Trd_Common.proto::OrderType` + MCP schema + futu-trd::types::OrderType 完整 enum.
245    // v1.4.53 条件单 (Stop/StopLimit/MIT/LIT/TrailingStop*) + v1.4.85 algo
246    // (TWAP/VWAP) 在 v1.4.86-90 schema-only 时挂. backend `map_order_type`
247    // (futu-gateway/src/handlers/trd/place_order.rs) 已支持全 17.
248    let trimmed = s.trim();
249    let upper = trimmed.to_ascii_uppercase();
250    let v = match upper.as_str() {
251        "NORMAL" | "LIMIT" | "1" => OrderType::Normal,
252        "MARKET" | "2" => OrderType::Market,
253        "ABSOLUTE_LIMIT" | "ABSOLUTELIMIT" | "5" => OrderType::AbsoluteLimit,
254        "AUCTION" | "6" => OrderType::Auction,
255        "AUCTION_LIMIT" | "AUCTIONLIMIT" | "7" => OrderType::AuctionLimit,
256        "SPECIAL_LIMIT" | "SPECIALLIMIT" | "8" => OrderType::SpecialLimit,
257        "SPECIAL_LIMIT_ALL" | "SPECIALLIMITALL" | "9" => OrderType::SpecialLimitAll,
258        // v1.4.53 F1 条件单 (10-15)
259        "STOP" | "10" => OrderType::Stop,
260        "STOP_LIMIT" | "STOP-LIMIT" | "STOPLIMIT" | "11" => OrderType::StopLimit,
261        "MIT" | "MARKET_IF_TOUCHED" | "MARKETIFTOUCHED" | "12" => OrderType::MarketifTouched,
262        "LIT" | "LIMIT_IF_TOUCHED" | "LIMITIFTOUCHED" | "13" => OrderType::LimitifTouched,
263        "TRAIL" | "TRAILING_STOP" | "TRAILING-STOP" | "TRAILINGSTOP" | "14" => {
264            OrderType::TrailingStop
265        }
266        "TRAIL_LIMIT" | "TRAILING_STOP_LIMIT" | "TRAILINGSTOPLIMIT" | "15" => {
267            OrderType::TrailingStopLimit
268        }
269        // v1.4.85 algo (16-19)
270        "TWAP_MARKET" | "TWAPMARKET" | "16" => OrderType::TwapMarket,
271        "TWAP_LIMIT" | "TWAPLIMIT" | "17" => OrderType::TwapLimit,
272        "VWAP_MARKET" | "VWAPMARKET" | "18" => OrderType::VwapMarket,
273        "VWAP_LIMIT" | "VWAPLIMIT" | "19" => OrderType::VwapLimit,
274        other => bail!(
275            "unknown order type {other:?} \
276             (NORMAL|MARKET|ABSOLUTE_LIMIT|AUCTION|AUCTION_LIMIT|\
277             SPECIAL_LIMIT|SPECIAL_LIMIT_ALL|STOP|STOP_LIMIT|MIT|LIT|\
278             TRAILING_STOP|TRAILING_STOP_LIMIT|TWAP_MARKET|TWAP_LIMIT|\
279             VWAP_MARKET|VWAP_LIMIT \
280             or int 1/2/5/6/7/8/9/10/11/12/13/14/15/16/17/18/19 per Trd_Common.proto)"
281        ),
282    };
283    Ok(v)
284}
285
286pub fn parse_modify_op(s: &str) -> Result<ModifyOrderOp> {
287    let v = match s.trim().to_ascii_uppercase().as_str() {
288        "NORMAL" | "MODIFY" => ModifyOrderOp::Normal,
289        "CANCEL" => ModifyOrderOp::Cancel,
290        "DISABLE" => ModifyOrderOp::Disable,
291        "ENABLE" => ModifyOrderOp::Enable,
292        "DELETE" => ModifyOrderOp::Delete,
293        other => bail!("unknown modify op {other:?} (NORMAL|CANCEL|DISABLE|ENABLE|DELETE)"),
294    };
295    Ok(v)
296}
297
298fn build_header(
299    env: &str,
300    acc_id: u64,
301    market: &str,
302    jp_acc_type: Option<i32>,
303) -> Result<TrdHeader> {
304    Ok(TrdHeader {
305        trd_env: parse_trd_env(env)?,
306        acc_id,
307        trd_market: parse_trd_market(market)?,
308        jp_acc_type,
309    })
310}
311
312// ========== place ==========
313
314#[derive(Serialize)]
315struct PlaceOut {
316    order_id: u64,
317    env: &'static str,
318    market: String,
319    acc_id: u64,
320    side: String,
321    order_type: String,
322    code: String,
323    qty: f64,
324    price: Option<f64>,
325}
326
327pub struct PlaceOrderInput<'a> {
328    pub env: &'a str,
329    pub acc_id: u64,
330    pub market: &'a str,
331    pub side: &'a str,
332    pub order_type: &'a str,
333    pub code: &'a str,
334    pub qty: f64,
335    pub price: Option<f64>,
336    pub jp_acc_type: Option<i32>,
337    pub idempotency_key: Option<String>,
338    // v1.4.53 F1 条件单
339    pub stop_price: Option<f64>,
340    pub trail_type: Option<i32>,
341    pub trail_value: Option<f64>,
342    pub trail_spread: Option<f64>,
343}
344
345pub async fn place_order(client: &Arc<FutuClient>, input: PlaceOrderInput<'_>) -> Result<String> {
346    let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
347    let trd_side = parse_trd_side(input.side)?;
348    let ord_type = parse_order_type(input.order_type)?;
349
350    let params = PlaceOrderParams {
351        header: header.clone(),
352        trd_side,
353        order_type: ord_type,
354        code: input.code.to_string(),
355        qty: input.qty,
356        price: input.price,
357        adjust_price: None,
358        adjust_side_and_limit: None,
359        idempotency_key: input.idempotency_key,
360        // v1.4.53 F1 条件单:透传 stop_price / trail_* 到 futu_trd
361        aux_price: input.stop_price,
362        trail_type: input.trail_type,
363        trail_value: input.trail_value,
364        trail_spread: input.trail_spread,
365    };
366    let res = futu_trd::order::place_order(client, &params).await?;
367
368    let out = PlaceOut {
369        order_id: res.order_id,
370        env: match header.trd_env {
371            TrdEnv::Simulate => "simulate",
372            TrdEnv::Real => "real",
373            _ => "unknown",
374        },
375        market: input.market.to_ascii_uppercase(),
376        acc_id: input.acc_id,
377        side: input.side.to_ascii_uppercase(),
378        order_type: input.order_type.to_ascii_uppercase(),
379        code: input.code.to_string(),
380        qty: input.qty,
381        price: input.price,
382    };
383    Ok(serde_json::to_string_pretty(&out)?)
384}
385
386// ========== modify ==========
387
388#[derive(Serialize)]
389struct ModifyOut {
390    order_id: u64,
391    op: String,
392    env: &'static str,
393    qty: Option<f64>,
394    price: Option<f64>,
395}
396
397pub struct ModifyOrderInput<'a> {
398    pub env: &'a str,
399    pub acc_id: u64,
400    pub market: &'a str,
401    pub order_id: &'a str,
402    pub op: &'a str,
403    pub qty: Option<f64>,
404    pub price: Option<f64>,
405    pub jp_acc_type: Option<i32>,
406    pub idempotency_key: Option<String>,
407}
408
409struct ResolvedOrderIdArg {
410    order_id: u64,
411    order_id_ex: Option<String>,
412}
413
414fn resolve_order_id_arg(raw: &str) -> Result<ResolvedOrderIdArg> {
415    let trimmed = raw.trim();
416    if trimmed.is_empty() {
417        bail!("order_id must not be empty");
418    }
419
420    // C++ APIServer_Trd_ModifyOrder.cpp hashes orderIDEx into orderID before
421    // local lookup/validation; clients may pass either numeric orderID or FU/FH
422    // orderIDEx.
423    if trimmed.bytes().all(|b| b.is_ascii_digit()) {
424        return Ok(ResolvedOrderIdArg {
425            order_id: trimmed.parse::<u64>()?,
426            order_id_ex: None,
427        });
428    }
429
430    Ok(ResolvedOrderIdArg {
431        order_id: 0,
432        order_id_ex: Some(trimmed.to_string()),
433    })
434}
435
436fn parse_numeric_order_id_arg(raw: &str, field: &str) -> Result<u64> {
437    let trimmed = raw.trim();
438    if trimmed.is_empty() {
439        bail!("{field} must not be empty");
440    }
441    if !trimmed.bytes().all(|b| b.is_ascii_digit()) {
442        bail!(
443            "{field} for futu_reconfirm_order must be numeric FTAPI order_id; \
444             orderIDEx is not supported by Trd_ReconfirmOrder"
445        );
446    }
447    Ok(trimmed.parse::<u64>()?)
448}
449
450pub async fn modify_order(client: &Arc<FutuClient>, input: ModifyOrderInput<'_>) -> Result<String> {
451    let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
452    let mop = parse_modify_op(input.op)?;
453    let resolved_order_id = resolve_order_id_arg(input.order_id)?;
454
455    let params = ModifyOrderParams {
456        header: header.clone(),
457        order_id: resolved_order_id.order_id,
458        order_id_ex: resolved_order_id.order_id_ex,
459        modify_order_op: mop,
460        qty: input.qty,
461        price: input.price,
462        for_all: None,
463        idempotency_key: input.idempotency_key,
464    };
465    let returned_id = futu_trd::order::modify_order(client, &params).await?;
466
467    let out = ModifyOut {
468        order_id: returned_id,
469        op: input.op.to_ascii_uppercase(),
470        env: match header.trd_env {
471            TrdEnv::Simulate => "simulate",
472            TrdEnv::Real => "real",
473            _ => "unknown",
474        },
475        qty: input.qty,
476        price: input.price,
477    };
478    Ok(serde_json::to_string_pretty(&out)?)
479}
480
481// ========== cancel ==========
482
483#[derive(Serialize)]
484struct CancelOut {
485    order_id: u64,
486    op: &'static str,
487    env: &'static str,
488}
489
490pub async fn cancel_order(
491    client: &Arc<FutuClient>,
492    env: &str,
493    acc_id: u64,
494    market: &str,
495    order_id: &str,
496    jp_acc_type: Option<i32>,
497    idempotency_key: Option<String>,
498) -> Result<String> {
499    let header = build_header(env, acc_id, market, jp_acc_type)?;
500    let resolved_order_id = resolve_order_id_arg(order_id)?;
501    // v1.4.39: cancel_order 本质是 modify_order 的 shortcut。走 modify_order 路径
502    // 以支持 idempotency_key(`futu_trd::order::cancel_order` helper 不接 key)。
503    let params = ModifyOrderParams {
504        header: header.clone(),
505        order_id: resolved_order_id.order_id,
506        order_id_ex: resolved_order_id.order_id_ex,
507        modify_order_op: futu_trd::types::ModifyOrderOp::Cancel,
508        qty: None,
509        price: None,
510        for_all: None,
511        idempotency_key,
512    };
513    let returned_id = futu_trd::order::modify_order(client, &params).await?;
514    let out = CancelOut {
515        order_id: returned_id,
516        op: "CANCEL",
517        env: match header.trd_env {
518            TrdEnv::Simulate => "simulate",
519            TrdEnv::Real => "real",
520            _ => "unknown",
521        },
522    };
523    Ok(serde_json::to_string_pretty(&out)?)
524}
525
526// ========== reconfirm ==========
527
528#[derive(Serialize)]
529struct ReconfirmOut {
530    order_id: u64,
531    reason: i32,
532    env: &'static str,
533}
534
535pub struct ReconfirmOrderInput<'a> {
536    pub env: &'a str,
537    pub acc_id: u64,
538    pub market: &'a str,
539    pub order_id: &'a str,
540    pub reason: i32,
541    pub jp_acc_type: Option<i32>,
542}
543
544pub async fn reconfirm_order(
545    client: &Arc<FutuClient>,
546    input: ReconfirmOrderInput<'_>,
547) -> Result<String> {
548    let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
549    let order_id = parse_numeric_order_id_arg(input.order_id, "order_id")?;
550    let returned_id =
551        futu_trd::misc::reconfirm_order(client, &header, order_id, input.reason).await?;
552    let out = ReconfirmOut {
553        order_id: returned_id,
554        reason: input.reason,
555        env: match header.trd_env {
556            TrdEnv::Simulate => "simulate",
557            TrdEnv::Real => "real",
558            _ => "unknown",
559        },
560    };
561    Ok(serde_json::to_string_pretty(&out)?)
562}
563
564#[derive(Serialize)]
565struct CancelAllOut {
566    op: &'static str,
567    env: &'static str,
568    acc_id: u64,
569    market: String,
570}
571
572/// 全部撤单。内部用 ModifyOrder proto 带 for_all=true + op=Cancel + order_id=0。
573/// 风险提示:立即撤销该账户**指定市场**(market 空时全账户)所有 pending
574/// 订单,不可恢复。
575pub async fn cancel_all_order(
576    client: &Arc<FutuClient>,
577    env: &str,
578    acc_id: u64,
579    market: &str,
580) -> Result<String> {
581    let header = build_header(env, acc_id, market, None)?;
582    let params = ModifyOrderParams {
583        header: header.clone(),
584        order_id: 0,
585        order_id_ex: None,
586        modify_order_op: ModifyOrderOp::Cancel,
587        qty: None,
588        price: None,
589        for_all: Some(true),
590        idempotency_key: None,
591    };
592    futu_trd::order::modify_order(client, &params).await?;
593    let out = CancelAllOut {
594        op: "CANCEL_ALL",
595        env: match header.trd_env {
596            TrdEnv::Simulate => "simulate",
597            TrdEnv::Real => "real",
598            _ => "unknown",
599        },
600        acc_id,
601        market: market.to_string(),
602    };
603    Ok(serde_json::to_string_pretty(&out)?)
604}
605
606// ========== 环境守卫 ==========
607
608/// 判断给定的 env 字符串是否指向真实环境。
609pub fn is_real_env(env: &str) -> bool {
610    matches!(env.trim().to_ascii_lowercase().as_str(), "real")
611}
612
613// ========== v1.4.93 BUG-001 Tests (runtime parser 9 + 17) ==========
614
615#[cfg(test)]
616mod tests;