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