Skip to main content

futu_backend/trade_query/orders/
query_orders.rs

1//! trade_query/orders/query_orders — query_orders + query_orders_inner (real + sim 双路径)
2//! (v1.4.110 CC Batch J: 拆自 orders.rs L336-666)
3
4use futu_cache::trd_cache::TrdCache;
5use futu_core::error::{FutuError, Result};
6
7use super::super::common::{hash_str_to_u64, parse_open_d_text};
8use super::super::*;
9use crate::conn::BackendConn;
10use crate::proto_internal::{order_sys_interface, sim_odr_sys_cmn, sim_order_sys_interface};
11
12use super::types::QueryErrorMode;
13
14use super::builders::*;
15use super::helpers::*;
16use super::status_helpers::*;
17
18pub async fn query_orders(
19    backend: &BackendConn,
20    acc_id: u64,
21    trd_cache: &TrdCache,
22) -> Result<Vec<futu_cache::trd_cache::CachedOrder>> {
23    query_orders_inner(backend, acc_id, trd_cache, QueryErrorMode::LenientCacheRead).await
24}
25
26/// v1.4.109: strict 版 query_orders for write/push safety paths.
27///
28/// 与 lenient 版区别: decode 失败 / `result != 0` → 返 `Err` (会被 retry 重试).
29/// 不再 silent 当 "Ok(empty)" → silent-success 反模式 D (CLAUDE.md #45) 的修复.
30///
31/// Callers that need a trustworthy order baseline before mutating order state
32/// must use this strict variant, not the read-path lenient cache-miss helper.
33pub async fn query_orders_strict(
34    backend: &BackendConn,
35    acc_id: u64,
36    trd_cache: &TrdCache,
37) -> Result<Vec<futu_cache::trd_cache::CachedOrder>> {
38    query_orders_inner(
39        backend,
40        acc_id,
41        trd_cache,
42        QueryErrorMode::StrictPushRefresh,
43    )
44    .await
45}
46
47/// v1.4.106 codex 0932 F3 [P2]: strict 版 query_orders for push-refresh path.
48///
49/// **4 attempts 全失败**: caller (dispatcher) 应触发 F4 `record_f2_exhausted` +
50/// log warn. 不再 silent 当成功流转.
51pub async fn query_orders_strict_for_push_refresh(
52    backend: &BackendConn,
53    acc_id: u64,
54    trd_cache: &TrdCache,
55) -> Result<Vec<futu_cache::trd_cache::CachedOrder>> {
56    query_orders_strict(backend, acc_id, trd_cache).await
57}
58
59fn require_real_cipher_for_strict_trade_query(
60    acc_id: u64,
61    trd_cache: &TrdCache,
62    op_name: &str,
63) -> Result<Vec<u8>> {
64    let cipher = trd_cache.get_cipher(acc_id).unwrap_or_default();
65    if cipher.is_empty() {
66        return Err(FutuError::ServerError {
67            ret_type: -401,
68            msg: format!(
69                "{op_name}: real account cipher missing; trade is not unlocked (acc_id={acc_id})"
70            ),
71        });
72    }
73    Ok(cipher)
74}
75
76pub fn order_proto_to_cached_like_cpp(
77    trd_env: i32,
78    o: &crate::proto_internal::odr_sys_cmn::Order,
79) -> Option<futu_cache::trd_cache::CachedOrder> {
80    order_proto_to_cached_with_jp_acc_type_like_cpp(trd_env, o, None)
81}
82
83pub fn order_proto_to_cached_with_jp_acc_type_like_cpp(
84    trd_env: i32,
85    o: &crate::proto_internal::odr_sys_cmn::Order,
86    jp_acc_type: Option<i32>,
87) -> Option<futu_cache::trd_cache::CachedOrder> {
88    use futu_cache::trd_cache::CachedOrder;
89
90    let trd_market = backend_order_unpacked_trd_market_like_cpp(trd_env, o)?;
91    let order_id_ex = o.order_id.clone().unwrap_or_default();
92    // v1.4.106 codex 0226 F6: backend `order_id` (= szOrderID) 是 alphanumeric 字符串,
93    // 不是 numeric. C++ 用 `HashStrToU64(szOrderID)` 派生 FTAPI numeric orderID。
94    let order_id: u64 = if order_id_ex.is_empty() {
95        0
96    } else {
97        hash_str_to_u64(&order_id_ex)
98    };
99    let create_timestamp = o.create_time.map(|t| t as f64 / 1_000_000.0);
100    let update_timestamp = o.update_time.map(|t| t as f64 / 1_000_000.0);
101    let order_status = backend_order_status_to_ftapi(o.status.unwrap_or(0));
102    let trd_side = o.side.unwrap_or(0) as i32;
103    let security_type = o.security_type.map(|v| v as i32);
104    let order_type = backend_order_type_to_ftapi(o.r#type.unwrap_or(0), trd_market, security_type);
105    let backend_order_id = order_id_ex.clone();
106    let order_version = o.version.unwrap_or(0) as i32;
107    let exchange_code = o.exchange_code.unwrap_or(0) as i32;
108    let exchange = o.exchange.clone().unwrap_or_default();
109    let security_type = security_type.unwrap_or(0);
110    let extracted_remark = match parse_open_d_text(o.text.as_deref()) {
111        Some((_local_id, user_remark)) => Some(user_remark),
112        None => o.text.clone(),
113    };
114
115    let order_trade_time_type = o.sup.as_ref().and_then(|s| s.order_trade_time_type);
116
117    Some(CachedOrder {
118        order_id,
119        order_id_ex,
120        code: o.symbol.as_ref().cloned().unwrap_or_default(),
121        name: o.stock_name.as_ref().cloned().unwrap_or_default(),
122        trd_side,
123        order_type,
124        order_status,
125        qty: parse_backend_decimal(&o.qty),
126        price: parse_backend_decimal(&o.price),
127        fill_qty: parse_backend_decimal(&o.cum_qty),
128        fill_avg_price: parse_backend_decimal(&o.avg_fill_price),
129        create_time: String::new(),
130        update_time: String::new(),
131        last_err_msg: o.last_err_msg.clone(),
132        sec_market: None,
133        create_timestamp,
134        update_timestamp,
135        remark: extracted_remark,
136        time_in_force: None,
137        fill_outside_rth: None,
138        aux_price: None,
139        trail_type: None,
140        trail_value: None,
141        trail_spread: None,
142        currency: o.currency.map(|c| c as i32),
143        trd_market,
144        backend_order_id,
145        order_version,
146        exchange_code,
147        exchange,
148        security_type,
149        session: futu_trd::projection::order_trade_time_type_to_session(order_trade_time_type),
150        order_trade_time_type,
151        jp_acc_type,
152        is_stub: false,
153        is_local_order: false,
154        stub_inserted_at_ms: 0,
155        is_pending_broker_confirm: false,
156    })
157}
158
159pub(super) fn sim_order_proto_to_cached_like_cpp(
160    trd_env: i32,
161    o: &sim_odr_sys_cmn::Order,
162) -> Option<futu_cache::trd_cache::CachedOrder> {
163    use futu_cache::trd_cache::CachedOrder;
164
165    o.order_id.as_ref()?;
166    o.side?;
167    let trd_market = if trd_env == 1 {
168        let trd_market = backend_real_market_to_trd_market_like_cpp(o.market?);
169        if !is_valid_real_trd_market_like_cpp(trd_market) {
170            return None;
171        }
172        Some(trd_market)
173    } else {
174        o.market.map(map_backend_market_to_trd_market)
175    };
176    o.symbol.as_ref()?;
177    o.create_time?;
178    o.status?;
179
180    let order_id_ex = o.order_id.clone().unwrap_or_default();
181    let order_id: u64 = if order_id_ex.is_empty() {
182        0
183    } else {
184        hash_str_to_u64(&order_id_ex)
185    };
186    let create_timestamp = o.create_time.map(|t| t as f64 / 1_000_000.0);
187    let update_timestamp = o.update_time.map(|t| t as f64 / 1_000_000.0);
188    let order_status = backend_order_status_to_ftapi(o.status.unwrap_or(0));
189    let trd_side = o.side.unwrap_or(0) as i32;
190    let security_type = o.security_type.map(|v| v as i32);
191    let order_type = backend_order_type_to_ftapi(o.r#type.unwrap_or(0), trd_market, security_type);
192    let backend_order_id = order_id_ex.clone();
193    let order_version = o.version.unwrap_or(0) as i32;
194    let exchange_code = o.exchange_code.unwrap_or(0) as i32;
195    let exchange = o.exchange.clone().unwrap_or_default();
196    let security_type = security_type.unwrap_or(0);
197    let extracted_remark = match parse_open_d_text(o.text.as_deref()) {
198        Some((_local_id, user_remark)) => Some(user_remark),
199        None => o.text.clone(),
200    };
201
202    let order_trade_time_type = o.sup.as_ref().and_then(|s| s.order_trade_time_type);
203
204    Some(CachedOrder {
205        order_id,
206        order_id_ex,
207        code: o.symbol.as_ref().cloned().unwrap_or_default(),
208        name: o.stock_name.as_ref().cloned().unwrap_or_default(),
209        trd_side,
210        order_type,
211        order_status,
212        qty: parse_backend_decimal(&o.qty),
213        price: parse_backend_decimal(&o.price),
214        fill_qty: parse_backend_decimal(&o.cum_qty),
215        fill_avg_price: parse_backend_decimal(&o.avg_fill_price),
216        create_time: String::new(),
217        update_time: String::new(),
218        last_err_msg: o.last_err_msg.clone(),
219        sec_market: None,
220        create_timestamp,
221        update_timestamp,
222        remark: extracted_remark,
223        time_in_force: None,
224        fill_outside_rth: None,
225        aux_price: None,
226        trail_type: None,
227        trail_value: None,
228        trail_spread: None,
229        currency: o.currency.map(|c| c as i32),
230        trd_market,
231        backend_order_id,
232        order_version,
233        exchange_code,
234        exchange,
235        security_type,
236        session: futu_trd::projection::order_trade_time_type_to_session(order_trade_time_type),
237        order_trade_time_type,
238        jp_acc_type: o.sub_account.map(|v| v as i32),
239        is_stub: false,
240        is_local_order: false,
241        stub_inserted_at_ms: 0,
242        is_pending_broker_confirm: false,
243    })
244}
245
246/// Strict order detail query for C++ push-refresh parity.
247///
248/// C++ receives `NOTICE_TYPE_ORDER_UPDATE` / `NOTICE_TYPE_ORDER_OP_RESULT` and
249/// calls `QueryOrderInfo` (4707/14707) with the pushed order IDs, rather than
250/// running a full `OrderListReq` (4708/14708). This matters for terminal states
251/// such as Deleted(23), which can be visible through the detail path while the
252/// current-order list omits them.
253pub async fn query_order_info_strict_for_push_refresh(
254    backend: &BackendConn,
255    acc_id: u64,
256    trd_cache: &TrdCache,
257    order_ids: &[String],
258    security_type: Option<u32>,
259    exchange: Option<&str>,
260) -> Result<Vec<futu_cache::trd_cache::CachedOrder>> {
261    use prost::Message;
262
263    let filtered_ids: Vec<String> = order_ids
264        .iter()
265        .filter(|id| !id.is_empty())
266        .cloned()
267        .collect();
268    if filtered_ids.is_empty() {
269        return Ok(vec![]);
270    }
271
272    let (trd_env, _enabled_markets) = derive_acc_query_context(trd_cache, acc_id);
273    let cmd_id = order_info_cmd_for_env(trd_env);
274    let real_cipher = if trd_env == 1 {
275        require_real_cipher_for_strict_trade_query(acc_id, trd_cache, "QueryOrderInfo")?
276    } else {
277        Vec::new()
278    };
279    let req =
280        build_order_info_req_base(acc_id, security_type, exchange, &filtered_ids, real_cipher);
281    let resp = backend.request(cmd_id, req.encode_to_vec()).await?;
282    let (orders, received): (Vec<futu_cache::trd_cache::CachedOrder>, usize) = if trd_env == 1 {
283        let parsed: order_sys_interface::OrderDetailRsp = Message::decode(resp.body.as_ref())?;
284        if let Err(status_err) = backend_order_info_status_like_cpp(
285            parsed.result,
286            parsed.msg_header.as_ref(),
287            parsed.err_msg.as_deref(),
288            acc_id,
289            filtered_ids.len(),
290            parsed.orders.len(),
291        ) {
292            return Err(futu_core::error::FutuError::ServerError {
293                ret_type: status_err.result,
294                msg: format!("{} (acc_id={acc_id})", status_err.message),
295            });
296        }
297        let received = parsed.orders.len();
298        let orders = parsed
299            .orders
300            .iter()
301            .filter_map(|order| {
302                let jp_acc_type = order
303                    .sub_account_id
304                    .and_then(|id| trd_cache.jp_acc_type_for_sub_account(acc_id, id));
305                order_proto_to_cached_with_jp_acc_type_like_cpp(trd_env, order, jp_acc_type)
306            })
307            .collect();
308        (orders, received)
309    } else {
310        let parsed: sim_order_sys_interface::OrderDetailRsp = Message::decode(resp.body.as_ref())?;
311        if let Err(status_err) = backend_order_info_status_by_account_like_cpp(
312            parsed.result,
313            parsed
314                .msg_header
315                .as_ref()
316                .and_then(|header| header.account_id),
317            parsed.err_msg.as_deref(),
318            acc_id,
319            filtered_ids.len(),
320            parsed.orders.len(),
321        ) {
322            return Err(futu_core::error::FutuError::ServerError {
323                ret_type: status_err.result,
324                msg: format!("{} (acc_id={acc_id})", status_err.message),
325            });
326        }
327        let received = parsed.orders.len();
328        let orders = parsed
329            .orders
330            .iter()
331            .filter_map(|order| sim_order_proto_to_cached_like_cpp(trd_env, order))
332            .collect();
333        (orders, received)
334    };
335    let mut applied_orders = Vec::with_capacity(orders.len());
336    for order in orders {
337        if trd_cache.upsert_order(acc_id, order.clone()) {
338            applied_orders.push(order);
339        }
340    }
341    tracing::debug!(
342        acc_id,
343        cmd_id,
344        requested = filtered_ids.len(),
345        received,
346        applied = applied_orders.len(),
347        "order details queried"
348    );
349    Ok(applied_orders)
350}
351
352pub async fn query_orders_inner(
353    backend: &BackendConn,
354    acc_id: u64,
355    trd_cache: &TrdCache,
356    error_mode: QueryErrorMode,
357) -> Result<Vec<futu_cache::trd_cache::CachedOrder>> {
358    use prost::Message;
359
360    // v1.4.106 codex 0955 F4: req shape 对齐 C++ FillOrderListReq.
361    // v1.4.106 14708 audit: C++ QueryOrderList_Inner sim 分支使用
362    // sim_order_sys_interface::OrderListReq + registry Orders.sim_cmd。
363    let (trd_env, enabled_markets) = derive_acc_query_context(trd_cache, acc_id);
364    // C++ `M_SendProto_SetReqMsgHeader` always copies
365    // `INNData_Trd_AccList::GetAccCipher(m_nAccID)` into real trade query
366    // headers. Deleted(23) rows observed after daemon restart are sensitive to
367    // this real-order-list request shape: sending an always-empty cipher can
368    // return a valid but empty backend snapshot.
369    let real_cipher = if trd_env == 1 && error_mode.is_strict() {
370        require_real_cipher_for_strict_trade_query(acc_id, trd_cache, "QueryOrderList")?
371    } else {
372        trd_cache.get_cipher(acc_id).unwrap_or_default()
373    };
374    let real_req = build_order_list_req_base(acc_id, trd_env, &enabled_markets, real_cipher);
375    let sim_req = build_sim_order_list_req_base(acc_id, &enabled_markets);
376
377    let mut all_orders = Vec::new();
378    // C++ first page calls `FillOrderListReq(req, "")`, so the optional
379    // page_flag is present with an empty string on the wire.
380    let mut page_flag: Option<String> = Some(String::new());
381    // v1.4.106 codex 0955 F5: 显式 track completion. partial pagination
382    // (completed=false 但 page_flag 缺失 / 超 max_pages 未 completed) 必须
383    // 返 Err, 不能写 cache (stub orders 会被 partial 列表抹掉, 历史教训
384    // 见坑 #44 v1.4.73-90 saga).
385    const MAX_PAGES: usize = 100;
386    let mut completed = false;
387
388    for _page in 0..MAX_PAGES {
389        let spec = trade_query_command(TradeQueryOperation::Orders);
390        let (cmd_id, req_body) = if trd_env == 1 {
391            let mut paged_req = real_req.clone();
392            paged_req.page_flag = page_flag.clone();
393            (spec.real_cmd, paged_req.encode_to_vec())
394        } else {
395            let mut paged_req = sim_req.clone();
396            paged_req.page_flag = page_flag.clone();
397            (spec.sim_cmd, paged_req.encode_to_vec())
398        };
399
400        let resp = match backend.request(cmd_id, req_body).await {
401            Ok(r) => r,
402            Err(e) => {
403                tracing::debug!(acc_id, cmd_id, error = %e, "order query failed");
404                return Err(e);
405            }
406        };
407
408        let parsed: order_sys_interface::OrderListRsp = match Message::decode(resp.body.as_ref()) {
409            Ok(p) => p,
410            Err(e) => {
411                // v1.4.106 codex 0932 F3 [P2]: strict 模式 → 返 Err 让上层 retry,
412                // 不 silent 当 Ok(empty). lenient (read path) 保留老行为.
413                if error_mode.is_strict() {
414                    tracing::warn!(
415                        acc_id,
416                        error = %e,
417                        "v1.4.106 F3 strict: order response decode failed → \
418                         returning Err to trigger retry (push-refresh path)"
419                    );
420                    return Err(futu_core::error::FutuError::from(e));
421                }
422                tracing::debug!(acc_id, error = %e, "order response decode failed (lenient)");
423                return Ok(vec![]);
424            }
425        };
426
427        if let Err(status_err) = backend_order_list_status_like_cpp(
428            parsed.result,
429            parsed.msg_header.as_ref(),
430            parsed.err_msg.as_deref(),
431            acc_id,
432        ) {
433            // v1.4.110: C++ CheckRspHeaderAndGetSvrRet rejects missing or
434            // mismatched msg_header.account_id. Treat structural response
435            // issues as retryable even on lenient read paths so they cannot
436            // masquerade as a legitimate empty order snapshot.
437            if error_mode.is_strict() || !status_err.is_backend_error {
438                tracing::warn!(
439                    acc_id,
440                    result = status_err.result,
441                    error = %status_err.message,
442                    "v1.4.110: order query status/header invalid -> returning Err \
443                     to trigger retry (push-refresh path)"
444                );
445                return Err(futu_core::error::FutuError::ServerError {
446                    ret_type: status_err.result,
447                    msg: format!("{} (acc_id={acc_id})", status_err.message),
448                });
449            }
450            return Ok(vec![]);
451        }
452
453        for o in &parsed.orders {
454            let jp_acc_type = o
455                .sub_account_id
456                .and_then(|id| trd_cache.jp_acc_type_for_sub_account(acc_id, id));
457            if let Some(order) =
458                order_proto_to_cached_with_jp_acc_type_like_cpp(trd_env, o, jp_acc_type)
459            {
460                all_orders.push(order);
461            }
462        }
463
464        // v1.4.106 codex 0955 F5: 显式分三档处理:
465        // - completed=true → 全部拉完, 写 cache.
466        // - completed=false + page_flag 非空 → 继续 paginate.
467        // - completed=false + page_flag 空/缺 → partial failure, 不写 cache, 返 Err.
468        let parsed_completed = parsed.completed.unwrap_or(false);
469        if parsed_completed {
470            completed = true;
471            break;
472        }
473        match parsed.page_flag {
474            Some(ref pf) if !pf.is_empty() => {
475                page_flag = Some(pf.clone());
476            }
477            _ => {
478                // partial + page_flag 缺 → 不能继续, backend 状态机异常.
479                tracing::warn!(
480                    acc_id,
481                    partial_count = all_orders.len(),
482                    "v1.4.106 audit 0955 F5: backend completed=false 但 page_flag \
483                     缺失/空 — partial response, 不写 cache 防 stub 被抹"
484                );
485                return Err(futu_core::error::FutuError::Codec(
486                    "query_orders: partial response (completed=false + missing page_flag)"
487                        .to_string(),
488                ));
489            }
490        }
491    }
492
493    // v1.4.106 codex 0955 F5: 超 MAX_PAGES 仍未 completed → partial, 不写 cache.
494    if !completed {
495        tracing::warn!(
496            acc_id,
497            max_pages = MAX_PAGES,
498            partial_count = all_orders.len(),
499            "v1.4.106 audit 0955 F5: pagination 超 MAX_PAGES 仍未 completed — \
500             partial response, 不写 cache 防 stub 被抹"
501        );
502        return Err(futu_core::error::FutuError::Codec(format!(
503            "query_orders: pagination exceeded {MAX_PAGES} pages without completed=true"
504        )));
505    }
506
507    tracing::debug!(acc_id, count = all_orders.len(), "orders queried");
508    // v1.4.93 S3 BUG-5318-009 真修:用 merge_preserving_stubs 代替 update_orders
509    // (= orders.insert 整覆盖)。
510    //
511    // 历史坑(v1.4.90 S BUG-e4da-009 fix 漏修这一层):
512    // - v1.4.90 把 place_order.rs / modify_order.rs 三个 *outer* callsite 改成
513    //   merge,但 query_orders 内部仍 update_orders → orders.insert 整覆盖。
514    // - 实际执行顺序:
515    //   1. PlaceOrder handler upsert stub → cache: [stub]
516    //   2. spawn task 调 query_orders →
517    //   3. backend 返 vec![] (just-placed not yet authoritative) →
518    //   4. *trd_cache.update_orders(acc_id, vec![])* → cache: []  ← STUB 被抹
519    //   5. query_orders 返 Ok(vec![]) →
520    //   6. outer caller cache.merge_preserving_stubs(acc_id, vec![]) →
521    //      existing_stubs = [] → cache 仍 []  ← merge 来晚了
522    //   7. log "merged ... count=0"
523    // - 端到端:stub 在 spawn 内被 query_orders 自己抹掉,外层 merge 是 dead code
524    //   from the stub's perspective.
525    //
526    // 真机证据(claude-5318 v1.4.90 real-money US.F PlaceOrder, 2026-04-25):
527    // - daemon log: place_order.rs:436 "stub upsert (order_id=15250071496077083016)"
528    // - daemon log: place_order.rs:502 "merge_preserving_stubs ... count=0"
529    // - /api/orders 0/5/30/60s 全空
530    //
531    // 修法:把 update_orders(仅 caller = 此处)改为 merge_preserving_stubs。
532    // - 仍保持 query_orders 的"populate cache as side-effect"语义
533    // - 但 backend 的权威列表与 fresh stub 共存(< 30s TTL)
534    // - 外层 callsite 的 merge_preserving_stubs 现在是幂等冗余(无害)
535    //
536    // 对 dispatcher.rs:228 push handler 调用:仅迭代返回的 Vec,不依赖 cache
537    // 状态,行为不变。
538    //
539    // 同坑 #44 "同 bug 跨版本 regression 链": v1.4.73→89 7 版 + v1.4.90 8th 版的
540    // "每版补一层没补根因"。本版(v1.4.93 S3)真补到 inner helper 这一层。
541    trd_cache.merge_preserving_stubs(acc_id, all_orders.clone());
542    Ok(all_orders)
543}