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    let (strategy_type, combo_legs) = o
117        .multi_leg_info
118        .as_ref()
119        .map(project_order_multi_leg_info_to_cached_like_cpp)
120        .unwrap_or_default();
121
122    Some(CachedOrder {
123        order_id,
124        order_id_ex,
125        code: o.symbol.as_ref().cloned().unwrap_or_default(),
126        name: o.stock_name.as_ref().cloned().unwrap_or_default(),
127        trd_side,
128        order_type,
129        order_status,
130        qty: parse_backend_decimal(&o.qty),
131        price: parse_backend_decimal(&o.price),
132        fill_qty: parse_backend_decimal(&o.cum_qty),
133        fill_avg_price: parse_backend_decimal(&o.avg_fill_price),
134        create_time: String::new(),
135        update_time: String::new(),
136        last_err_msg: o.last_err_msg.clone(),
137        sec_market: None,
138        create_timestamp,
139        update_timestamp,
140        remark: extracted_remark,
141        time_in_force: None,
142        fill_outside_rth: None,
143        aux_price: None,
144        trail_type: None,
145        trail_value: None,
146        trail_spread: None,
147        currency: o.currency.map(|c| c as i32),
148        trd_market,
149        strategy_type,
150        combo_legs,
151        backend_order_id,
152        order_version,
153        exchange_code,
154        exchange,
155        security_type,
156        session: futu_trd::projection::order_trade_time_type_to_session(order_trade_time_type),
157        order_trade_time_type,
158        jp_acc_type,
159        is_stub: false,
160        is_local_order: false,
161        stub_inserted_at_ms: 0,
162        is_pending_broker_confirm: false,
163    })
164}
165
166fn project_order_multi_leg_info_to_cached_like_cpp(
167    multi_leg: &crate::proto_internal::odr_sys_cmn::MultiLegInfo,
168) -> (Option<i32>, Vec<futu_proto::qot_common::ComboLeg>) {
169    // Ref:
170    // - FutuOpenD/Src/NNProtoCenter/Trade/Order/NNProto_Trd_OrderReal.cpp:9-37
171    // - FutuOpenD/Src/APIServer/Business/Trade/_APIServer_Trd_Comm.cpp:2348-2368
172    //
173    // C++ unpacks backend `multi_leg_info` into `Ndt_Trd_Order.enOptionStgyType`
174    // plus an order-id keyed combo-leg list, then `OrderData_NNToAPI` projects
175    // both for current and history order-list responses.
176    let strategy_type = multi_leg
177        .strategy_type
178        .and_then(combo_strategy_type_s2c_to_api_like_cpp);
179    let combo_legs = multi_leg
180        .leg_infos
181        .iter()
182        .map(combo_leg_data_s2c_to_api_like_cpp)
183        .collect();
184    (strategy_type, combo_legs)
185}
186
187fn combo_strategy_type_s2c_to_api_like_cpp(strategy_type: u32) -> Option<i32> {
188    let api = match strategy_type {
189        1 => futu_proto::qot_common::OptionStrategyType::SingleOption,
190        2 | 3 => futu_proto::qot_common::OptionStrategyType::Covered,
191        4 => futu_proto::qot_common::OptionStrategyType::Spread,
192        6 => futu_proto::qot_common::OptionStrategyType::Straddle,
193        7 => futu_proto::qot_common::OptionStrategyType::Strangle,
194        8 => futu_proto::qot_common::OptionStrategyType::Collar,
195        9 => futu_proto::qot_common::OptionStrategyType::Butterfly,
196        11 => futu_proto::qot_common::OptionStrategyType::Condor,
197        13 => futu_proto::qot_common::OptionStrategyType::IronButterfly,
198        14 => futu_proto::qot_common::OptionStrategyType::IronCondor,
199        15 => futu_proto::qot_common::OptionStrategyType::CalendarSpread,
200        16 => futu_proto::qot_common::OptionStrategyType::DiagonalSpread,
201        17..=19 => futu_proto::qot_common::OptionStrategyType::Customize,
202        _ => futu_proto::qot_common::OptionStrategyType::Unknown,
203    };
204    (api != futu_proto::qot_common::OptionStrategyType::Unknown).then_some(api as i32)
205}
206
207fn combo_leg_data_s2c_to_api_like_cpp(
208    leg: &crate::proto_internal::odr_sys_cmn::OrderLegInfo,
209) -> futu_proto::qot_common::ComboLeg {
210    futu_proto::qot_common::ComboLeg {
211        security: futu_proto::qot_common::Security {
212            market: exchange_to_qot_market_like_cpp(
213                leg.leg_exchange.as_deref().unwrap_or_default(),
214            ),
215            code: leg.leg_symbol.clone().unwrap_or_default(),
216        },
217        side: leg.leg_side.map(|v| v as i32),
218        qty_ratio: leg
219            .leg_ratio_qty
220            .as_deref()
221            .and_then(|v| v.parse::<f64>().ok()),
222        position_id: leg.position_id.as_deref().map(hash_str_to_u64),
223    }
224}
225
226fn exchange_to_qot_market_like_cpp(exchange: &str) -> i32 {
227    match exchange {
228        // Ref: FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:338-383
229        "SEHK" | "HKFE" => futu_proto::qot_common::QotMarket::HkSecurity as i32,
230        "US" | "NYMEX" | "COMEX" | "CBOT" | "CME" | "CBOE" => {
231            futu_proto::qot_common::QotMarket::UsSecurity as i32
232        }
233        "SSE" => futu_proto::qot_common::QotMarket::CnshSecurity as i32,
234        "SZSE" => futu_proto::qot_common::QotMarket::CnszSecurity as i32,
235        "SGX" => futu_proto::qot_common::QotMarket::SgSecurity as i32,
236        "OSE" | "JP" => futu_proto::qot_common::QotMarket::JpSecurity as i32,
237        "CRYPTO" => futu_proto::qot_common::QotMarket::CcSecurity as i32,
238        "BMS" => futu_proto::qot_common::QotMarket::MySecurity as i32,
239        _ => futu_proto::qot_common::QotMarket::Unknown as i32,
240    }
241}
242
243pub(super) fn sim_order_proto_to_cached_like_cpp(
244    trd_env: i32,
245    o: &sim_odr_sys_cmn::Order,
246) -> Option<futu_cache::trd_cache::CachedOrder> {
247    use futu_cache::trd_cache::CachedOrder;
248
249    o.order_id.as_ref()?;
250    o.side?;
251    let trd_market = if trd_env == 1 {
252        let trd_market = backend_real_market_to_trd_market_like_cpp(o.market?);
253        if !is_valid_real_trd_market_like_cpp(trd_market) {
254            return None;
255        }
256        Some(trd_market)
257    } else {
258        o.market.map(map_backend_market_to_trd_market)
259    };
260    o.symbol.as_ref()?;
261    o.create_time?;
262    o.expire_time?;
263    o.status?;
264
265    let order_id_ex = o.order_id.clone().unwrap_or_default();
266    let order_id: u64 = if order_id_ex.is_empty() {
267        0
268    } else {
269        hash_str_to_u64(&order_id_ex)
270    };
271    let create_timestamp = o.create_time.map(|t| t as f64 / 1_000_000.0);
272    let update_timestamp = o.update_time.map(|t| t as f64 / 1_000_000.0);
273    let order_status = backend_order_status_to_ftapi(o.status.unwrap_or(0));
274    let trd_side = o.side.unwrap_or(0) as i32;
275    let security_type = o.security_type.map(|v| v as i32);
276    let order_type = backend_order_type_to_ftapi(o.r#type.unwrap_or(0), trd_market, security_type);
277    let backend_order_id = order_id_ex.clone();
278    let order_version = o.version.unwrap_or(0) as i32;
279    let exchange_code = o.exchange_code.unwrap_or(0) as i32;
280    let exchange = o.exchange.clone().unwrap_or_default();
281    let security_type = security_type.unwrap_or(0);
282    let extracted_remark = match parse_open_d_text(o.text.as_deref()) {
283        Some((_local_id, user_remark)) => Some(user_remark),
284        None => o.text.clone(),
285    };
286
287    let order_trade_time_type = o.sup.as_ref().and_then(|s| s.order_trade_time_type);
288
289    Some(CachedOrder {
290        order_id,
291        order_id_ex,
292        code: o.symbol.as_ref().cloned().unwrap_or_default(),
293        name: o.stock_name.as_ref().cloned().unwrap_or_default(),
294        trd_side,
295        order_type,
296        order_status,
297        qty: parse_backend_decimal(&o.qty),
298        price: parse_backend_decimal(&o.price),
299        fill_qty: parse_backend_decimal(&o.cum_qty),
300        fill_avg_price: parse_backend_decimal(&o.avg_fill_price),
301        create_time: String::new(),
302        update_time: String::new(),
303        last_err_msg: o.last_err_msg.clone(),
304        sec_market: None,
305        create_timestamp,
306        update_timestamp,
307        remark: extracted_remark,
308        time_in_force: None,
309        fill_outside_rth: None,
310        aux_price: None,
311        trail_type: None,
312        trail_value: None,
313        trail_spread: None,
314        currency: o.currency.map(|c| c as i32),
315        trd_market,
316        strategy_type: None,
317        combo_legs: Vec::new(),
318        backend_order_id,
319        order_version,
320        exchange_code,
321        exchange,
322        security_type,
323        session: futu_trd::projection::order_trade_time_type_to_session(order_trade_time_type),
324        order_trade_time_type,
325        jp_acc_type: o.sub_account.map(|v| v as i32),
326        is_stub: false,
327        is_local_order: false,
328        stub_inserted_at_ms: 0,
329        is_pending_broker_confirm: false,
330    })
331}
332
333/// Strict order detail query for C++ push-refresh parity.
334///
335/// C++ receives `NOTICE_TYPE_ORDER_UPDATE` / `NOTICE_TYPE_ORDER_OP_RESULT` and
336/// calls `QueryOrderInfo` (4707/14707) with the pushed order IDs, rather than
337/// running a full `OrderListReq` (4708/14708). This matters for terminal states
338/// such as Deleted(23), which can be visible through the detail path while the
339/// current-order list omits them.
340pub async fn query_order_info_strict_for_push_refresh(
341    backend: &BackendConn,
342    acc_id: u64,
343    trd_cache: &TrdCache,
344    order_ids: &[String],
345    security_type: Option<u32>,
346    exchange: Option<&str>,
347) -> Result<Vec<futu_cache::trd_cache::CachedOrder>> {
348    use prost::Message;
349
350    let filtered_ids: Vec<String> = order_ids
351        .iter()
352        .filter(|id| !id.is_empty())
353        .cloned()
354        .collect();
355    if filtered_ids.is_empty() {
356        return Ok(vec![]);
357    }
358
359    let (trd_env, _account_trd_market, _enabled_markets) =
360        derive_acc_query_context(trd_cache, acc_id);
361    let cmd_id = order_info_cmd_for_env(trd_env);
362    let real_cipher = if trd_env == 1 {
363        require_real_cipher_for_strict_trade_query(acc_id, trd_cache, "QueryOrderInfo")?
364    } else {
365        Vec::new()
366    };
367    let req =
368        build_order_info_req_base(acc_id, security_type, exchange, &filtered_ids, real_cipher);
369    let resp = backend.request(cmd_id, req.encode_to_vec()).await?;
370    let (orders, received): (Vec<futu_cache::trd_cache::CachedOrder>, usize) = if trd_env == 1 {
371        let parsed: order_sys_interface::OrderDetailRsp = Message::decode(resp.body.as_ref())?;
372        if let Err(status_err) = backend_order_info_status_like_cpp(
373            parsed.result,
374            parsed.msg_header.as_ref(),
375            parsed.err_msg.as_deref(),
376            acc_id,
377            filtered_ids.len(),
378            parsed.orders.len(),
379        ) {
380            return Err(futu_core::error::FutuError::ServerError {
381                ret_type: status_err.result,
382                msg: format!("{} (acc_id={acc_id})", status_err.message),
383            });
384        }
385        let received = parsed.orders.len();
386        let orders = parsed
387            .orders
388            .iter()
389            .filter_map(|order| {
390                let jp_acc_type = order
391                    .sub_account_id
392                    .and_then(|id| trd_cache.jp_acc_type_for_sub_account(acc_id, id));
393                order_proto_to_cached_with_jp_acc_type_like_cpp(trd_env, order, jp_acc_type)
394            })
395            .collect();
396        (orders, received)
397    } else {
398        let parsed: sim_order_sys_interface::OrderDetailRsp = Message::decode(resp.body.as_ref())?;
399        if let Err(status_err) = backend_order_info_status_by_account_like_cpp(
400            parsed.result,
401            parsed
402                .msg_header
403                .as_ref()
404                .and_then(|header| header.account_id),
405            parsed.err_msg.as_deref(),
406            acc_id,
407            filtered_ids.len(),
408            parsed.orders.len(),
409        ) {
410            return Err(futu_core::error::FutuError::ServerError {
411                ret_type: status_err.result,
412                msg: format!("{} (acc_id={acc_id})", status_err.message),
413            });
414        }
415        let received = parsed.orders.len();
416        let orders = parsed
417            .orders
418            .iter()
419            .filter_map(|order| sim_order_proto_to_cached_like_cpp(trd_env, order))
420            .collect();
421        (orders, received)
422    };
423    let mut applied_orders = Vec::with_capacity(orders.len());
424    for order in orders {
425        if trd_cache.upsert_order(acc_id, order.clone()) {
426            applied_orders.push(order);
427        }
428    }
429    tracing::debug!(
430        acc_id,
431        cmd_id,
432        requested = filtered_ids.len(),
433        received,
434        applied = applied_orders.len(),
435        "order details queried"
436    );
437    Ok(applied_orders)
438}
439
440pub async fn query_orders_inner(
441    backend: &BackendConn,
442    acc_id: u64,
443    trd_cache: &TrdCache,
444    error_mode: QueryErrorMode,
445) -> Result<Vec<futu_cache::trd_cache::CachedOrder>> {
446    use prost::Message;
447
448    // v1.4.106 codex 0955 F4: req shape 对齐 C++ FillOrderListReq.
449    // v1.4.106 14708 audit: C++ QueryOrderList_Inner sim 分支使用
450    // sim_order_sys_interface::OrderListReq + registry Orders.sim_cmd。
451    let (trd_env, account_trd_market, enabled_markets) =
452        derive_acc_query_context(trd_cache, acc_id);
453    // C++ `M_SendProto_SetReqMsgHeader` always copies
454    // `INNData_Trd_AccList::GetAccCipher(m_nAccID)` into real trade query
455    // headers. Deleted(23) rows observed after daemon restart are sensitive to
456    // this real-order-list request shape: sending an always-empty cipher can
457    // return a valid but empty backend snapshot.
458    let real_cipher = if trd_env == 1 && error_mode.is_strict() {
459        require_real_cipher_for_strict_trade_query(acc_id, trd_cache, "QueryOrderList")?
460    } else {
461        trd_cache.get_cipher(acc_id).unwrap_or_default()
462    };
463    let real_req = build_order_list_req_base(
464        acc_id,
465        trd_env,
466        account_trd_market,
467        &enabled_markets,
468        real_cipher,
469    );
470    let sim_req = build_sim_order_list_req_base(acc_id, &enabled_markets);
471
472    let mut all_orders = Vec::new();
473    // C++ first page calls `FillOrderListReq(req, "")`, so the optional
474    // page_flag is present with an empty string on the wire.
475    let mut page_flag: Option<String> = Some(String::new());
476    // v1.4.106 codex 0955 F5: 显式 track completion. partial pagination
477    // (completed=false 但 page_flag 缺失 / 超 max_pages 未 completed) 必须
478    // 返 Err, 不能写 cache (stub orders 会被 partial 列表抹掉, 历史教训
479    // 见坑 #44 v1.4.73-90 saga).
480    const MAX_PAGES: usize = 100;
481    let mut completed = false;
482
483    for _page in 0..MAX_PAGES {
484        let spec = trade_query_command(TradeQueryOperation::Orders);
485        let (cmd_id, req_body) = if trd_env == 1 {
486            let mut paged_req = real_req.clone();
487            paged_req.page_flag = page_flag.clone();
488            (spec.real_cmd, paged_req.encode_to_vec())
489        } else {
490            let mut paged_req = sim_req.clone();
491            paged_req.page_flag = page_flag.clone();
492            (spec.sim_cmd, paged_req.encode_to_vec())
493        };
494
495        let resp = match backend.request(cmd_id, req_body).await {
496            Ok(r) => r,
497            Err(e) => {
498                tracing::debug!(acc_id, cmd_id, error = %e, "order query failed");
499                return Err(e);
500            }
501        };
502
503        let parsed: order_sys_interface::OrderListRsp = match Message::decode(resp.body.as_ref()) {
504            Ok(p) => p,
505            Err(e) => {
506                // v1.4.106 codex 0932 F3 [P2]: strict 模式 → 返 Err 让上层 retry,
507                // 不 silent 当 Ok(empty). lenient (read path) 保留老行为.
508                if error_mode.is_strict() {
509                    tracing::warn!(
510                        acc_id,
511                        error = %e,
512                        "v1.4.106 F3 strict: order response decode failed → \
513                         returning Err to trigger retry (push-refresh path)"
514                    );
515                    return Err(futu_core::error::FutuError::from(e));
516                }
517                tracing::debug!(acc_id, error = %e, "order response decode failed (lenient)");
518                return Ok(vec![]);
519            }
520        };
521
522        if let Err(status_err) = backend_order_list_status_like_cpp(
523            parsed.result,
524            parsed.msg_header.as_ref(),
525            parsed.err_msg.as_deref(),
526            acc_id,
527        ) {
528            // v1.4.110: C++ CheckRspHeaderAndGetSvrRet rejects missing or
529            // mismatched msg_header.account_id. Treat structural response
530            // issues as retryable even on lenient read paths so they cannot
531            // masquerade as a legitimate empty order snapshot.
532            if error_mode.is_strict() || !status_err.is_backend_error {
533                tracing::warn!(
534                    acc_id,
535                    result = status_err.result,
536                    error = %status_err.message,
537                    "v1.4.110: order query status/header invalid -> returning Err \
538                     to trigger retry (push-refresh path)"
539                );
540                return Err(futu_core::error::FutuError::ServerError {
541                    ret_type: status_err.result,
542                    msg: format!("{} (acc_id={acc_id})", status_err.message),
543                });
544            }
545            return Ok(vec![]);
546        }
547
548        for o in &parsed.orders {
549            let jp_acc_type = o
550                .sub_account_id
551                .and_then(|id| trd_cache.jp_acc_type_for_sub_account(acc_id, id));
552            if let Some(order) =
553                order_proto_to_cached_with_jp_acc_type_like_cpp(trd_env, o, jp_acc_type)
554            {
555                all_orders.push(order);
556            }
557        }
558
559        // v1.4.106 codex 0955 F5: 显式分三档处理:
560        // - completed=true → 全部拉完, 写 cache.
561        // - completed=false + page_flag 非空 → 继续 paginate.
562        // - completed=false + page_flag 空/缺 → partial failure, 不写 cache, 返 Err.
563        let parsed_completed = parsed.completed.unwrap_or(false);
564        if parsed_completed {
565            completed = true;
566            break;
567        }
568        match parsed.page_flag {
569            Some(ref pf) if !pf.is_empty() => {
570                page_flag = Some(pf.clone());
571            }
572            _ => {
573                // partial + page_flag 缺 → 不能继续, backend 状态机异常.
574                tracing::warn!(
575                    acc_id,
576                    partial_count = all_orders.len(),
577                    "v1.4.106 audit 0955 F5: backend completed=false 但 page_flag \
578                     缺失/空 — partial response, 不写 cache 防 stub 被抹"
579                );
580                return Err(futu_core::error::FutuError::Codec(
581                    "query_orders: partial response (completed=false + missing page_flag)"
582                        .to_string(),
583                ));
584            }
585        }
586    }
587
588    // v1.4.106 codex 0955 F5: 超 MAX_PAGES 仍未 completed → partial, 不写 cache.
589    if !completed {
590        tracing::warn!(
591            acc_id,
592            max_pages = MAX_PAGES,
593            partial_count = all_orders.len(),
594            "v1.4.106 audit 0955 F5: pagination 超 MAX_PAGES 仍未 completed — \
595             partial response, 不写 cache 防 stub 被抹"
596        );
597        return Err(futu_core::error::FutuError::Codec(format!(
598            "query_orders: pagination exceeded {MAX_PAGES} pages without completed=true"
599        )));
600    }
601
602    tracing::debug!(acc_id, count = all_orders.len(), "orders queried");
603    // v1.4.93 S3 BUG-5318-009 真修:用 merge_preserving_stubs 代替 update_orders
604    // (= orders.insert 整覆盖)。
605    //
606    // 历史坑(v1.4.90 S BUG-e4da-009 fix 漏修这一层):
607    // - v1.4.90 把 place_order.rs / modify_order.rs 三个 *outer* callsite 改成
608    //   merge,但 query_orders 内部仍 update_orders → orders.insert 整覆盖。
609    // - 实际执行顺序:
610    //   1. PlaceOrder handler upsert stub → cache: [stub]
611    //   2. spawn task 调 query_orders →
612    //   3. backend 返 vec![] (just-placed not yet authoritative) →
613    //   4. *trd_cache.update_orders(acc_id, vec![])* → cache: []  ← STUB 被抹
614    //   5. query_orders 返 Ok(vec![]) →
615    //   6. outer caller cache.merge_preserving_stubs(acc_id, vec![]) →
616    //      existing_stubs = [] → cache 仍 []  ← merge 来晚了
617    //   7. log "merged ... count=0"
618    // - 端到端:stub 在 spawn 内被 query_orders 自己抹掉,外层 merge 是 dead code
619    //   from the stub's perspective.
620    //
621    // 真机证据(claude-5318 v1.4.90 real-money US.F PlaceOrder, 2026-04-25):
622    // - daemon log: place_order.rs:436 "stub upsert (order_id=15250071496077083016)"
623    // - daemon log: place_order.rs:502 "merge_preserving_stubs ... count=0"
624    // - /api/orders 0/5/30/60s 全空
625    //
626    // 修法:把 update_orders(仅 caller = 此处)改为 merge_preserving_stubs。
627    // - 仍保持 query_orders 的"populate cache as side-effect"语义
628    // - 但 backend 的权威列表与 fresh stub 共存(< 30s TTL)
629    // - 外层 callsite 的 merge_preserving_stubs 现在是幂等冗余(无害)
630    //
631    // 对 dispatcher.rs:228 push handler 调用:仅迭代返回的 Vec,不依赖 cache
632    // 状态,行为不变。
633    //
634    // 同坑 #44 "同 bug 跨版本 regression 链": v1.4.73→89 7 版 + v1.4.90 8th 版的
635    // "每版补一层没补根因"。本版(v1.4.93 S3)真补到 inner helper 这一层。
636    trd_cache.merge_preserving_stubs(acc_id, all_orders.clone());
637    Ok(all_orders)
638}