Skip to main content

futu_qot/
subscription_plan.rs

1//! QOT subscribe request planning helpers.
2//!
3//! This module owns small pure decisions that turn public `Qot_Sub` request
4//! flags into backend subscription options.  Surface handlers should call this
5//! layer instead of re-deriving session/detail semantics inline.
6
7pub const SESSION_NONE: i32 = 0;
8pub const SESSION_RTH: i32 = 1;
9pub const SESSION_ETH: i32 = 2;
10pub const SESSION_ALL: i32 = 3;
11pub const SESSION_OVERNIGHT: i32 = 5;
12pub const SECURITY_TYPE_DRVT: i32 = 8;
13pub const SECURITY_TYPE_FUTURE: i32 = 10;
14
15// Internal subscribe-market markers consumed by futu-backend::quote_sub.
16// They are derived from cached security metadata, not accepted as public
17// QotMarket input. Ref: C++ APIServer_Qot_StockBasic.cpp:45-51 and
18// APIServer_Qot_OrderBook.cpp:42-48 first PullOptinoInfo/GetStockID for
19// options, then subscribe/read using the resolved StockKey.
20pub const BACKEND_MARKET_HK_OPTION: i32 = 9;
21pub const BACKEND_MARKET_US_OPTION: i32 = 15;
22
23// Ref: `proto/Qot_Common.proto::SubType`. These are public FTAPI enum values,
24// not backend-dynamic configuration; keep them centralized so subscribe,
25// first-push, and cache-read gates do not drift.
26pub const SUB_TYPE_NONE: i32 = 0;
27pub const SUB_TYPE_BASIC: i32 = 1;
28pub const SUB_TYPE_ORDER_BOOK: i32 = 2;
29pub const SUB_TYPE_TICKER: i32 = 4;
30pub const SUB_TYPE_RT: i32 = 5;
31pub const SUB_TYPE_KL_DAY: i32 = 6;
32pub const SUB_TYPE_KL_5MIN: i32 = 7;
33pub const SUB_TYPE_KL_15MIN: i32 = 8;
34pub const SUB_TYPE_KL_30MIN: i32 = 9;
35pub const SUB_TYPE_KL_60MIN: i32 = 10;
36pub const SUB_TYPE_KL_1MIN: i32 = 11;
37pub const SUB_TYPE_KL_WEEK: i32 = 12;
38pub const SUB_TYPE_KL_MONTH: i32 = 13;
39pub const SUB_TYPE_BROKER: i32 = 14;
40pub const SUB_TYPE_KL_QUARTER: i32 = 15;
41pub const SUB_TYPE_KL_YEAR: i32 = 16;
42pub const SUB_TYPE_KL_3MIN: i32 = 17;
43
44pub const VALID_QOT_SUB_TYPES: &[i32] = &[
45    SUB_TYPE_BASIC,
46    SUB_TYPE_ORDER_BOOK,
47    SUB_TYPE_TICKER,
48    SUB_TYPE_RT,
49    SUB_TYPE_KL_DAY,
50    SUB_TYPE_KL_5MIN,
51    SUB_TYPE_KL_15MIN,
52    SUB_TYPE_KL_30MIN,
53    SUB_TYPE_KL_60MIN,
54    SUB_TYPE_KL_1MIN,
55    SUB_TYPE_KL_WEEK,
56    SUB_TYPE_KL_MONTH,
57    SUB_TYPE_BROKER,
58    SUB_TYPE_KL_QUARTER,
59    SUB_TYPE_KL_YEAR,
60    SUB_TYPE_KL_3MIN,
61];
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64pub struct SubscribeOptionsPlan {
65    pub requested_session: i32,
66    pub backend_session: i32,
67    pub extended_time: bool,
68    pub orderbook_detail: bool,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct RegQotPushResolvedSecurity {
73    pub sec_key: String,
74    pub stock_id: u64,
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq)]
78pub enum RegQotPushLookup {
79    Missing,
80    Present { stock_id: u64 },
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub enum SubscribePlanError {
85    OvernightSessionUnsupported,
86}
87
88impl SubscribeOptionsPlan {
89    /// Normalize Qot_Sub option fields.
90    ///
91    /// C++ `APIServer_Qot_Sub.cpp:194-200` rejects `Session_OVERNIGHT`.
92    /// C++ `ToSession(bExtendedTime, Session_NONE)` maps omitted session to
93    /// ETH when `extended_time=true`, otherwise RTH.
94    pub fn from_raw(
95        requested_session: Option<i32>,
96        extended_time: Option<bool>,
97        orderbook_detail: Option<bool>,
98    ) -> Result<Self, SubscribePlanError> {
99        let requested_session = requested_session.unwrap_or(SESSION_NONE);
100        if requested_session == SESSION_OVERNIGHT {
101            return Err(SubscribePlanError::OvernightSessionUnsupported);
102        }
103
104        let extended_time = extended_time.unwrap_or(false);
105        let backend_session = if requested_session == SESSION_NONE {
106            if extended_time {
107                SESSION_ETH
108            } else {
109                SESSION_RTH
110            }
111        } else {
112            requested_session
113        };
114
115        Ok(Self {
116            requested_session,
117            backend_session,
118            extended_time,
119            orderbook_detail: orderbook_detail.unwrap_or(false),
120        })
121    }
122}
123
124/// Whether a public `Qot_Common.QotMarket` value is accepted by `Qot_Sub`.
125///
126/// This intentionally mirrors the historical gateway gate exactly. `RegQotPush`
127/// also uses this predicate, but does not use [`normalize_qot_sub_market`]
128/// because C++ `RegQotPush` resolves the submitted `Qot_Common::Security` as-is.
129/// Ref: `proto/Qot_Common.proto:8-21`.
130#[must_use]
131pub fn is_valid_qot_market(market: i32) -> bool {
132    matches!(market, 1 | 11 | 21 | 22 | 31 | 41 | 51 | 61 | 71 | 81 | 91)
133}
134
135/// Normalize `Qot_Sub` market input.
136///
137/// `Qot_Sub` is a user-facing API and historically accepted callers that sent
138/// `Trd_Common.TrdMarket` enum values instead of `Qot_Common.QotMarket`.
139/// Preserve that compatibility in the shared QOT domain so gateway surfaces do
140/// not each carry their own mapping table. `RegQotPush` intentionally bypasses
141/// this helper; see [`is_valid_qot_market`].
142#[must_use]
143pub fn normalize_qot_sub_market(market: i32) -> Option<i32> {
144    if is_valid_qot_market(market) {
145        return Some(market);
146    }
147
148    // Ref: `proto/Trd_Common.proto:24-40` and `proto/Qot_Common.proto:8-21`.
149    // This is compatibility for callers that accidentally send TrdMarket enum
150    // values to Qot_Sub; new code should send QotMarket values directly.
151    match market {
152        2 => Some(11),   // TrdMarket.US -> QotMarket.US
153        3 => Some(21),   // TrdMarket.CN -> QotMarket.CNSH(默认上海)
154        4 => Some(1),    // TrdMarket.HKCC -> QotMarket.HK
155        5 => Some(1),    // TrdMarket.Futures -> QotMarket.HK(fallback)
156        6 => Some(31),   // TrdMarket.SG -> QotMarket.SG
157        7 => Some(91),   // TrdMarket.Crypto -> QotMarket.CC
158        8 => Some(51),   // TrdMarket.AU -> QotMarket.AU
159        15 => Some(41),  // TrdMarket.JP -> QotMarket.JP
160        111 => Some(61), // TrdMarket.MY -> QotMarket.MY
161        112 => Some(71), // TrdMarket.CA -> QotMarket.CA
162        _ => None,
163    }
164}
165
166/// Resolve `Qot_RegQotPush.security_list` to cache keys and stock ids.
167///
168/// Unlike `Qot_Sub`, C++ `RegQotPush` resolves the submitted
169/// `Qot_Common::Security` as-is and does not apply the TrdMarket compatibility
170/// mapping. The caller supplies a cache lookup closure so this pure domain
171/// helper stays independent from `futu-cache`.
172pub fn resolve_reg_qot_push_securities<F>(
173    securities: &[futu_proto::qot_common::Security],
174    mut lookup_stock_id: F,
175) -> Result<Vec<RegQotPushResolvedSecurity>, String>
176where
177    F: FnMut(&str) -> RegQotPushLookup,
178{
179    let mut resolved = Vec::with_capacity(securities.len());
180    for sec in securities {
181        if sec.code.trim().is_empty() {
182            return Err(
183                "RegQotPush: code 不能为空。C++ 会先对 securityList 逐项调用 GetStockID,\
184                 空 code 不能进入 push 注册/取消。"
185                    .to_string(),
186            );
187        }
188        if !is_valid_qot_market(sec.market) {
189            return Err(format!(
190                "RegQotPush: 非法 market={} for code={}。valid QotMarket: \
191                 1=HK/11=US/21=CNSH/22=CNSZ/31=SG/41=JP/51=AU/61=MY/71=CA/81=FX/91=CC。",
192                sec.market, sec.code
193            ));
194        }
195
196        let sec_key = format!("{}_{}", sec.market, sec.code);
197        match lookup_stock_id(&sec_key) {
198            RegQotPushLookup::Missing => {
199                return Err(format!(
200                    "RegQotPush: 未知证券 {}。C++ APIServer_Qot_RegQotPush.cpp:36-47 \
201                     在 register/unregister 前都会先 GetStockID;daemon 无法从静态表解析该证券,\
202                     不执行 push 注册状态变更。",
203                    sec_key
204                ));
205            }
206            RegQotPushLookup::Present { stock_id: 0 } => {
207                return Err(format!(
208                    "RegQotPush: 证券 {} 缺少 stock_id。C++ 需要 GetStockID 成功后才会调用 \
209                     RegOrUnRegPush;daemon 不执行 push 注册状态变更。",
210                    sec_key
211                ));
212            }
213            RegQotPushLookup::Present { stock_id } => {
214                resolved.push(RegQotPushResolvedSecurity { sec_key, stock_id });
215            }
216        }
217    }
218    Ok(resolved)
219}
220
221#[must_use]
222pub fn is_kl_sub_type(sub_type: i32) -> bool {
223    matches!(
224        sub_type,
225        SUB_TYPE_KL_DAY
226            | SUB_TYPE_KL_5MIN
227            | SUB_TYPE_KL_15MIN
228            | SUB_TYPE_KL_30MIN
229            | SUB_TYPE_KL_60MIN
230            | SUB_TYPE_KL_1MIN
231            | SUB_TYPE_KL_WEEK
232            | SUB_TYPE_KL_MONTH
233            | SUB_TYPE_KL_QUARTER
234            | SUB_TYPE_KL_YEAR
235            | SUB_TYPE_KL_3MIN
236    )
237}
238
239#[must_use]
240pub fn is_valid_sub_type(sub_type: i32) -> bool {
241    VALID_QOT_SUB_TYPES.contains(&sub_type)
242}
243
244/// Return the public name for option sub types rejected by C++ Qot_Sub.
245///
246/// Ref: C++ `APIServer_Qot_Sub.cpp::IsOptionSupportSub`. This is a fixed
247/// public proto compatibility matrix, not dynamic backend configuration.
248#[must_use]
249pub fn unsupported_option_sub_type_name(sub_type: i32) -> Option<&'static str> {
250    match sub_type {
251        SUB_TYPE_NONE => Some("None"),
252        SUB_TYPE_KL_30MIN => Some("KL_30Min"),
253        SUB_TYPE_KL_WEEK => Some("KL_Week"),
254        SUB_TYPE_KL_MONTH => Some("KL_Month"),
255        SUB_TYPE_KL_QUARTER => Some("KL_Quarter"),
256        SUB_TYPE_KL_YEAR => Some("KL_Year"),
257        SUB_TYPE_KL_3MIN => Some("KL_3Min"),
258        _ => None,
259    }
260}
261
262#[must_use]
263pub fn reg_push_rehab_types(sub_type: i32, requested: &[i32]) -> Vec<i32> {
264    if is_kl_sub_type(sub_type) {
265        if requested.is_empty() {
266            // C++ RegOrUnRegPush: KL 未指定 rehab 时默认前复权.
267            vec![1]
268        } else {
269            requested.to_vec()
270        }
271    } else {
272        vec![0]
273    }
274}
275
276#[must_use]
277pub fn kl_type_for_sub_type(sub_type: i32) -> Option<i32> {
278    match sub_type {
279        SUB_TYPE_KL_1MIN => Some(1),
280        SUB_TYPE_KL_DAY => Some(2),
281        SUB_TYPE_KL_WEEK => Some(3),
282        SUB_TYPE_KL_MONTH => Some(4),
283        SUB_TYPE_KL_YEAR => Some(5),
284        SUB_TYPE_KL_5MIN => Some(6),
285        SUB_TYPE_KL_15MIN => Some(7),
286        SUB_TYPE_KL_30MIN => Some(8),
287        SUB_TYPE_KL_60MIN => Some(9),
288        SUB_TYPE_KL_3MIN => Some(10),
289        SUB_TYPE_KL_QUARTER => Some(11),
290        _ => None,
291    }
292}
293
294#[must_use]
295pub fn backend_subscribe_market_for_security(
296    requested_market: i32,
297    sec_type: i32,
298    mkt_id: u32,
299) -> i32 {
300    if sec_type == SECURITY_TYPE_DRVT {
301        return match mkt_id {
302            // Ref: futu-backend stock_list::market_id_matches(HKOption/USOption).
303            // C++ resolves option rows before subscription; when the option row
304            // is known, route CMD6211 to the option NN_QuoteMktType bucket
305            // instead of the owner market bucket.
306            7 | 8 | 570..=579 => BACKEND_MARKET_HK_OPTION,
307            41..=45 => BACKEND_MARKET_US_OPTION,
308            // Some option-chain/static rows may not carry the precise market_id
309            // in older caches. In that case the public owner market still
310            // identifies HK vs US options.
311            _ => match requested_market {
312                1 => BACKEND_MARKET_HK_OPTION,
313                11 => BACKEND_MARKET_US_OPTION,
314                _ => requested_market,
315            },
316        };
317    }
318
319    if sec_type != SECURITY_TYPE_FUTURE {
320        return requested_market;
321    }
322
323    match mkt_id {
324        // C++ `APIServer_Inner_API.cpp::Market_NNToAPI` exposes HK futures as
325        // QotMarket_HK_Security to clients, while backend CMD6211 still needs
326        // NN_QuoteMktType_FUT_HK / FUT_HK_NEW in header reserved[0]. The
327        // `mkt_id` ranges below mirror `stock_list::market_id_matches`.
328        5 => 5,
329        6 | 110..=119 => 6,
330        60..=109 => 14,
331        160..=179 => 13,
332        185..=194 => 16,
333        // v1.4.108 Henry live report: HK futures such as FUCOnext/FUCOmain
334        // can arrive with private market_code=1406. Treat the whole 1400
335        // bucket as HK futures only after sec_type has proven this row is a
336        // future; non-future rows never enter this branch.
337        1400..=1499 => 6,
338        _ => match requested_market {
339            1 | 2 => 6,
340            11 => 14,
341            31 => 13,
342            41 => 16,
343            _ => requested_market,
344        },
345    }
346}
347
348/// Extract the public `QotMarket` prefix from a gateway/cache sec_key.
349///
350/// The canonical key format is `"{market}_{code}"`. The code part is allowed
351/// to contain additional underscores because only the first separator belongs
352/// to the key envelope.
353#[must_use]
354pub fn public_market_from_sec_key(sec_key: &str) -> Option<i32> {
355    let (market, code) = sec_key.split_once('_')?;
356    if code.is_empty() {
357        return None;
358    }
359    market.parse().ok()
360}
361
362/// Derive the backend desired-set key for a cached subscription row.
363///
364/// `Qot_Sub` stores subscriptions under the public FTAPI sec_key, but backend
365/// CMD6211 desired set must use the precise backend quote market for futures.
366/// This helper keeps the "parse public key, then derive backend market from
367/// cache sec_type/mkt_id" rule in one place for normal unsubscribe and
368/// unsub-all paths.
369#[must_use]
370pub fn backend_desired_key_for_sec_key(
371    sec_key: &str,
372    stock_id: u64,
373    sec_type: i32,
374    mkt_id: u32,
375) -> Option<(u64, i32)> {
376    if stock_id == 0 {
377        return None;
378    }
379    let public_market = public_market_from_sec_key(sec_key)?;
380    Some((
381        stock_id,
382        backend_subscribe_market_for_security(public_market, sec_type, mkt_id),
383    ))
384}
385
386#[cfg(test)]
387mod tests;