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