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