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, PlaceOrderOptions, PlaceOrderParams, TrdEnv,
13    TrdHeader, TrdMarket, 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    let m = futu_trd::market::parse_trd_market(s).ok_or_else(|| {
174        anyhow::anyhow!(
175            "unknown trd market {:?} (write path 接 {}). fund markets 仅 read path 支持.",
176            s.trim().to_ascii_uppercase(),
177            futu_trd::market::TRD_MARKET_NON_FUND_PARSE_CHOICES
178        )
179    })?;
180    if let Some(label) = futu_trd::market::canonical_fund_trd_market_label(m) {
181        return bail_fund_market(label);
182    }
183    Ok(m)
184}
185
186fn bail_fund_market(label: &'static str) -> Result<TrdMarket> {
187    bail!(
188        "trd market {label} 仅支持 view-only read endpoints \
189         (positions/funds/cash-log/history-orders/history-fills); write 路径 \
190         (place_order/modify_order/cancel_order) 用对应主市场, daemon 自动按 \
191         持仓 broker 路由. v1.4.102 audit 26 F1 fix"
192    )
193}
194
195pub fn parse_trd_env(s: &str) -> Result<TrdEnv> {
196    futu_trd::parsing::parse_trd_env(s).ok_or_else(|| {
197        anyhow::anyhow!(
198            "unknown trd env {:?} ({})",
199            s.trim().to_ascii_lowercase(),
200            futu_trd::parsing::TRD_ENV_PARSE_CHOICES
201        )
202    })
203}
204
205pub fn parse_trd_side(s: &str) -> Result<TrdSide> {
206    futu_trd::parsing::parse_trd_side(s).ok_or_else(|| {
207        anyhow::anyhow!(
208            "unknown trd side {:?} ({})",
209            s.trim().to_ascii_uppercase(),
210            futu_trd::parsing::TRD_SIDE_PARSE_CHOICES
211        )
212    })
213}
214
215pub fn parse_order_type(s: &str) -> Result<OrderType> {
216    futu_trd::parsing::parse_order_type(s).ok_or_else(|| {
217        anyhow::anyhow!(
218            "unknown order type {:?} ({})",
219            s.trim().to_ascii_uppercase(),
220            futu_trd::parsing::ORDER_TYPE_PARSE_CHOICES
221        )
222    })
223}
224
225pub fn parse_modify_op(s: &str) -> Result<ModifyOrderOp> {
226    futu_trd::parsing::parse_modify_op(s).ok_or_else(|| {
227        anyhow::anyhow!(
228            "unknown modify op {:?} ({})",
229            s.trim().to_ascii_uppercase(),
230            futu_trd::parsing::MODIFY_OP_PARSE_CHOICES
231        )
232    })
233}
234
235fn build_header(
236    env: &str,
237    acc_id: u64,
238    market: &str,
239    jp_acc_type: Option<i32>,
240) -> Result<TrdHeader> {
241    Ok(TrdHeader {
242        trd_env: parse_trd_env(env)?,
243        acc_id,
244        trd_market: parse_trd_market(market)?,
245        jp_acc_type,
246    })
247}
248
249// ========== place ==========
250
251#[derive(Serialize)]
252struct PlaceOut {
253    order_id: u64,
254    env: &'static str,
255    market: String,
256    acc_id: u64,
257    side: String,
258    order_type: String,
259    code: String,
260    qty: f64,
261    price: Option<f64>,
262    time_in_force: Option<i32>,
263    fill_outside_rth: Option<bool>,
264    session: Option<i32>,
265    expire_time: Option<String>,
266}
267
268pub struct PlaceOrderInput<'a> {
269    pub env: &'a str,
270    pub acc_id: u64,
271    pub market: &'a str,
272    pub side: &'a str,
273    pub order_type: &'a str,
274    pub code: &'a str,
275    pub qty: f64,
276    pub price: Option<f64>,
277    pub time_in_force: Option<i32>,
278    pub fill_outside_rth: Option<bool>,
279    pub session: Option<i32>,
280    pub expire_time: Option<&'a str>,
281    pub jp_acc_type: Option<i32>,
282    pub idempotency_key: Option<String>,
283    // v1.4.53 F1 条件单
284    pub stop_price: Option<f64>,
285    pub trail_type: Option<i32>,
286    pub trail_value: Option<f64>,
287    pub trail_spread: Option<f64>,
288}
289
290pub async fn place_order(client: &Arc<FutuClient>, input: PlaceOrderInput<'_>) -> Result<String> {
291    let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
292    let trd_side = parse_trd_side(input.side)?;
293    let ord_type = parse_order_type(input.order_type)?;
294
295    let params = PlaceOrderParams {
296        header: header.clone(),
297        trd_side,
298        order_type: ord_type,
299        code: input.code.to_string(),
300        qty: input.qty,
301        price: input.price,
302        adjust_price: None,
303        adjust_side_and_limit: None,
304        idempotency_key: input.idempotency_key,
305        // v1.4.53 F1 条件单:透传 stop_price / trail_* 到 futu_trd
306        aux_price: input.stop_price,
307        trail_type: input.trail_type,
308        trail_value: input.trail_value,
309        trail_spread: input.trail_spread,
310    };
311    let options = PlaceOrderOptions {
312        time_in_force: input.time_in_force,
313        fill_outside_rth: input.fill_outside_rth,
314        session: input.session,
315        expire_time: input.expire_time.map(str::to_string),
316    };
317    let res = futu_trd::place_order_with_options(client, &params, &options).await?;
318
319    let out = PlaceOut {
320        order_id: res.order_id,
321        env: match header.trd_env {
322            TrdEnv::Simulate => "simulate",
323            TrdEnv::Real => "real",
324            _ => "unknown",
325        },
326        market: input.market.to_ascii_uppercase(),
327        acc_id: input.acc_id,
328        side: input.side.to_ascii_uppercase(),
329        order_type: input.order_type.to_ascii_uppercase(),
330        code: input.code.to_string(),
331        qty: input.qty,
332        price: input.price,
333        time_in_force: input.time_in_force,
334        fill_outside_rth: input.fill_outside_rth,
335        session: input.session,
336        expire_time: input.expire_time.map(str::to_string),
337    };
338    Ok(serde_json::to_string_pretty(&out)?)
339}
340
341// ========== modify ==========
342
343#[derive(Serialize)]
344struct ModifyOut {
345    order_id: u64,
346    op: String,
347    env: &'static str,
348    qty: Option<f64>,
349    price: Option<f64>,
350}
351
352pub struct ModifyOrderInput<'a> {
353    pub env: &'a str,
354    pub acc_id: u64,
355    pub market: &'a str,
356    pub order_id: &'a str,
357    pub op: &'a str,
358    pub qty: Option<f64>,
359    pub price: Option<f64>,
360    pub jp_acc_type: Option<i32>,
361    pub idempotency_key: Option<String>,
362}
363
364struct ResolvedOrderIdArg {
365    order_id: u64,
366    order_id_ex: Option<String>,
367}
368
369fn resolve_order_id_arg(raw: &str) -> Result<ResolvedOrderIdArg> {
370    let trimmed = raw.trim();
371    if trimmed.is_empty() {
372        bail!("order_id must not be empty");
373    }
374
375    // C++ APIServer_Trd_ModifyOrder.cpp hashes orderIDEx into orderID before
376    // local lookup/validation; clients may pass either numeric orderID or FU/FH
377    // orderIDEx.
378    if trimmed.bytes().all(|b| b.is_ascii_digit()) {
379        return Ok(ResolvedOrderIdArg {
380            order_id: trimmed.parse::<u64>()?,
381            order_id_ex: None,
382        });
383    }
384
385    Ok(ResolvedOrderIdArg {
386        order_id: 0,
387        order_id_ex: Some(trimmed.to_string()),
388    })
389}
390
391fn parse_numeric_order_id_arg(raw: &str, field: &str) -> Result<u64> {
392    let trimmed = raw.trim();
393    if trimmed.is_empty() {
394        bail!("{field} must not be empty");
395    }
396    if !trimmed.bytes().all(|b| b.is_ascii_digit()) {
397        bail!(
398            "{field} for futu_reconfirm_order must be numeric FTAPI order_id; \
399             orderIDEx is not supported by Trd_ReconfirmOrder"
400        );
401    }
402    Ok(trimmed.parse::<u64>()?)
403}
404
405pub async fn modify_order(client: &Arc<FutuClient>, input: ModifyOrderInput<'_>) -> Result<String> {
406    let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
407    let mop = parse_modify_op(input.op)?;
408    let resolved_order_id = resolve_order_id_arg(input.order_id)?;
409
410    let params = ModifyOrderParams {
411        header: header.clone(),
412        order_id: resolved_order_id.order_id,
413        order_id_ex: resolved_order_id.order_id_ex,
414        modify_order_op: mop,
415        qty: input.qty,
416        price: input.price,
417        for_all: None,
418        idempotency_key: input.idempotency_key,
419    };
420    let returned_id = futu_trd::order::modify_order(client, &params).await?;
421
422    let out = ModifyOut {
423        order_id: returned_id,
424        op: input.op.to_ascii_uppercase(),
425        env: match header.trd_env {
426            TrdEnv::Simulate => "simulate",
427            TrdEnv::Real => "real",
428            _ => "unknown",
429        },
430        qty: input.qty,
431        price: input.price,
432    };
433    Ok(serde_json::to_string_pretty(&out)?)
434}
435
436// ========== cancel ==========
437
438#[derive(Serialize)]
439struct CancelOut {
440    order_id: u64,
441    op: &'static str,
442    env: &'static str,
443}
444
445pub async fn cancel_order(
446    client: &Arc<FutuClient>,
447    env: &str,
448    acc_id: u64,
449    market: &str,
450    order_id: &str,
451    jp_acc_type: Option<i32>,
452    idempotency_key: Option<String>,
453) -> Result<String> {
454    let header = build_header(env, acc_id, market, jp_acc_type)?;
455    let resolved_order_id = resolve_order_id_arg(order_id)?;
456    // v1.4.39: cancel_order 本质是 modify_order 的 shortcut。走 modify_order 路径
457    // 以支持 idempotency_key(`futu_trd::order::cancel_order` helper 不接 key)。
458    let params = ModifyOrderParams {
459        header: header.clone(),
460        order_id: resolved_order_id.order_id,
461        order_id_ex: resolved_order_id.order_id_ex,
462        modify_order_op: futu_trd::types::ModifyOrderOp::Cancel,
463        qty: None,
464        price: None,
465        for_all: None,
466        idempotency_key,
467    };
468    let returned_id = futu_trd::order::modify_order(client, &params).await?;
469    let out = CancelOut {
470        order_id: returned_id,
471        op: "CANCEL",
472        env: match header.trd_env {
473            TrdEnv::Simulate => "simulate",
474            TrdEnv::Real => "real",
475            _ => "unknown",
476        },
477    };
478    Ok(serde_json::to_string_pretty(&out)?)
479}
480
481// ========== reconfirm ==========
482
483#[derive(Serialize)]
484struct ReconfirmOut {
485    order_id: u64,
486    reason: i32,
487    env: &'static str,
488}
489
490pub struct ReconfirmOrderInput<'a> {
491    pub env: &'a str,
492    pub acc_id: u64,
493    pub market: &'a str,
494    pub order_id: &'a str,
495    pub reason: i32,
496    pub jp_acc_type: Option<i32>,
497}
498
499pub async fn reconfirm_order(
500    client: &Arc<FutuClient>,
501    input: ReconfirmOrderInput<'_>,
502) -> Result<String> {
503    let header = build_header(input.env, input.acc_id, input.market, input.jp_acc_type)?;
504    let order_id = parse_numeric_order_id_arg(input.order_id, "order_id")?;
505    let returned_id =
506        futu_trd::misc::reconfirm_order(client, &header, order_id, input.reason).await?;
507    let out = ReconfirmOut {
508        order_id: returned_id,
509        reason: input.reason,
510        env: match header.trd_env {
511            TrdEnv::Simulate => "simulate",
512            TrdEnv::Real => "real",
513            _ => "unknown",
514        },
515    };
516    Ok(serde_json::to_string_pretty(&out)?)
517}
518
519#[derive(Serialize)]
520struct CancelAllOut {
521    op: &'static str,
522    env: &'static str,
523    acc_id: u64,
524    market: String,
525}
526
527/// 全部撤单。内部用 ModifyOrder proto 带 for_all=true + op=Cancel + order_id=0。
528/// 风险提示:立即撤销该账户**指定市场**(market 空时全账户)所有 pending
529/// 订单,不可恢复。
530pub async fn cancel_all_order(
531    client: &Arc<FutuClient>,
532    env: &str,
533    acc_id: u64,
534    market: &str,
535) -> Result<String> {
536    let header = build_header(env, acc_id, market, None)?;
537    let params = ModifyOrderParams {
538        header: header.clone(),
539        order_id: 0,
540        order_id_ex: None,
541        modify_order_op: ModifyOrderOp::Cancel,
542        qty: None,
543        price: None,
544        for_all: Some(true),
545        idempotency_key: None,
546    };
547    futu_trd::order::modify_order(client, &params).await?;
548    let out = CancelAllOut {
549        op: "CANCEL_ALL",
550        env: match header.trd_env {
551            TrdEnv::Simulate => "simulate",
552            TrdEnv::Real => "real",
553            _ => "unknown",
554        },
555        acc_id,
556        market: market.to_string(),
557    };
558    Ok(serde_json::to_string_pretty(&out)?)
559}
560
561// ========== 环境守卫 ==========
562
563/// 判断给定的 env 字符串是否指向真实环境。
564pub fn is_real_env(env: &str) -> bool {
565    matches!(env.trim().to_ascii_lowercase().as_str(), "real")
566}
567
568// ========== v1.4.93 BUG-001 Tests (runtime parser 9 + 17) ==========
569
570#[cfg(test)]
571mod tests;