Skip to main content

futu_qot/
right_gate.rs

1//! QOT subscription permission decisions.
2//!
3//! This module intentionally keeps only plain domain inputs. Gateway/cache
4//! adapters turn backend right data and static security metadata into these
5//! structs, so REST/gRPC/raw-WS/MCP can converge on one permission matrix.
6
7use futu_core::diagnostic_text::{
8    jp_stock_quote_permission_hint, quote_permission_action_for_market,
9};
10
11pub const SECURITY_TYPE_DRVT: i32 = 8;
12pub const SECURITY_TYPE_INDEX: i32 = 6;
13pub const SECURITY_TYPE_FUTURE: i32 = 10;
14pub const SECURITY_TYPE_CRYPTO: i32 = 12;
15pub const QOT_MARKET_CC_SECURITY: i32 = 91;
16pub const QOT_RIGHT_UNKNOWN: i32 = 0;
17pub const QOT_RIGHT_BMP: i32 = 1;
18pub const QOT_RIGHT_LEVEL1: i32 = 2;
19pub const QOT_RIGHT_NO: i32 = 5;
20
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
22pub struct SecurityRightClass {
23    pub sec_type: i32,
24    pub mkt_id: u32,
25    pub option_code_like: bool,
26}
27
28impl SecurityRightClass {
29    fn is_option(self) -> bool {
30        self.sec_type == SECURITY_TYPE_DRVT || self.option_code_like
31    }
32
33    fn is_future(self, public_market: i32) -> bool {
34        self.sec_type == SECURITY_TYPE_FUTURE
35            || (public_market == 11 && (60..=109).contains(&self.mkt_id))
36    }
37
38    fn is_hk_future(self) -> bool {
39        // C++ 10.7 maps HK futures to NN_QuoteMktID 5/6 and 110..=119.
40        // 1400..=1449 is MY futures, so it must not use HK future qot-rights.
41        // Ref: NNBase_Define_Inline.h:271-369; NNBase_Define_Enum.h:1022-1024.
42        self.sec_type == SECURITY_TYPE_FUTURE && matches!(self.mkt_id, 5 | 6 | 110..=119)
43    }
44
45    fn is_us_security(self, public_market: i32) -> bool {
46        matches!(self.mkt_id, 10..=29 | 1200..=1249)
47            || (public_market == 11 && !self.is_future(public_market) && !self.is_option())
48    }
49
50    fn is_us_otc(self) -> bool {
51        // Ref: FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:4017-4021
52        // `IsUsOtcMarket(enMarket)` is exactly `NN_QuoteMktID_US_PINK`.
53        self.mkt_id == 13
54    }
55
56    fn is_index(self) -> bool {
57        self.sec_type == SECURITY_TYPE_INDEX
58    }
59
60    fn is_crypto(self, public_market: i32) -> bool {
61        self.sec_type == SECURITY_TYPE_CRYPTO
62            || public_market == QOT_MARKET_CC_SECURITY
63            || (360..=459).contains(&self.mkt_id)
64    }
65
66    fn is_sh_market(self, public_market: i32) -> bool {
67        matches!(self.mkt_id, 30 | 32 | 33 | 34 | 36..=40) || public_market == 21
68    }
69
70    fn is_sz_market(self, public_market: i32) -> bool {
71        matches!(self.mkt_id, 31 | 35) || public_market == 22
72    }
73
74    fn is_sg_future(self) -> bool {
75        self.sec_type == SECURITY_TYPE_FUTURE && (160..=179).contains(&self.mkt_id)
76    }
77
78    fn is_jp_future(self) -> bool {
79        self.sec_type == SECURITY_TYPE_FUTURE && (185..=194).contains(&self.mkt_id)
80    }
81
82    fn is_sg_security_market(self, public_market: i32) -> bool {
83        // Ref: FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:1229-1236,
84        // 4476-4494, 4636-4642; SG stock gates use SG stock right, not futures.
85        (180..=184).contains(&self.mkt_id)
86            || (public_market == 31
87                && self.sec_type != SECURITY_TYPE_FUTURE
88                && self.sec_type != SECURITY_TYPE_DRVT)
89    }
90
91    fn is_my_security_market(self, public_market: i32) -> bool {
92        // Ref: FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:1237-1244,
93        // 4476-4494; MY stock gates use MY stock right.
94        (1350..=1399).contains(&self.mkt_id)
95            || (public_market == 61
96                && self.sec_type != SECURITY_TYPE_FUTURE
97                && self.sec_type != SECURITY_TYPE_DRVT)
98    }
99
100    fn is_jp_security_market(self, public_market: i32) -> bool {
101        // Ref: FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:1245-1252,
102        // 4505-4512; JP stock gates use JP stock right and must not be
103        // rejected by JP futures BMP rights.
104        (830..=849).contains(&self.mkt_id)
105            || (public_market == 41
106                && self.sec_type != SECURITY_TYPE_FUTURE
107                && self.sec_type != SECURITY_TYPE_DRVT
108                && !self.is_jp_future()
109                && !(800..=829).contains(&self.mkt_id))
110    }
111}
112
113#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
114pub struct QotRightSnapshot {
115    pub hk_qot_right: i32,
116    pub us_qot_right: i32,
117    pub sh_qot_right: i32,
118    pub sz_qot_right: i32,
119    pub hk_option_qot_right: i32,
120    pub hk_future_qot_right: i32,
121    pub hk_option_orderbook_depth: Option<u32>,
122    pub hk_future_orderbook_depth: Option<u32>,
123    pub us_option_qot_right: i32,
124    pub us_index_qot_right: i32,
125    pub us_otc_qot_right: i32,
126    pub us_cme_future_qot_right: i32,
127    pub us_cbot_future_qot_right: i32,
128    pub us_nymex_future_qot_right: i32,
129    pub us_comex_future_qot_right: i32,
130    pub us_cboe_future_qot_right: i32,
131    pub sg_future_qot_right: i32,
132    pub jp_future_qot_right: i32,
133    pub sg_stock_qot_right: i32,
134    pub my_stock_qot_right: i32,
135    pub jp_stock_qot_right: i32,
136    pub cc_qot_right: i32,
137}
138
139fn qot_right_denies_realtime(right: i32) -> bool {
140    matches!(right, QOT_RIGHT_BMP | QOT_RIGHT_NO)
141}
142
143fn qot_right_is_none(right: i32) -> bool {
144    right == QOT_RIGHT_NO
145}
146
147fn us_future_right_for_mkt(rights: &QotRightSnapshot, mkt_id: u32) -> i32 {
148    match mkt_id {
149        60..=69 => rights.us_nymex_future_qot_right,
150        70..=79 => rights.us_comex_future_qot_right,
151        80..=89 => rights.us_cbot_future_qot_right,
152        90..=99 => rights.us_cme_future_qot_right,
153        100..=109 => rights.us_cboe_future_qot_right,
154        _ => QOT_RIGHT_UNKNOWN,
155    }
156}
157
158fn qot_permission_action(market_suffix: Option<&str>, code: &str) -> String {
159    let symbol = match market_suffix {
160        Some(_) if code.contains('.') => code.to_string(),
161        Some(suffix) => format!("{suffix}.{code}"),
162        None => code.to_string(),
163    };
164    quote_permission_action_for_market(market_suffix, &symbol).text_with_code()
165}
166
167pub fn qot_read_right_reject_reason(
168    market: i32,
169    code: &str,
170    security: SecurityRightClass,
171    rights: &QotRightSnapshot,
172) -> Option<String> {
173    // C++ CheckHasQotRight gates read-style QOT/F10 endpoints and is deliberately
174    // narrower than IsHasRightToSub. Notably, ordinary HK securities are not
175    // rejected here merely because HK real-time rights are BMP.
176    // Ref: FutuOpenD/Src/APIServer/APIServer_Inner_API.cpp:1037-1143.
177    if security.is_us_security(market) {
178        if security.is_index() && rights.us_index_qot_right == QOT_RIGHT_BMP {
179            return Some(format!(
180                "US index 行情权限不足,不能获取 {code};{}",
181                qot_permission_action(Some("US"), code)
182            ));
183        }
184        if security.is_us_otc() && rights.us_otc_qot_right == QOT_RIGHT_BMP {
185            return Some(format!(
186                "US OTC 行情权限不足,不能获取 {code};{}",
187                qot_permission_action(Some("US"), code)
188            ));
189        }
190        if rights.us_qot_right == QOT_RIGHT_BMP {
191            return Some(format!(
192                "US 行情权限不足,不能获取 {code};{}",
193                qot_permission_action(Some("US"), code)
194            ));
195        }
196    } else if market == 11 && security.is_option() {
197        if rights.us_option_qot_right == QOT_RIGHT_BMP {
198            return Some(format!(
199                "US option 行情权限不足,不能获取 {code};{}",
200                qot_permission_action(Some("US"), code)
201            ));
202        }
203    } else if security.is_future(market) && (60..=109).contains(&security.mkt_id) {
204        if us_future_right_for_mkt(rights, security.mkt_id) == QOT_RIGHT_BMP {
205            return Some(format!(
206                "US future 行情权限不足,不能获取 {code};{}",
207                qot_permission_action(Some("US"), code)
208            ));
209        }
210    } else if security.is_sh_market(market) {
211        if rights.sh_qot_right == QOT_RIGHT_BMP {
212            return Some(format!(
213                "SH 行情权限不足,不能获取 {code};{}",
214                qot_permission_action(Some("SH"), code)
215            ));
216        }
217    } else if security.is_sz_market(market) {
218        if rights.sz_qot_right == QOT_RIGHT_BMP {
219            return Some(format!(
220                "SZ 行情权限不足,不能获取 {code};{}",
221                qot_permission_action(Some("SZ"), code)
222            ));
223        }
224    } else if security.is_sg_future() {
225        if rights.sg_future_qot_right == QOT_RIGHT_BMP {
226            return Some(format!(
227                "SG future 行情权限不足,不能获取 {code};{}",
228                qot_permission_action(Some("SG"), code)
229            ));
230        }
231    } else if security.is_jp_future() {
232        if rights.jp_future_qot_right == QOT_RIGHT_BMP {
233            return Some(format!(
234                "JP future 行情权限不足,不能获取 {code};{}",
235                qot_permission_action(Some("JP"), code)
236            ));
237        }
238    } else if security.is_sg_security_market(market) {
239        if qot_right_is_none(rights.sg_stock_qot_right) {
240            return Some(format!(
241                "SG stock 行情权限不足,不能获取 {code};{}",
242                qot_permission_action(Some("SG"), code)
243            ));
244        }
245    } else if security.is_my_security_market(market) {
246        if qot_right_is_none(rights.my_stock_qot_right) {
247            return Some(format!(
248                "MY stock 行情权限不足,不能获取 {code};{}",
249                qot_permission_action(Some("MY"), code)
250            ));
251        }
252    } else if security.is_jp_security_market(market) {
253        if qot_right_is_none(rights.jp_stock_qot_right) {
254            return Some(format!(
255                "JP stock / 日股行情权限不足,不能获取 {code};{}",
256                jp_stock_quote_permission_hint().text_with_code()
257            ));
258        }
259    } else if security.is_crypto(market) && rights.cc_qot_right != QOT_RIGHT_LEVEL1 {
260        return Some(format!(
261            "Crypto 行情权限不足,不能获取 {code};\
262             请检查 Crypto MarketCrypto quote permissions;{}。",
263            qot_permission_action(Some("Crypto"), code)
264        ));
265    }
266
267    None
268}
269
270pub fn qot_sub_right_reject_reason(
271    market: i32,
272    code: &str,
273    security: SecurityRightClass,
274    sub_types: &[i32],
275    rights: &QotRightSnapshot,
276) -> Option<String> {
277    let wants_orderbook = sub_types.contains(&2);
278    let wants_ticker = sub_types.contains(&4);
279    let wants_broker = sub_types.contains(&14);
280    let is_option = security.is_option();
281    let is_hk_future = security.is_hk_future();
282    let is_future = security.is_future(market);
283    let is_index = security.is_index();
284    let is_crypto = security.is_crypto(market);
285    let is_sg_stock = security.is_sg_security_market(market);
286    let is_my_stock = security.is_my_security_market(market);
287    let is_jp_stock = security.is_jp_security_market(market);
288    let is_other = !is_option && !is_future && !is_index;
289
290    match market {
291        _ if is_crypto && qot_right_denies_realtime(rights.cc_qot_right) => Some(format!(
292            "Subscribe: crypto 行情权限不足,不能订阅 {code};\
293                 请检查 Crypto MarketCrypto quote permissions;{}。",
294            qot_permission_action(Some("Crypto"), code)
295        )),
296        21 if qot_right_denies_realtime(rights.sh_qot_right) => Some(format!(
297            "Subscribe: 沪股行情权限不足,不能订阅 SH market;{}。",
298            qot_permission_action(Some("SH"), code)
299        )),
300        22 if qot_right_denies_realtime(rights.sz_qot_right) => Some(format!(
301            "Subscribe: 深股行情权限不足,不能订阅 SZ market;{}。",
302            qot_permission_action(Some("SZ"), code)
303        )),
304        31 if security.is_sg_future() && qot_right_denies_realtime(rights.sg_future_qot_right) => {
305            Some(format!(
306                "Subscribe: SG future 行情权限不足,不能订阅 SG future;{}。",
307                qot_permission_action(Some("SG"), code)
308            ))
309        }
310        31 if is_sg_stock && qot_right_is_none(rights.sg_stock_qot_right) => Some(format!(
311            "Subscribe: SG stock 行情权限不足,不能订阅 {code};{}。",
312            qot_permission_action(Some("SG"), code)
313        )),
314        61 if is_my_stock && qot_right_is_none(rights.my_stock_qot_right) => Some(format!(
315            "Subscribe: MY stock 行情权限不足,不能订阅 {code};{}。",
316            qot_permission_action(Some("MY"), code)
317        )),
318        41 if security.is_jp_future() && qot_right_denies_realtime(rights.jp_future_qot_right) => {
319            Some(format!(
320                "Subscribe: JP future 行情权限不足,不能订阅 JP future;{}。",
321                qot_permission_action(Some("JP"), code)
322            ))
323        }
324        41 if is_jp_stock && qot_right_is_none(rights.jp_stock_qot_right) => Some(format!(
325            "Subscribe: JP stock / 日股行情权限不足,不能订阅 {code};{}",
326            jp_stock_quote_permission_hint().text_with_code()
327        )),
328        1 if is_other || is_index => {
329            if qot_right_denies_realtime(rights.hk_qot_right) {
330                Some(format!(
331                    "Subscribe: HK 行情权限不足,不能订阅 {code};{}。",
332                    qot_permission_action(Some("HK"), code)
333                ))
334            } else if wants_broker && rights.hk_qot_right == QOT_RIGHT_LEVEL1 {
335                Some(format!(
336                    "Subscribe: HK Level1 权限不支持 broker queue 订阅 ({code})。"
337                ))
338            } else {
339                None
340            }
341        }
342        1 if is_option => {
343            if qot_right_denies_realtime(rights.hk_option_qot_right) {
344                Some(format!(
345                    "Subscribe: HK option 行情权限不足,不能订阅 {code}。"
346                ))
347            } else if wants_orderbook && rights.hk_option_orderbook_depth == Some(0) {
348                Some(format!(
349                    "Subscribe: HK option order book depth 为 0,不能订阅摆盘 ({code})。"
350                ))
351            } else if wants_ticker && rights.hk_option_qot_right == QOT_RIGHT_LEVEL1 {
352                Some(format!(
353                    "Subscribe: HK option Level1 权限不支持 ticker 订阅 ({code});Ticker 需要 LV2。"
354                ))
355            } else {
356                None
357            }
358        }
359        1 if is_hk_future => {
360            if qot_right_denies_realtime(rights.hk_future_qot_right) {
361                Some(format!(
362                    "Subscribe: HK future 行情权限不足,不能订阅 {code}。"
363                ))
364            } else if wants_orderbook && rights.hk_future_orderbook_depth == Some(0) {
365                Some(format!(
366                    "Subscribe: HK future order book depth 为 0,不能订阅摆盘 ({code})。"
367                ))
368            } else if wants_ticker && rights.hk_future_qot_right == QOT_RIGHT_LEVEL1 {
369                Some(format!(
370                    "Subscribe: HK future Level1 权限不支持 ticker 订阅 ({code});Ticker 需要 LV2。"
371                ))
372            } else {
373                None
374            }
375        }
376        11 if is_option && qot_right_denies_realtime(rights.us_option_qot_right) => Some(format!(
377            "Subscribe: US option 行情权限不足,不能订阅 {code};{}。",
378            qot_permission_action(Some("US"), code)
379        )),
380        11 if is_future => {
381            let right = us_future_right_for_mkt(rights, security.mkt_id);
382            if right == QOT_RIGHT_UNKNOWN || qot_right_denies_realtime(right) {
383                Some(format!(
384                    "Subscribe: US future 行情权限不足或未知,不能订阅 {code};{}。",
385                    qot_permission_action(Some("US"), code)
386                ))
387            } else {
388                None
389            }
390        }
391        11 if is_index && qot_right_denies_realtime(rights.us_index_qot_right) => Some(format!(
392            "Subscribe: US index 行情权限不足,不能订阅 {code};{}。",
393            qot_permission_action(Some("US"), code)
394        )),
395        11 if security.mkt_id == 13 && qot_right_denies_realtime(rights.us_otc_qot_right) => {
396            Some(format!(
397                "Subscribe: US OTC 行情权限不足,不能订阅 {code};{}。",
398                qot_permission_action(Some("US"), code)
399            ))
400        }
401        11 if is_other && qot_right_denies_realtime(rights.us_qot_right) => Some(format!(
402            "Subscribe: US 行情权限不足,不能订阅 {code};{}。",
403            qot_permission_action(Some("US"), code)
404        )),
405        _ => None,
406    }
407}
408
409#[cfg(test)]
410mod tests;