Skip to main content

futu_backend/
quote_sub.rs

1// 行情订阅管理
2//
3// FTAPI SubType → 后端 SubscribeBit 映射
4// CMD 6211: 发送订阅请求到后端 (set-state semantics, server 覆盖式)
5// CMD 6212: 接收行情推送
6//
7// **v1.4.106 codex 1131 F1+F2** 重构:
8// - submit_global_desired_set 公共 fn 发"全集"CMD6211,并返回 QotSubError。
9//   backend reject / decode 错 / timeout 都 loud 返回,caller 据此决定
10//   ack-then-commit (F1);旧 silent legacy wrapper 已在 v1.4.109 删除。
11// - backend 覆盖式
12//   ("每次请求,客户端都需要提供当前需要订阅的所有股票的所有订阅位")
13//   unsub 通过发**新的更小集**实现 — 与 sub 同 entry 复用 (F2)
14// - SubscribeRequestParams 携带 session / orderbook_detail / broker_detail /
15//   rehab_type / first_push 等 backend req fields (F6)
16
17use std::sync::Arc;
18
19use prost::Message;
20
21use futu_core::error::FutuError;
22
23use crate::command_runtime::{
24    SubscriptionDispatchContext, execute_qot_subscription_set,
25    execute_qot_subscription_set_with_dispatch,
26};
27use crate::conn::BackendConn;
28use crate::proto_internal::ft_cmd_stock_quote_sub;
29use crate::proto_internal::ft_cmd_stock_quote_sub_data;
30
31mod sub_bits;
32mod ticker;
33
34pub use futu_command_spec::CMD_QOT_PULL_TICKER;
35pub use futu_command_spec::CMD_QOT_PUSH_SUB as CMD_QOT_SUB;
36pub use futu_domain_qot_subscription::{
37    EmptyDesiredMarket, SecurityWithOpts, SubBitOptions, SubscribeSetCommandMode,
38    SubscribeSetPlanError, SubscribeSetSecurityPlan, empty_desired_market_for_sub,
39    ensure_subscribe_set_backend_success, ftapi_market_to_quote_mkt, is_depth_sub_type,
40    plan_empty_subscribe_set_commands, plan_subscribe_set_commands,
41};
42pub use sub_bits::{
43    SubscribeBitInfo, sub_type_to_bit_infos_with_options, sub_type_to_bits,
44    sub_type_to_bits_with_options,
45};
46pub use ticker::{TICKER_PAGE_MAX_ITEMS, pull_latest_ticker, pull_ticker_page};
47#[cfg(test)]
48use ticker::{
49    build_ticker_page_request, common_session_to_nn, nn_quote_session, tick_period_type,
50    ticker_periods_for_nn_session,
51};
52
53/// 后端推送命令 ID
54pub const CMD_QOT_PUSH: u16 = 6212;
55/// FTAPI SubType 枚举值
56pub mod sub_type {
57    pub const BASIC: i32 = 1;
58    pub const ORDER_BOOK: i32 = 2;
59    pub const TICKER: i32 = 4;
60    pub const RT: i32 = 5;
61    pub const KL_DAY: i32 = 6;
62    pub const KL_5MIN: i32 = 7;
63    pub const KL_15MIN: i32 = 8;
64    pub const KL_30MIN: i32 = 9;
65    pub const KL_60MIN: i32 = 10;
66    pub const KL_1MIN: i32 = 11;
67    pub const KL_WEEK: i32 = 12;
68    pub const KL_MONTH: i32 = 13;
69    pub const BROKER: i32 = 14;
70    pub const KL_QUARTER: i32 = 15;
71    pub const KL_YEAR: i32 = 16;
72    pub const KL_3MIN: i32 = 17;
73    pub const KL_10MIN: i32 = 18;
74    pub const KL_120MIN: i32 = 19;
75    pub const KL_180MIN: i32 = 20;
76    pub const KL_240MIN: i32 = 21;
77    pub const ORDER_BOOK_ODD: i32 = 22;
78}
79
80/// 后端 SubscribeBit 值
81pub mod sbit {
82    pub const PRICE: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_PRICE;
83    pub const STOCK_STATE: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_STOCK_STATE;
84    pub const STOCK_TYPE_SPECIFIC: u32 =
85        futu_domain_qot_subscription::SUBSCRIBE_BIT_STOCK_TYPE_SPECIFIC;
86    pub const ORDER_BOOK: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_ORDER_BOOK;
87    pub const DEAL_STATISTICS: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_DEAL_STATISTICS;
88    pub const HK_BROKER_QUEUE: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_HK_BROKER_QUEUE;
89    pub const US_PREMARKET_AFTERHOURS: u32 =
90        futu_domain_qot_subscription::SUBSCRIBE_BIT_US_PREMARKET_AFTERHOURS;
91    pub const US_LV2_ORDER: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_US_LV2_ORDER;
92    pub const TIME_SHARING: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_TIME_SHARING;
93    pub const KLINE_1MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_1MIN;
94    pub const KLINE_3MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_3MIN;
95    pub const KLINE_5MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_5MIN;
96    pub const KLINE_15MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_15MIN;
97    pub const KLINE_30MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_30MIN;
98    pub const KLINE_60MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_60MIN;
99    pub const KLINE_DAY: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_DAY;
100    pub const KLINE_WEEK: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_WEEK;
101    pub const KLINE_MONTH: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_MONTH;
102    pub const KLINE_QUARTER: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_QUARTER;
103    pub const KLINE_YEAR: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_YEAR;
104    // Ref: FutuOpenD/Src/NNProtoFile/Server/PB/Quote/FTCmdStockQuoteSubData.proto:92-98.
105    // These backend subscribe bits are non-contiguous; do not derive them from
106    // public FTAPI SubType values.
107    pub const KLINE_120MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_120MIN;
108    pub const KLINE_240MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_240MIN;
109    pub const TICK: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_TICK;
110    pub const MEGER_LV2_ORDER: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_MEGER_LV2_ORDER;
111    pub const KLINE_10MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_10MIN;
112    pub const KLINE_180MIN: u32 = futu_domain_qot_subscription::SUBSCRIBE_BIT_KLINE_180MIN;
113}
114
115/// **v1.4.106 codex 1131 F1**: `submit_global_desired_set` 错误类型.
116/// 让 caller (SubHandler) 区分 backend reject vs decode err vs timeout →
117/// ack-then-commit pattern (F1 P1).
118#[derive(Debug)]
119pub enum QotSubError {
120    /// backend 返了 SubscribeSetRsp.result != 0 (reject), 携带 result 数 + warning.
121    BackendRejected { result: i32, warning: i32 },
122    /// 响应 decode 失败 — 该批次状态未知, 不写 local state.
123    DecodeFailed(String),
124    /// 网络 / TCP 错误 (timeout / 连接断). 透传 inner.
125    Transport(FutuError),
126    /// **v1.4.106 codex 0631 F3 [P2]**: caller 传了一个 backend 不识别的
127    /// `ftapi_market` (`ftapi_market_to_quote_mkt → 0`). 防御性 fail loud:
128    /// 不发任何 CMD6211, 整批 reject. caller 应早 validate 后再调.
129    /// 携带 offending list 让 caller 报清晰错给用户.
130    UnsupportedMarket { offending: Vec<i32> },
131    /// **v1.4.106 codex 0631 F3 [P2]**: 多 market 分批发送时, 部分市场
132    /// backend 失败 (BackendRejected / DecodeFailed) 但其它 OK — 该批次
133    /// 是部分应用 (split state). caller 不能当全成功, 需明示用户
134    /// "succeeded markets 已生效, failed 需重发".
135    PartialMarketFailure { succeeded: Vec<u8>, failed: Vec<u8> },
136}
137
138impl std::fmt::Display for QotSubError {
139    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        match self {
141            QotSubError::BackendRejected { result, warning } => {
142                write!(
143                    f,
144                    "backend rejected CMD6211: result={result} warning={warning}"
145                )
146            }
147            QotSubError::DecodeFailed(s) => write!(f, "CMD6211 response decode failed: {s}"),
148            QotSubError::Transport(e) => write!(f, "CMD6211 transport error: {e}"),
149            QotSubError::UnsupportedMarket { offending } => write!(
150                f,
151                "CMD6211 unsupported ftapi_market(s): {offending:?} \
152                 (ftapi_market_to_quote_mkt returned 0). Caller must validate \
153                 ftapi_market before submit_global_desired_set."
154            ),
155            QotSubError::PartialMarketFailure { succeeded, failed } => write!(
156                f,
157                "CMD6211 partial-market failure: succeeded={succeeded:?} \
158                 failed={failed:?}. State is split: succeeded markets are \
159                 applied, failed markets need re-submit."
160            ),
161        }
162    }
163}
164
165impl std::error::Error for QotSubError {
166    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
167        match self {
168            QotSubError::Transport(e) => Some(e),
169            _ => None,
170        }
171    }
172}
173
174impl From<FutuError> for QotSubError {
175    fn from(e: FutuError) -> Self {
176        QotSubError::Transport(e)
177    }
178}
179
180#[derive(Clone, Copy, Debug, PartialEq, Eq)]
181pub struct QotSubscriptionWriterAdmission {
182    pub connection_generation: u64,
183    pub serial_no: u32,
184}
185
186#[derive(Clone, Copy, Debug, PartialEq, Eq)]
187pub struct DispatchAccepted {
188    pub connection_generation: u64,
189    pub serial_no: u32,
190    pub quote_market_type: u8,
191    pub runtime_dispatch_generation: u64,
192}
193
194#[derive(Clone, Copy, Debug, PartialEq, Eq)]
195pub enum NormalDispatchOutcome {
196    Succeeded,
197    Failed,
198}
199
200#[derive(Clone)]
201pub struct NormalSubscriptionDispatchHooks {
202    pub try_admit: Arc<dyn Fn(QotSubscriptionWriterAdmission) -> bool + Send + Sync>,
203    pub on_accepted:
204        Arc<dyn Fn(QotSubscriptionWriterAdmission, u8, Vec<u8>) -> DispatchAccepted + Send + Sync>,
205    pub on_outcome: Arc<dyn Fn(DispatchAccepted, NormalDispatchOutcome) + Send + Sync>,
206}
207
208/// **v1.4.110 Phase 2 Slice 4**: per-security 输入 — `(stock_id, broker_id, sub_types_with_opts)`
209/// 3-tuple. broker_id = `None` 走 no-broker 路径 (普通股, Phase 2 默认),
210/// `Some(NonZeroU32)` 走 broker-aware 路径 (crypto multi-broker).
211///
212/// 抽出 type alias 防 clippy `type_complexity` warn.
213pub type SecuritySubscribeInput = SubscribeSetSecurityPlan;
214
215fn plan_error_to_qot_sub_error(err: SubscribeSetPlanError) -> QotSubError {
216    match err {
217        SubscribeSetPlanError::EmptyDesiredSetWithoutMarket => {
218            QotSubError::UnsupportedMarket { offending: vec![0] }
219        }
220        SubscribeSetPlanError::UnsupportedMarket { offending } => {
221            QotSubError::UnsupportedMarket { offending }
222        }
223    }
224}
225
226/// **v1.4.106 codex 1131 F6**: 构建带 SubBitOptions 的订阅请求 — 真传 session
227/// / detail / extended_time 给 backend.
228///
229/// **v1.4.110 Phase 2 Slice 4**: 升 3-tuple `(stock_id, broker_id, sub_types)`
230/// 让 SecuritySubscribe.broker_id 真写出去 (对齐 C++ `MktQotSub.cpp:454-463`):
231/// ```cpp
232/// SecuritySubscribe::set_security_id(stSecKey.nStockID);
233/// if (stSecKey.HasBroker()) {
234///     SecuritySubscribe::set_broker_id(stSecKey.GetBrokerID());
235/// }
236/// ```
237/// - `broker_id = None` ⟺ C++ `m_hasBroker = false`, 不调 `set_broker_id`
238/// - `broker_id = Some(N)` ⟺ C++ `m_hasBroker = true`, 写 `broker_id = N`
239pub fn build_subscribe_req_with_options(
240    securities: &[SecuritySubscribeInput],
241) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
242    build_subscribe_req_with_options_inner(securities, None)
243}
244
245pub fn build_keep_subscribe_req_with_options(
246    securities: &[SecuritySubscribeInput],
247) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
248    // Ref: C++ `MktQotSub.cpp:532-535` stores the last normal SubscribeSetReq
249    // and sets `timer_sub=1` only for periodic keep-sub replay.
250    build_subscribe_req_with_options_inner(securities, Some(1))
251}
252
253// The current 2607 C++ source still writes legacy `BitInfo::prob2` on the
254// compatibility branches at `MktQotSubInstance.cpp:151,164`, while newer
255// branches use `prob2_v2`. Preserve both proto2 presence paths explicitly.
256#[allow(deprecated)]
257fn build_subscribe_req_with_options_inner(
258    securities: &[SecuritySubscribeInput],
259    timer_sub: Option<i32>,
260) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
261    let mut security_list = Vec::new();
262
263    for (stock_id, broker_id, sub_types_with_opts) in securities {
264        let mut bit_info_list = Vec::new();
265        for (st, opts) in sub_types_with_opts {
266            for info in sub_type_to_bit_infos_with_options(*st, opts.clone()) {
267                bit_info_list.push(ft_cmd_stock_quote_sub_data::BitInfo {
268                    bit: Some(info.bit),
269                    prob: info.prob,
270                    prob2: info.prob2,
271                    prob2_v2: info.prob2_v2,
272                });
273            }
274        }
275        security_list.push(ft_cmd_stock_quote_sub_data::SecuritySubscribe {
276            security_id: Some(*stock_id),
277            bit_info_list,
278            // v1.4.110 Phase 2 Slice 4: broker-aware CMD6211 wire.
279            // Some(NZ) → 写 i32 (crypto multi-broker); None → 不写 (普通股).
280            broker_id: broker_id.map(|nz| nz.get() as i32),
281        });
282    }
283
284    ft_cmd_stock_quote_sub::SubscribeSetReq {
285        security_list,
286        reserved: None,
287        timer_sub,
288    }
289}
290
291/// **v1.4.106 codex 1131 F1+F2**: 给 caller (SubHandler) 的 set-state 接口.
292///
293/// **语义**: 发送整组 desired (stock_id, ftapi_market, sub_type_with_opts) →
294/// backend 返 SubscribeSetRsp. backend "覆盖式" — 所有不在新集合中的旧订阅
295/// 自动取消 (per FTCmdStockQuoteSub.proto 设计 doc:
296/// "server会覆盖此客户端之前的订阅,并主动推送一次新增加的股票订阅位数据").
297///
298/// **F1 ack-then-commit**: caller 必须仅在 Ok 后才写 SubscriptionManager state.
299/// Err 时 → 不写 state, 返用户 ret_type=-1 + 错误原因.
300///
301/// **F2 unsub via fresh set**: 退订通过传"new desired set 不含 removed key"
302/// 实现, 不调单独 unsub backend cmd. SubHandler 计算
303/// `current global - removed` 后调本 fn.
304///
305/// **max_sub_count**: 响应中 backend 下发的 quota — caller 应调
306/// `SubscriptionManager::set_total_quota_from_backend(max_sub_count as u32)`
307/// 同步真值 (F5).
308///
309/// **return 值**: backend 下发的 max_sub_count (caller 据此 update SubscriptionManager
310/// 总配额 — F5 P2 dynamic quota).
311pub async fn submit_global_desired_set(
312    backend: &BackendConn,
313    securities: &[SecurityWithOpts],
314    hooks: &NormalSubscriptionDispatchHooks,
315) -> std::result::Result<i32, QotSubError> {
316    submit_global_desired_set_inner(backend, securities, hooks).await
317}
318
319async fn submit_global_desired_set_inner(
320    backend: &BackendConn,
321    securities: &[SecurityWithOpts],
322    hooks: &NormalSubscriptionDispatchHooks,
323) -> std::result::Result<i32, QotSubError> {
324    let plan = plan_subscribe_set_commands(securities, SubscribeSetCommandMode::Normal)
325        .map_err(plan_error_to_qot_sub_error)?;
326
327    // **v1.4.106 codex 0631 F3 [P2]**: 多 market 分批发送, 部分失败 → 不能当
328    // 全成功. 收集 succeeded / failed market 列表, 返 PartialMarketFailure.
329    //
330    // 老代码 .await? 短路: 第一个失败市场退出, max_sub_count=0 — caller 看不到
331    // 哪些市场已成功 (split state). 改为收集所有结果, 部分失败 loud Err.
332    let mut max_sub_count = 0i32;
333    let mut succeeded_markets: Vec<u8> = Vec::new();
334    let mut failed_markets: Vec<u8> = Vec::new();
335    let mut first_transport_err: Option<FutuError> = None;
336    for command in &plan.commands {
337        match submit_subscribe_with_market(
338            backend,
339            &command.securities,
340            command.mkt_type,
341            command.is_depth,
342            command.is_unsub_all,
343            hooks,
344        )
345        .await
346        {
347            Ok(count) => {
348                if count > max_sub_count {
349                    max_sub_count = count;
350                }
351                if !succeeded_markets.contains(&command.mkt_type) {
352                    succeeded_markets.push(command.mkt_type);
353                }
354            }
355            Err(QotSubError::Transport(e)) => {
356                // Transport error: 网络断 / TCP 错 — 整批中断, 透传不分批.
357                // 这种情况下 partial 没意义 (后续市场也会同样 Transport 错).
358                first_transport_err = Some(e);
359                break;
360            }
361            Err(_) => {
362                if !failed_markets.contains(&command.mkt_type) {
363                    failed_markets.push(command.mkt_type);
364                }
365            }
366        }
367    }
368    if let Some(e) = first_transport_err {
369        return Err(QotSubError::Transport(e));
370    }
371    if !failed_markets.is_empty() {
372        succeeded_markets.sort_unstable();
373        failed_markets.sort_unstable();
374        tracing::warn!(
375            succeeded = ?succeeded_markets,
376            failed = ?failed_markets,
377            "v1.4.106 audit 0631 F3: submit_global_desired_set partial failure"
378        );
379        return Err(QotSubError::PartialMarketFailure {
380            succeeded: succeeded_markets,
381            failed: failed_markets,
382        });
383    }
384
385    Ok(max_sub_count)
386}
387
388pub async fn submit_empty_desired_set_for_markets(
389    backend: &BackendConn,
390    markets: &[EmptyDesiredMarket],
391    hooks: &NormalSubscriptionDispatchHooks,
392) -> std::result::Result<i32, QotSubError> {
393    let plan = plan_empty_subscribe_set_commands(markets).map_err(plan_error_to_qot_sub_error)?;
394
395    let mut max_sub_count = 0i32;
396    let mut succeeded_markets: Vec<u8> = Vec::new();
397    let mut failed_markets: Vec<u8> = Vec::new();
398    let mut first_transport_err: Option<FutuError> = None;
399
400    for command in &plan.commands {
401        match submit_subscribe_with_market(
402            backend,
403            &command.securities,
404            command.mkt_type,
405            command.is_depth,
406            command.is_unsub_all,
407            hooks,
408        )
409        .await
410        {
411            Ok(count) => {
412                max_sub_count = max_sub_count.max(count);
413                if !succeeded_markets.contains(&command.mkt_type) {
414                    succeeded_markets.push(command.mkt_type);
415                }
416            }
417            Err(QotSubError::Transport(e)) => {
418                first_transport_err = Some(e);
419                break;
420            }
421            Err(_) => {
422                if !failed_markets.contains(&command.mkt_type) {
423                    failed_markets.push(command.mkt_type);
424                }
425            }
426        }
427    }
428
429    if let Some(e) = first_transport_err {
430        return Err(QotSubError::Transport(e));
431    }
432    if !failed_markets.is_empty() {
433        succeeded_markets.sort_unstable();
434        succeeded_markets.dedup();
435        failed_markets.sort_unstable();
436        failed_markets.dedup();
437        return Err(QotSubError::PartialMarketFailure {
438            succeeded: succeeded_markets,
439            failed: failed_markets,
440        });
441    }
442
443    Ok(max_sub_count)
444}
445
446/// 发送单个 (mkt_type, is_depth) 的 CMD6211 请求, 返 max_sub_count.
447///
448/// v1.4.110 Phase 2 Slice 4: secs 升 3-tuple `(stock_id, broker_id, sub_types)`
449/// 让 build_subscribe_req_with_options 能写出 SecuritySubscribe.broker_id.
450async fn submit_subscribe_with_market(
451    backend: &BackendConn,
452    secs: &[SecuritySubscribeInput],
453    mkt_type: u8,
454    is_depth: bool,
455    is_unsub_all: bool,
456    hooks: &NormalSubscriptionDispatchHooks,
457) -> std::result::Result<i32, QotSubError> {
458    let req = if is_unsub_all {
459        // 全退场景 — security_list 空, reserved=1 让 body 非零 (Windows backend 兼容).
460        ft_cmd_stock_quote_sub::SubscribeSetReq {
461            security_list: vec![],
462            reserved: Some(1),
463            timer_sub: None,
464        }
465    } else {
466        build_subscribe_req_with_options(secs)
467    };
468    let body = req.encode_to_vec();
469
470    let mut reserved = [0u8; 10];
471    reserved[0] = mkt_type;
472    // v1.4.110 Phase 2 Slice 4: request_bits 是 trace 用, 不必含 broker_id;
473    // SecuritySubscribe.broker_id 在 build_subscribe_req_with_options 内部写.
474    let request_bits: Vec<(u64, Vec<(u32, i64)>)> = secs
475        .iter()
476        .map(|(stock_id, _broker_id, sub_types)| {
477            let bits = sub_types
478                .iter()
479                .flat_map(|(sub_type, opts)| sub_type_to_bits_with_options(*sub_type, opts.clone()))
480                .collect();
481            (*stock_id, bits)
482        })
483        .collect();
484
485    tracing::info!(
486        mkt_type,
487        is_depth,
488        is_unsub_all,
489        count = secs.len(),
490        body_len = body.len(),
491        request_bits = ?request_bits,
492        "v1.4.106 audit 1131 F1: sending CMD6211 subscribe (set-state)"
493    );
494
495    let accepted = Arc::new(parking_lot::Mutex::new(None));
496    let context = SubscriptionDispatchContext {
497        quote_market_type: mkt_type,
498        exact_normal_wire: body.clone(),
499        hooks: hooks.clone(),
500        accepted: Arc::clone(&accepted),
501    };
502    let resp =
503        match execute_qot_subscription_set_with_dispatch(backend, body.into(), reserved, context)
504            .await
505        {
506            Ok(resp) => resp,
507            Err(error) => {
508                notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Failed);
509                return Err(QotSubError::Transport(error));
510            }
511        };
512
513    let parsed: ft_cmd_stock_quote_sub::SubscribeSetRsp = match Message::decode(resp.body.as_ref())
514    {
515        Ok(parsed) => parsed,
516        Err(error) => {
517            notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Failed);
518            return Err(QotSubError::DecodeFailed(format!("{error}")));
519        }
520    };
521
522    let status = match ensure_subscribe_set_backend_success(
523        parsed.result,
524        parsed.warning_code,
525        parsed.max_sub_count,
526    ) {
527        Ok(status) => status,
528        Err(reject) => {
529            // **F1 P1**: backend reject → 让 caller 知道 (Err), 不 silent-warn.
530            tracing::warn!(
531                mkt_type,
532                is_depth,
533                result = reject.result,
534                warning = reject.warning_code,
535                request_bits = ?request_bits,
536                "v1.4.106 audit 1131 F1: CMD6211 backend rejected"
537            );
538            notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Failed);
539            return Err(QotSubError::BackendRejected {
540                result: reject.result,
541                warning: reject.warning_code,
542            });
543        }
544    };
545
546    tracing::info!(
547        mkt_type,
548        is_depth,
549        max_sub_count = status.max_sub_count,
550        "v1.4.106 audit 1131 F1: CMD6211 ok"
551    );
552    notify_normal_dispatch_outcome(hooks, &accepted, NormalDispatchOutcome::Succeeded);
553    Ok(status.max_sub_count)
554}
555
556fn notify_normal_dispatch_outcome(
557    hooks: &NormalSubscriptionDispatchHooks,
558    accepted: &parking_lot::Mutex<Option<DispatchAccepted>>,
559    outcome: NormalDispatchOutcome,
560) {
561    let receipt = accepted.lock().take();
562    if let Some(receipt) = receipt {
563        (hooks.on_outcome)(receipt, outcome);
564    }
565}
566
567/// Submit one immutable cached keep request.
568///
569/// The caller supplies the exact last normal wire with only top-level
570/// `timer_sub=1` changed. This path deliberately has no normal-dispatch hooks,
571/// so keep cannot publish a new receipt or mutate normal eligibility state.
572pub async fn submit_cached_keep_wire(
573    backend: &BackendConn,
574    quote_market_type: u8,
575    wire_with_timer_sub_1: Vec<u8>,
576) -> std::result::Result<i32, QotSubError> {
577    let mut reserved = [0u8; 10];
578    reserved[0] = quote_market_type;
579    let resp = execute_qot_subscription_set(backend, wire_with_timer_sub_1.into(), reserved)
580        .await
581        .map_err(QotSubError::Transport)?;
582    let parsed: ft_cmd_stock_quote_sub::SubscribeSetRsp = Message::decode(resp.body.as_ref())
583        .map_err(|error| QotSubError::DecodeFailed(format!("{error}")))?;
584    let status = ensure_subscribe_set_backend_success(
585        parsed.result,
586        parsed.warning_code,
587        parsed.max_sub_count,
588    )
589    .map_err(|reject| QotSubError::BackendRejected {
590        result: reject.result,
591        warning: reject.warning_code,
592    })?;
593    Ok(status.max_sub_count)
594}
595
596#[cfg(test)]
597mod tests;