Skip to main content

futu_trd/
order.rs

1use std::sync::atomic::{AtomicU32, Ordering};
2
3use futu_core::error::{FutuError, Result};
4use futu_core::proto_id;
5use futu_net::client::FutuClient;
6
7use crate::types::{ModifyOrderParams, PlaceOrderParams, PlaceOrderResult};
8
9/// 全局唯一的 packet ID 生成器(防重放攻击)
10static PACKET_SERIAL: AtomicU32 = AtomicU32::new(1);
11
12fn client_conn_id(client: &FutuClient) -> Result<u64> {
13    client.conn_id().ok_or(FutuError::NotInitialized)
14}
15
16fn next_packet_id(conn_id: u64) -> futu_proto::common::PacketId {
17    let serial = PACKET_SERIAL.fetch_add(1, Ordering::Relaxed);
18    futu_proto::common::PacketId {
19        // Echo InitConnect S2C connID like C++ FTAPI clients. The gateway
20        // replay guard checks this against the actual TCP connection id.
21        conn_id,
22        serial_no: serial,
23    }
24}
25
26/// v1.4.39 (eli exhaustive report 修): 把幂等键映射到 `Common.PacketID`,让 daemon 端
27/// 的 packet_id fallback(`idempotency.rs` 90s TTL cache)能识别"同一键 = 同一请求"。
28///
29/// **设计**:conn_id = u64 hash(key),serial_no = 0 固定。daemon 端把 packet_id
30/// 格式化为 `"tcp-pkt-{conn_id}-{serial_no}"`,所以不同 key → 不同 conn_id → 不同
31/// cache entry;相同 key → 相同 conn_id → 命中 cache。
32fn packet_id_for_idempotency_key(key: &str) -> futu_proto::common::PacketId {
33    use std::collections::hash_map::DefaultHasher;
34    use std::hash::{Hash, Hasher};
35    let mut hasher = DefaultHasher::new();
36    key.hash(&mut hasher);
37    futu_proto::common::PacketId {
38        conn_id: hasher.finish(),
39        serial_no: 0,
40    }
41}
42
43/// 下单
44///
45/// 向 FutuOpenD 发送下单请求。
46/// 注意:需要先解锁交易 (`unlock_trade`)。
47/// v1.4.48 #8 修(eli 验收报告 §9 Test 2):客户端侧(futucli → daemon / futucli → C++ OpenD)
48/// 的 `Trd_PlaceOrder.C2S.sec_market` 之前硬编码 `None`,eli wire-level A/B 抓包
49/// 证伪"proto 里有就自动填"—— C++ OpenD 直接拒 `missing Transaction Securities
50/// Market`。
51///
52/// 此 helper 是 daemon 端 `derive_sec_market` 的客户端镜像(同逻辑,避免跨 crate
53/// 依赖)。对齐 `Trd_Common.TrdSecMarket` enum:HK=1 / US=2 / CN_SH=31 / CN_SZ=32
54/// / SG=41 / JP=51 / AU=61 / MY=71 / CA=81。
55///
56/// 规则:
57/// 1. 按 trd_market 推(1/4=HK → 1; 2=US → 2; 3=CN 按 code 前缀分 SH/SZ; 6=SG → 41; ...)
58/// 2. 无法推 → 0(Unknown,backend 可能拒 —— 至少比 None 好,有 diagnostic)
59fn derive_sec_market_client(trd_market: i32, code: &str) -> i32 {
60    match trd_market {
61        1 | 4 | 113 => 1,  // HK / HKCC / HK_Fund → HK
62        2 | 11 | 123 => 2, // US / Futures_US / US_Fund → US
63        3 => {
64            // CN: 按 code 前缀判 SH/SZ
65            let bare = code
66                .trim_start_matches("SH.")
67                .trim_start_matches("SZ.")
68                .trim_start_matches("CN.");
69            match bare.chars().next() {
70                Some('6') | Some('9') => 31,             // SH
71                Some('0') | Some('2') | Some('3') => 32, // SZ
72                _ => 31,                                 // default SH
73            }
74        }
75        6 | 12 | 124 => 41, // SG / Futures_SG / SG_Fund
76        8 => 61,            // AU
77        // 注:trd_market=11 已在上面 `2 | 11 | 123 => 2` 分支(US)里 catch
78        // (v1.4.47 P0.3 修:sim 账户 trd_market=11 按 US stock 处理)
79        15 => 51,  // JP
80        111 => 71, // MY
81        112 => 81, // CA
82        _ => 0,    // Unknown
83    }
84}
85
86fn parse_place_order_response_body(body: &[u8]) -> Result<PlaceOrderResult> {
87    let resp: futu_proto::trd_place_order::Response =
88        prost::Message::decode(body).map_err(FutuError::Proto)?;
89
90    if resp.ret_type != 0 {
91        return Err(crate::server_err(
92            resp.ret_type,
93            resp.ret_msg,
94            resp.err_code,
95        ));
96    }
97
98    let s2c = resp
99        .s2c
100        .ok_or(FutuError::Codec("missing s2c in PlaceOrder".into()))?;
101
102    let order_id = s2c.order_id.ok_or_else(|| {
103        FutuError::Codec(
104            "missing orderID in successful PlaceOrder response; C++ \
105             APIServer_Trd_PlaceOrder.cpp:856-864 sets orderID before \
106             returning success"
107                .into(),
108        )
109    })?;
110
111    Ok(PlaceOrderResult { order_id })
112}
113
114pub async fn place_order(
115    client: &FutuClient,
116    params: &PlaceOrderParams,
117) -> Result<PlaceOrderResult> {
118    // v1.4.102 codex 28 F3 (P1) fix: SDK 层也拒 fund market 113/123 写入.
119    //
120    // **历史**: REST/MCP/CLI wrapper 都加了 fund market reject (codex 26 F1
121    // / 27 F7), 但直接 Rust SDK / gRPC / direct proto caller 仍可构造
122    // `header.trd_market = 113/123` 调用本 fn. `derive_sec_market_client`
123    // 把 113→HK / 123→US 后, backend 看到 normal HK/US write 不会拒 → 用户
124    // 用 fund 账户号下单 → silent 误路由风险.
125    //
126    // **修法**: SDK fn 入口拒 113/123, 让所有 caller (REST/MCP/CLI/gRPC/
127    // direct SDK) 共享同一 runtime contract.
128    if matches!(
129        params.header.trd_market,
130        crate::types::TrdMarket::HKFund | crate::types::TrdMarket::USFund
131    ) {
132        return Err(FutuError::ServerError {
133            ret_type: -1,
134            msg: "place_order: trd_market HKFUND (113) / USFUND (123) 仅支持 view-only \
135                  read endpoints; write 路径 (place_order) 用主市场 HK=1 / US=2. \
136                  v1.4.102 codex 28 F3 fix."
137                .to_string(),
138        });
139    }
140
141    let packet_id = params
142        .idempotency_key
143        .as_deref()
144        .map(packet_id_for_idempotency_key)
145        .map(Ok)
146        .unwrap_or_else(|| client_conn_id(client).map(next_packet_id))?;
147    let req = futu_proto::trd_place_order::Request {
148        c2s: futu_proto::trd_place_order::C2s {
149            packet_id,
150            header: params.header.to_proto(),
151            trd_side: params.trd_side as i32,
152            order_type: params.order_type as i32,
153            code: params.code.clone(),
154            qty: params.qty,
155            price: params.price,
156            adjust_price: params.adjust_price,
157            adjust_side_and_limit: params.adjust_side_and_limit,
158            sec_market: Some(derive_sec_market_client(
159                params.header.trd_market as i32,
160                &params.code,
161            )),
162            remark: None,
163            time_in_force: None,
164            fill_outside_rth: None,
165            // v1.4.53 F1 条件单:透传 aux_price / trail_* 到 FTAPI
166            aux_price: params.aux_price,
167            trail_type: params.trail_type,
168            trail_value: params.trail_value,
169            trail_spread: params.trail_spread,
170            session: None,
171            position_id: None,
172        },
173    };
174
175    let body = prost::Message::encode_to_vec(&req);
176    let resp_frame = client.request(proto_id::TRD_PLACE_ORDER, body).await?;
177
178    parse_place_order_response_body(resp_frame.body.as_ref())
179}
180
181/// 修改/撤销订单
182pub async fn modify_order(client: &FutuClient, params: &ModifyOrderParams) -> Result<u64> {
183    // v1.4.102 codex 28 F3 (P1) fix: SDK 层 modify_order 也拒 fund market.
184    if matches!(
185        params.header.trd_market,
186        crate::types::TrdMarket::HKFund | crate::types::TrdMarket::USFund
187    ) {
188        return Err(FutuError::ServerError {
189            ret_type: -1,
190            msg: "modify_order: trd_market HKFUND (113) / USFUND (123) 仅支持 view-only \
191                  read endpoints; write 路径用主市场. v1.4.102 codex 28 F3 fix."
192                .to_string(),
193        });
194    }
195
196    let packet_id = params
197        .idempotency_key
198        .as_deref()
199        .map(packet_id_for_idempotency_key)
200        .map(Ok)
201        .unwrap_or_else(|| client_conn_id(client).map(next_packet_id))?;
202    let req = futu_proto::trd_modify_order::Request {
203        c2s: futu_proto::trd_modify_order::C2s {
204            packet_id,
205            header: params.header.to_proto(),
206            order_id: params.order_id,
207            modify_order_op: params.modify_order_op as i32,
208            for_all: params.for_all,
209            trd_market: None,
210            qty: params.qty,
211            price: params.price,
212            adjust_price: None,
213            adjust_side_and_limit: None,
214            aux_price: None,
215            trail_type: None,
216            trail_value: None,
217            trail_spread: None,
218            order_id_ex: params.order_id_ex.clone(),
219        },
220    };
221
222    let body = prost::Message::encode_to_vec(&req);
223    let resp_frame = client.request(proto_id::TRD_MODIFY_ORDER, body).await?;
224
225    let resp: futu_proto::trd_modify_order::Response =
226        prost::Message::decode(resp_frame.body.as_ref()).map_err(FutuError::Proto)?;
227
228    if resp.ret_type != 0 {
229        return Err(crate::server_err(
230            resp.ret_type,
231            resp.ret_msg,
232            resp.err_code,
233        ));
234    }
235
236    let s2c = resp
237        .s2c
238        .ok_or(FutuError::Codec("missing s2c in ModifyOrder".into()))?;
239
240    Ok(s2c.order_id)
241}
242
243/// 撤单(modify_order 的便捷封装)
244pub async fn cancel_order(
245    client: &FutuClient,
246    header: &crate::types::TrdHeader,
247    order_id: u64,
248) -> Result<u64> {
249    modify_order(
250        client,
251        &ModifyOrderParams {
252            header: header.clone(),
253            order_id,
254            order_id_ex: None,
255            modify_order_op: crate::types::ModifyOrderOp::Cancel,
256            qty: None,
257            price: None,
258            for_all: None,
259            idempotency_key: None,
260        },
261    )
262    .await
263}
264
265#[cfg(test)]
266mod tests_v1_4_67_bug_3;