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