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