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::collections::{BTreeMap, BTreeSet};
18
19use prost::Message;
20
21use futu_core::error::FutuError;
22
23use crate::conn::BackendConn;
24use crate::proto_internal::ft_cmd_stock_quote_sub;
25use crate::proto_internal::ft_cmd_stock_quote_sub_data;
26
27mod sub_bits;
28mod ticker;
29
30pub use sub_bits::{
31    SubBitOptions, SubscribeBitInfo, sub_type_to_bit_infos_with_options, sub_type_to_bits,
32    sub_type_to_bits_with_options,
33};
34pub use ticker::pull_latest_ticker;
35#[cfg(test)]
36use ticker::{
37    common_session_to_nn, nn_quote_session, tick_period_type, ticker_periods_for_nn_session,
38};
39
40/// 后端订阅命令 ID
41pub const CMD_QOT_SUB: u16 = 6211;
42/// 后端推送命令 ID
43pub const CMD_QOT_PUSH: u16 = 6212;
44/// 逐笔主动拉取命令 ID.
45///
46/// Ref: C++ `NNBiz_Qot_PullQot.cpp:327-365`
47/// `SendPullTickerListReq(... NN_ProtoCmd_Qot_Pull_Ticker ...)`.
48pub const CMD_QOT_PULL_TICKER: u16 = 6128;
49
50/// FTAPI SubType 枚举值
51pub mod sub_type {
52    pub const BASIC: i32 = 1;
53    pub const ORDER_BOOK: i32 = 2;
54    pub const TICKER: i32 = 4;
55    pub const RT: i32 = 5;
56    pub const KL_DAY: i32 = 6;
57    pub const KL_5MIN: i32 = 7;
58    pub const KL_15MIN: i32 = 8;
59    pub const KL_30MIN: i32 = 9;
60    pub const KL_60MIN: i32 = 10;
61    pub const KL_1MIN: i32 = 11;
62    pub const KL_WEEK: i32 = 12;
63    pub const KL_MONTH: i32 = 13;
64    pub const BROKER: i32 = 14;
65    pub const KL_QUARTER: i32 = 15;
66    pub const KL_YEAR: i32 = 16;
67    pub const KL_3MIN: i32 = 17;
68    pub const KL_10MIN: i32 = 18;
69    pub const KL_120MIN: i32 = 19;
70    pub const KL_180MIN: i32 = 20;
71    pub const KL_240MIN: i32 = 21;
72    pub const ORDER_BOOK_ODD: i32 = 22;
73}
74
75/// 后端 SubscribeBit 值
76pub mod sbit {
77    pub const PRICE: u32 = 0;
78    pub const STOCK_STATE: u32 = 1;
79    pub const STOCK_TYPE_SPECIFIC: u32 = 2;
80    pub const ORDER_BOOK: u32 = 3;
81    pub const ORDER_BOOK_DETAIL: u32 = 4; // v1.4.106 codex 1131 F6: detail 走独立 bit
82    pub const DEAL_STATISTICS: u32 = 5;
83    pub const HK_BROKER_QUEUE: u32 = 9;
84    pub const HK_BROKER_DETAIL: u32 = 10; // v1.4.106 codex 1131 F6: HK broker detail
85    pub const US_PREMARKET_AFTERHOURS: u32 = 13;
86    pub const US_LV2_ORDER: u32 = 17;
87    pub const TIME_SHARING: u32 = 20;
88    pub const KLINE_1MIN: u32 = 21;
89    pub const KLINE_3MIN: u32 = 22;
90    pub const KLINE_5MIN: u32 = 23;
91    pub const KLINE_15MIN: u32 = 24;
92    pub const KLINE_30MIN: u32 = 25;
93    pub const KLINE_60MIN: u32 = 26;
94    pub const KLINE_DAY: u32 = 27;
95    pub const KLINE_WEEK: u32 = 28;
96    pub const KLINE_MONTH: u32 = 29;
97    pub const KLINE_QUARTER: u32 = 30;
98    pub const KLINE_YEAR: u32 = 31;
99    // Ref: FutuOpenD/Src/NNProtoFile/Server/PB/Quote/FTCmdStockQuoteSubData.proto:92-98.
100    // These backend subscribe bits are non-contiguous; do not derive them from
101    // public FTAPI SubType values.
102    pub const KLINE_120MIN: u32 = 36;
103    pub const KLINE_240MIN: u32 = 37;
104    pub const TICK: u32 = 35;
105    pub const MEGER_LV2_ORDER: u32 = 39;
106    pub const KLINE_10MIN: u32 = 40;
107    pub const KLINE_180MIN: u32 = 41;
108}
109
110/// **v1.4.106 codex 1131 F1+F2**: 单个 (security, sub_types_with_opts) 集合.
111/// 让 caller 一次表达 (stock_id, ftapi_market, [(sub_type, SubBitOptions)]).
112///
113/// **v1.4.110 Phase 2 Slice 4**: 从 3-tuple 升级为 struct, 加 `broker_id` 字段
114/// 让 CMD6211 `SecuritySubscribe.broker_id` (FTCmdStockQuoteSubData.proto:269,
115/// 对齐 C++ `MktQotSub.cpp:454-463 SecuritySubscribe::set_broker_id`) 真正写
116/// 出去. broker_id = `None` 走 no-broker 路径 (普通股); `Some(NonZeroU32)`
117/// = crypto multi-broker 隔离.
118///
119/// 当前 (Phase 2) 所有 caller 都传 `broker_id = None` (Phase 3 才在 SubHandler
120/// 入口接 `securityFirm` resolve 写 broker, 见 codex 调研 12:13 增量).
121#[derive(Debug, Clone)]
122pub struct SecurityWithOpts {
123    pub stock_id: u64,
124    pub ftapi_market: i32,
125    pub sub_types_with_opts: Vec<(i32, SubBitOptions)>,
126    /// **v1.4.110 Phase 2 Slice 4**: broker-aware 订阅 (C++ `StockKey(stockID, brokerID)`,
127    /// 见 `qot_stock_key.rs` Phase 1 doc).
128    /// - `None`: C++ `m_hasBroker = false`, CMD6211 不写 broker_id field
129    /// - `Some(N)`: C++ `m_hasBroker = true`, CMD6211 写 broker_id = N
130    pub broker_id: Option<std::num::NonZeroU32>,
131}
132
133impl SecurityWithOpts {
134    /// 不带 broker 的快捷构造 (普通股 / 非 crypto).
135    pub fn new(
136        stock_id: u64,
137        ftapi_market: i32,
138        sub_types_with_opts: Vec<(i32, SubBitOptions)>,
139    ) -> Self {
140        Self {
141            stock_id,
142            ftapi_market,
143            sub_types_with_opts,
144            // v1.4.110 codex audit P3 #10: 默认 no-broker. 普通股 / non-crypto
145            // 路径走这里; crypto multi-broker 用 `with_broker(...)` 构造.
146            broker_id: None,
147        }
148    }
149
150    /// 带 broker 的快捷构造 (crypto multi-broker). `broker_id = 0` 自动降级 None.
151    pub fn with_broker(
152        stock_id: u64,
153        ftapi_market: i32,
154        sub_types_with_opts: Vec<(i32, SubBitOptions)>,
155        broker_id: u32,
156    ) -> Self {
157        Self {
158            stock_id,
159            ftapi_market,
160            sub_types_with_opts,
161            broker_id: std::num::NonZeroU32::new(broker_id),
162        }
163    }
164}
165
166/// Explicit backend market group for an empty CMD6211 set-state request.
167///
168/// Empty requests are used when the last desired item in a market bucket is
169/// removed. They still must carry the original backend route market in protocol
170/// header `reserved[0]`; routeMarketID=0 is rejected by backend.
171#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
172pub struct EmptyDesiredMarket {
173    pub mkt_type: u8,
174    pub is_depth: bool,
175}
176
177pub fn empty_desired_market_for_sub(
178    ftapi_market: i32,
179    sub_type: i32,
180) -> Option<EmptyDesiredMarket> {
181    let mkt_type = ftapi_market_to_quote_mkt(ftapi_market);
182    if mkt_type == 0 {
183        return None;
184    }
185    Some(EmptyDesiredMarket {
186        mkt_type,
187        is_depth: is_depth_sub_type(sub_type),
188    })
189}
190
191/// **v1.4.106 codex 1131 F1**: `submit_global_desired_set` 错误类型.
192/// 让 caller (SubHandler) 区分 backend reject vs decode err vs timeout →
193/// ack-then-commit pattern (F1 P1).
194#[derive(Debug)]
195pub enum QotSubError {
196    /// backend 返了 SubscribeSetRsp.result != 0 (reject), 携带 result 数 + warning.
197    BackendRejected { result: i32, warning: i32 },
198    /// 响应 decode 失败 — 该批次状态未知, 不写 local state.
199    DecodeFailed(String),
200    /// 网络 / TCP 错误 (timeout / 连接断). 透传 inner.
201    Transport(FutuError),
202    /// **v1.4.106 codex 0631 F3 [P2]**: caller 传了一个 backend 不识别的
203    /// `ftapi_market` (`ftapi_market_to_quote_mkt → 0`). 防御性 fail loud:
204    /// 不发任何 CMD6211, 整批 reject. caller 应早 validate 后再调.
205    /// 携带 offending list 让 caller 报清晰错给用户.
206    UnsupportedMarket { offending: Vec<i32> },
207    /// **v1.4.106 codex 0631 F3 [P2]**: 多 market 分批发送时, 部分市场
208    /// backend 失败 (BackendRejected / DecodeFailed) 但其它 OK — 该批次
209    /// 是部分应用 (split state). caller 不能当全成功, 需明示用户
210    /// "succeeded markets 已生效, failed 需重发".
211    PartialMarketFailure { succeeded: Vec<u8>, failed: Vec<u8> },
212}
213
214impl std::fmt::Display for QotSubError {
215    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
216        match self {
217            QotSubError::BackendRejected { result, warning } => {
218                write!(
219                    f,
220                    "backend rejected CMD6211: result={result} warning={warning}"
221                )
222            }
223            QotSubError::DecodeFailed(s) => write!(f, "CMD6211 response decode failed: {s}"),
224            QotSubError::Transport(e) => write!(f, "CMD6211 transport error: {e}"),
225            QotSubError::UnsupportedMarket { offending } => write!(
226                f,
227                "CMD6211 unsupported ftapi_market(s): {offending:?} \
228                 (ftapi_market_to_quote_mkt returned 0). Caller must validate \
229                 ftapi_market before submit_global_desired_set."
230            ),
231            QotSubError::PartialMarketFailure { succeeded, failed } => write!(
232                f,
233                "CMD6211 partial-market failure: succeeded={succeeded:?} \
234                 failed={failed:?}. State is split: succeeded markets are \
235                 applied, failed markets need re-submit."
236            ),
237        }
238    }
239}
240
241impl std::error::Error for QotSubError {
242    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
243        match self {
244            QotSubError::Transport(e) => Some(e),
245            _ => None,
246        }
247    }
248}
249
250impl From<FutuError> for QotSubError {
251    fn from(e: FutuError) -> Self {
252        QotSubError::Transport(e)
253    }
254}
255
256/// **v1.4.110 Phase 2 Slice 4**: per-security 输入 — `(stock_id, broker_id, sub_types_with_opts)`
257/// 3-tuple. broker_id = `None` 走 no-broker 路径 (普通股, Phase 2 默认),
258/// `Some(NonZeroU32)` 走 broker-aware 路径 (crypto multi-broker).
259///
260/// 抽出 type alias 防 clippy `type_complexity` warn.
261pub type SecuritySubscribeInput = (u64, Option<std::num::NonZeroU32>, Vec<(i32, SubBitOptions)>);
262
263/// **v1.4.106 codex 1131 F6**: 构建带 SubBitOptions 的订阅请求 — 真传 session
264/// / detail / extended_time 给 backend.
265///
266/// **v1.4.110 Phase 2 Slice 4**: 升 3-tuple `(stock_id, broker_id, sub_types)`
267/// 让 SecuritySubscribe.broker_id 真写出去 (对齐 C++ `MktQotSub.cpp:454-463`):
268/// ```cpp
269/// SecuritySubscribe::set_security_id(stSecKey.nStockID);
270/// if (stSecKey.HasBroker()) {
271///     SecuritySubscribe::set_broker_id(stSecKey.GetBrokerID());
272/// }
273/// ```
274/// - `broker_id = None` ⟺ C++ `m_hasBroker = false`, 不调 `set_broker_id`
275/// - `broker_id = Some(N)` ⟺ C++ `m_hasBroker = true`, 写 `broker_id = N`
276pub fn build_subscribe_req_with_options(
277    securities: &[SecuritySubscribeInput],
278) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
279    build_subscribe_req_with_options_inner(securities, None)
280}
281
282pub fn build_keep_subscribe_req_with_options(
283    securities: &[SecuritySubscribeInput],
284) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
285    // Ref: C++ `MktQotSub.cpp:532-535` stores the last normal SubscribeSetReq
286    // and sets `timer_sub=1` only for periodic keep-sub replay.
287    build_subscribe_req_with_options_inner(securities, Some(1))
288}
289
290fn build_subscribe_req_with_options_inner(
291    securities: &[SecuritySubscribeInput],
292    timer_sub: Option<i32>,
293) -> ft_cmd_stock_quote_sub::SubscribeSetReq {
294    let mut security_list = Vec::new();
295
296    for (stock_id, broker_id, sub_types_with_opts) in securities {
297        let mut bit_info_list = Vec::new();
298        for (st, opts) in sub_types_with_opts {
299            for info in sub_type_to_bit_infos_with_options(*st, opts.clone()) {
300                bit_info_list.push(ft_cmd_stock_quote_sub_data::BitInfo {
301                    bit: Some(info.bit),
302                    prob: Some(info.prob),
303                    prob2: info.prob2,
304                    prob2_v2: info.prob2_v2,
305                });
306            }
307        }
308        security_list.push(ft_cmd_stock_quote_sub_data::SecuritySubscribe {
309            security_id: Some(*stock_id),
310            bit_info_list,
311            // v1.4.110 Phase 2 Slice 4: broker-aware CMD6211 wire.
312            // Some(NZ) → 写 i32 (crypto multi-broker); None → 不写 (普通股).
313            broker_id: broker_id.map(|nz| nz.get() as i32),
314        });
315    }
316
317    ft_cmd_stock_quote_sub::SubscribeSetReq {
318        security_list,
319        reserved: None,
320        timer_sub,
321    }
322}
323
324/// FTAPI market → 后端 NN_QuoteMktType 映射
325///
326/// 0 = unknown (caller should reject loud per F7).
327pub fn ftapi_market_to_quote_mkt(market: i32) -> u8 {
328    match market {
329        1 => 1,  // HK → NN_QuoteMktType_HK
330        11 => 2, // US → NN_QuoteMktType_US
331        21 => 3, // SH → NN_QuoteMktType_SH
332        22 => 4, // SZ → NN_QuoteMktType_SZ
333        // Internal subscribe-market markers derived from static security info.
334        // Public QotMarket maps HK futures back to HK_Security (C++
335        // APIServer_Inner_API.cpp::Market_NNToAPI), but CMD6211 reserved[0]
336        // still needs the backend NN_QuoteMktType. These values are not
337        // client-facing QotMarket values; SubHandler derives them from
338        // CachedSecurityInfo.sec_type/mkt_id before entering this function.
339        5 => 5,   // HK futures legacy → NN_QuoteMktType_FUT_HK
340        6 => 6,   // HK futures current → NN_QuoteMktType_FUT_HK_NEW
341        9 => 9,   // HK_OPTIONS → NN_QuoteMktType_HK_OPTIONS
342        13 => 13, // SG futures → NN_QuoteMktType_SG_FUTURE
343        31 => 15, // SG stock → NN_QuoteMktType_SG_SECURITY
344        15 => 7,  // US_OPTIONS → NN_QuoteMktType_US_OPTIONS
345        14 => 8,  // US_FUTURE → NN_QuoteMktType_US_FUT
346        16 => 16, // JP futures → NN_QuoteMktType_JP_FUTURE
347        41 => 25, // JP stock → NN_QuoteMktType_JP_SECURITY
348        61 => 27, // MY stock → NN_QuoteMktType_MY_SECURITY
349        23 => 10, // SH_KCB → NN_QuoteMktType_SH_KCB
350        // v1.4.110 codex Phase 3 Slice 6b: QotMarket_CC_Security=91 (Crypto)
351        // → NN_QuoteMktType_CRYPTO=17. C++ `NNBase_Define_Enum.h:517`.
352        91 => 17, // Crypto → NN_QuoteMktType_CRYPTO
353        // F7 [P2]: AU=51 / CA=71 / FX=81 当前无明确 NN_QuoteMktType 对应。
354        // SubHandler 会按 F7 loud reject ftapi_market_to_quote_mkt==0 的 case.
355        _ => 0,
356    }
357}
358
359/// "深度"类 SubType.
360///
361/// 仅用于旧的 empty desired-set bucket 标记和 trace;非空 CMD6211 不能按
362/// normal/depth 拆分。CMD6211 是 backend 覆盖式 set-state,请求必须携带同一
363/// market 的完整订阅 bit 集合;C++ `MktQotSub.cpp::Timer_SendSubReq` 也是每个
364/// market 发一条 `SubscribeSetReq`,在同一个 `SecuritySubscribe` 内追加全部 bit。
365fn is_depth_sub_type(sub_type: i32) -> bool {
366    matches!(
367        sub_type,
368        sub_type::ORDER_BOOK | sub_type::ORDER_BOOK_ODD | sub_type::BROKER
369    )
370}
371
372/// **v1.4.106 codex 1131 F1+F2**: 给 caller (SubHandler) 的 set-state 接口.
373///
374/// **语义**: 发送整组 desired (stock_id, ftapi_market, sub_type_with_opts) →
375/// backend 返 SubscribeSetRsp. backend "覆盖式" — 所有不在新集合中的旧订阅
376/// 自动取消 (per FTCmdStockQuoteSub.proto 设计 doc:
377/// "server会覆盖此客户端之前的订阅,并主动推送一次新增加的股票订阅位数据").
378///
379/// **F1 ack-then-commit**: caller 必须仅在 Ok 后才写 SubscriptionManager state.
380/// Err 时 → 不写 state, 返用户 ret_type=-1 + 错误原因.
381///
382/// **F2 unsub via fresh set**: 退订通过传"new desired set 不含 removed key"
383/// 实现, 不调单独 unsub backend cmd. SubHandler 计算
384/// `current global - removed` 后调本 fn.
385///
386/// **max_sub_count**: 响应中 backend 下发的 quota — caller 应调
387/// `SubscriptionManager::set_total_quota_from_backend(max_sub_count as u32)`
388/// 同步真值 (F5).
389///
390/// **return 值**: backend 下发的 max_sub_count (caller 据此 update SubscriptionManager
391/// 总配额 — F5 P2 dynamic quota).
392pub async fn submit_global_desired_set(
393    backend: &BackendConn,
394    securities: &[SecurityWithOpts],
395) -> std::result::Result<i32, QotSubError> {
396    submit_global_desired_set_inner(backend, securities, false).await
397}
398
399/// Periodically replay the current CMD6211 desired set with `timer_sub=1`.
400///
401/// Ref: C++ `MktQotSub.cpp:543-580` `Timer_KeepSubStatus()` sends the cached
402/// `m_pbLastSendReq`; `MktQotSub.cpp:532-535` sets `timer_sub=1` for that replay.
403pub async fn submit_keep_subscribe_desired_set(
404    backend: &BackendConn,
405    securities: &[SecurityWithOpts],
406) -> std::result::Result<i32, QotSubError> {
407    submit_global_desired_set_inner(backend, securities, true).await
408}
409
410async fn submit_global_desired_set_inner(
411    backend: &BackendConn,
412    securities: &[SecurityWithOpts],
413    is_timer_sub: bool,
414) -> std::result::Result<i32, QotSubError> {
415    if securities.is_empty() {
416        // Empty desired-set submit is ambiguous without the original backend
417        // route market. Call `submit_empty_desired_set_for_markets` with the
418        // market bucket(s) being cleared instead.
419        return Err(QotSubError::UnsupportedMarket { offending: vec![0] });
420    }
421
422    // **v1.4.106 codex 0631 F3 [P2]**: 入口 validate 全部 ftapi_market 已知.
423    // 任一未知 → Err(UnsupportedMarket{offending}), 不发任何 CMD6211 请求.
424    //
425    // 老代码 (v1.4.106 codex 1131 F7) 是 silent skip + warn —— 全部未知时返
426    // Ok(()) 但 0 backend 请求 = silent success (CLAUDE.md 反模式 D
427    // silent-success). 修法: validate first, fail loud, caller 早 reject.
428    let offending: Vec<i32> = securities
429        .iter()
430        .filter_map(|sec| {
431            if ftapi_market_to_quote_mkt(sec.ftapi_market) == 0 {
432                Some(sec.ftapi_market)
433            } else {
434                None
435            }
436        })
437        .collect();
438    if !offending.is_empty() {
439        let mut dedup: Vec<i32> = offending;
440        dedup.sort_unstable();
441        dedup.dedup();
442        tracing::warn!(
443            offending = ?dedup,
444            "v1.4.106 audit 0631 F3: submit_global_desired_set rejected — unsupported ftapi_market(s)"
445        );
446        return Err(QotSubError::UnsupportedMarket { offending: dedup });
447    }
448
449    // 按后端市场类型分组(C++ 每个市场独立发送订阅请求)。
450    //
451    // 不再按 normal/depth 拆分: CMD6211 是覆盖式 set-state;同一 market 拆两条
452    // 请求会让后发请求覆盖先发请求,表现为 QUOTE 或 ORDER_BOOK 只能有一边持续
453    // push。C++ 对齐点: `MktQotSub.cpp::Timer_SendSubReq` 为一个 market 构造
454    // 单个 `SubscribeSetReq`,同一个 security 内聚合全部 `SubBit`。
455    //
456    // v1.4.110 Phase 2 Slice 4: MktGroup 内每项升 `(stock_id, broker_id, sub_types_with_opts)`
457    // 3-tuple, 让 build_subscribe_req_with_options 能写出
458    // SecuritySubscribe.broker_id.
459    type MktGroup = Vec<SecuritySubscribeInput>;
460    let mut by_market: BTreeMap<u8, MktGroup> = BTreeMap::new();
461    for sec in securities {
462        let mkt = ftapi_market_to_quote_mkt(sec.ftapi_market);
463        // F3 已 validate, 不可能是 0 — 此处 debug_assert.
464        debug_assert!(mkt != 0, "F3 validate 应已拒未知 market");
465        by_market.entry(mkt).or_default().push((
466            sec.stock_id,
467            sec.broker_id,
468            sec.sub_types_with_opts.clone(),
469        ));
470    }
471
472    // **v1.4.106 codex 0631 F3 [P2]**: 多 market 分批发送, 部分失败 → 不能当
473    // 全成功. 收集 succeeded / failed market 列表, 返 PartialMarketFailure.
474    //
475    // 老代码 .await? 短路: 第一个失败市场退出, max_sub_count=0 — caller 看不到
476    // 哪些市场已成功 (split state). 改为收集所有结果, 部分失败 loud Err.
477    let mut max_sub_count = 0i32;
478    let mut succeeded_markets: Vec<u8> = Vec::new();
479    let mut failed_markets: Vec<u8> = Vec::new();
480    let mut first_transport_err: Option<FutuError> = None;
481    for (mkt_type, secs) in &by_market {
482        let contains_depth_type = secs
483            .iter()
484            .any(|(_, _, sub_types)| sub_types.iter().any(|(st, _)| is_depth_sub_type(*st)));
485        match submit_subscribe_with_market(
486            backend,
487            secs,
488            *mkt_type,
489            contains_depth_type,
490            false,
491            is_timer_sub,
492        )
493        .await
494        {
495            Ok(count) => {
496                if count > max_sub_count {
497                    max_sub_count = count;
498                }
499                if !succeeded_markets.contains(mkt_type) {
500                    succeeded_markets.push(*mkt_type);
501                }
502            }
503            Err(QotSubError::Transport(e)) => {
504                // Transport error: 网络断 / TCP 错 — 整批中断, 透传不分批.
505                // 这种情况下 partial 没意义 (后续市场也会同样 Transport 错).
506                first_transport_err = Some(e);
507                break;
508            }
509            Err(_) => {
510                if !failed_markets.contains(mkt_type) {
511                    failed_markets.push(*mkt_type);
512                }
513            }
514        }
515    }
516    if let Some(e) = first_transport_err {
517        return Err(QotSubError::Transport(e));
518    }
519    if !failed_markets.is_empty() {
520        succeeded_markets.sort_unstable();
521        failed_markets.sort_unstable();
522        tracing::warn!(
523            succeeded = ?succeeded_markets,
524            failed = ?failed_markets,
525            "v1.4.106 audit 0631 F3: submit_global_desired_set partial failure"
526        );
527        return Err(QotSubError::PartialMarketFailure {
528            succeeded: succeeded_markets,
529            failed: failed_markets,
530        });
531    }
532
533    Ok(max_sub_count)
534}
535
536pub async fn submit_empty_desired_set_for_markets(
537    backend: &BackendConn,
538    markets: &[EmptyDesiredMarket],
539) -> std::result::Result<i32, QotSubError> {
540    if markets.is_empty() {
541        return Err(QotSubError::UnsupportedMarket { offending: vec![0] });
542    }
543
544    let groups: Vec<EmptyDesiredMarket> = BTreeSet::from_iter(markets.iter().copied())
545        .into_iter()
546        .collect();
547    if groups.iter().any(|g| g.mkt_type == 0) {
548        return Err(QotSubError::UnsupportedMarket { offending: vec![0] });
549    }
550
551    let mut max_sub_count = 0i32;
552    let mut succeeded_markets: Vec<u8> = Vec::new();
553    let mut failed_markets: Vec<u8> = Vec::new();
554    let mut first_transport_err: Option<FutuError> = None;
555
556    for group in groups {
557        match submit_subscribe_with_market(
558            backend,
559            &[],
560            group.mkt_type,
561            group.is_depth,
562            true,
563            false,
564        )
565        .await
566        {
567            Ok(count) => {
568                max_sub_count = max_sub_count.max(count);
569                if !succeeded_markets.contains(&group.mkt_type) {
570                    succeeded_markets.push(group.mkt_type);
571                }
572            }
573            Err(QotSubError::Transport(e)) => {
574                first_transport_err = Some(e);
575                break;
576            }
577            Err(_) => {
578                if !failed_markets.contains(&group.mkt_type) {
579                    failed_markets.push(group.mkt_type);
580                }
581            }
582        }
583    }
584
585    if let Some(e) = first_transport_err {
586        return Err(QotSubError::Transport(e));
587    }
588    if !failed_markets.is_empty() {
589        succeeded_markets.sort_unstable();
590        succeeded_markets.dedup();
591        failed_markets.sort_unstable();
592        failed_markets.dedup();
593        return Err(QotSubError::PartialMarketFailure {
594            succeeded: succeeded_markets,
595            failed: failed_markets,
596        });
597    }
598
599    Ok(max_sub_count)
600}
601
602/// 发送单个 (mkt_type, is_depth) 的 CMD6211 请求, 返 max_sub_count.
603///
604/// v1.4.110 Phase 2 Slice 4: secs 升 3-tuple `(stock_id, broker_id, sub_types)`
605/// 让 build_subscribe_req_with_options 能写出 SecuritySubscribe.broker_id.
606async fn submit_subscribe_with_market(
607    backend: &BackendConn,
608    secs: &[SecuritySubscribeInput],
609    mkt_type: u8,
610    is_depth: bool,
611    is_unsub_all: bool,
612    is_timer_sub: bool,
613) -> std::result::Result<i32, QotSubError> {
614    let req = if is_unsub_all {
615        // 全退场景 — security_list 空, reserved=1 让 body 非零 (Windows backend 兼容).
616        ft_cmd_stock_quote_sub::SubscribeSetReq {
617            security_list: vec![],
618            reserved: Some(1),
619            timer_sub: None,
620        }
621    } else if is_timer_sub {
622        build_keep_subscribe_req_with_options(secs)
623    } else {
624        build_subscribe_req_with_options(secs)
625    };
626    let body = req.encode_to_vec();
627
628    let mut reserved = [0u8; 10];
629    reserved[0] = mkt_type;
630    // v1.4.110 Phase 2 Slice 4: request_bits 是 trace 用, 不必含 broker_id;
631    // SecuritySubscribe.broker_id 在 build_subscribe_req_with_options 内部写.
632    let request_bits: Vec<(u64, Vec<(u32, i64)>)> = secs
633        .iter()
634        .map(|(stock_id, _broker_id, sub_types)| {
635            let bits = sub_types
636                .iter()
637                .flat_map(|(sub_type, opts)| sub_type_to_bits_with_options(*sub_type, opts.clone()))
638                .collect();
639            (*stock_id, bits)
640        })
641        .collect();
642
643    tracing::info!(
644        mkt_type,
645        is_depth,
646        is_unsub_all,
647        is_timer_sub,
648        count = secs.len(),
649        body_len = body.len(),
650        request_bits = ?request_bits,
651        "v1.4.106 audit 1131 F1: sending CMD6211 subscribe (set-state)"
652    );
653
654    let resp = backend
655        .request_with_reserved(CMD_QOT_SUB, body, reserved)
656        .await
657        .map_err(QotSubError::Transport)?;
658
659    let parsed: ft_cmd_stock_quote_sub::SubscribeSetRsp = Message::decode(resp.body.as_ref())
660        .map_err(|e| QotSubError::DecodeFailed(format!("{e}")))?;
661
662    let result = parsed.result.unwrap_or(-1);
663    let warning = parsed.warning_code.unwrap_or(0);
664    let max_sub_count = parsed.max_sub_count.unwrap_or(0);
665
666    if result != 0 {
667        // **F1 P1**: backend reject → 让 caller 知道 (Err), 不 silent-warn.
668        tracing::warn!(
669            mkt_type,
670            is_depth,
671            is_timer_sub,
672            result,
673            warning,
674            request_bits = ?request_bits,
675            "v1.4.106 audit 1131 F1: CMD6211 backend rejected"
676        );
677        return Err(QotSubError::BackendRejected { result, warning });
678    }
679
680    tracing::info!(
681        mkt_type,
682        is_depth,
683        is_timer_sub,
684        max_sub_count,
685        "v1.4.106 audit 1131 F1: CMD6211 ok"
686    );
687    Ok(max_sub_count)
688}
689
690#[cfg(test)]
691mod tests;