Skip to main content

futu_gateway_core/bridge/
qot_right_refresh.rs

1//! QOT entitlement refresh.
2//!
3//! Mechanical split from `push_parser.rs`; exported types are re-exported by
4//! `push_parser` to preserve existing integration-test and handler paths.
5
6use std::sync::Arc;
7
8use futu_backend::conn::BackendConn;
9use futu_cache::qot_right::{QotRightBackendExtras, QotRightBackendUpdate, QotRightCache};
10
11use super::{CacheBundle, GatewayBridge};
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum QotRightRequestMode {
15    HighestPriority,
16    WithPushedNotify,
17}
18
19#[derive(Debug, Clone)]
20pub struct QotRightRefreshErr {
21    pub stage: &'static str,
22    pub detail: String,
23}
24
25impl std::fmt::Display for QotRightRefreshErr {
26    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
27        write!(
28            f,
29            "qot_right refresh failed at {}: {}",
30            self.stage, self.detail
31        )
32    }
33}
34
35impl std::error::Error for QotRightRefreshErr {}
36
37pub use futu_cache::qot_right::QotRightRefreshReport;
38
39pub(super) fn spawn_startup_refresh(
40    backend: Arc<BackendConn>,
41    caches: CacheBundle,
42    epoch_uid: Option<u64>,
43) -> tokio::task::JoinHandle<()> {
44    caches.qot_right_cache.mark_pending();
45    // F1: 首次 login 完成 → epoch 0→1 (后续 reconnect 继续递增).
46    caches.qot_right_cache.advance_login_epoch(1, epoch_uid);
47    tokio::spawn(async move {
48        if let Err(e) = GatewayBridge::request_qot_right_v2(
49            &backend,
50            &caches.qot_right_cache,
51            QotRightRequestMode::HighestPriority,
52        )
53        .await
54        {
55            tracing::warn!(error = %e, "startup qot_right refresh failed");
56        }
57    })
58}
59
60fn qot_right_quote_change_notify(
61    qot_right_cache: &QotRightCache,
62    mode: QotRightRequestMode,
63) -> Option<futu_backend::proto_internal::ftcmd6651_qta_auth_chg::QuoteChangeNotify> {
64    match mode {
65        QotRightRequestMode::HighestPriority => None,
66        QotRightRequestMode::WithPushedNotify => {
67            qot_right_cache.pushed_quote_change_notify().map(|pn| {
68                use futu_backend::proto_internal::ftcmd6651_qta_auth_chg as chg;
69                chg::QuoteChangeNotify {
70                    quote_change_list: pn
71                        .change_items
72                        .iter()
73                        .map(|&(qt, before, after)| chg::QuoteChangeItem {
74                            quote_type: Some(qt),
75                            quote_before_change: Some(before),
76                            quote_after_change: Some(after),
77                            clt_type: None,
78                            ip_area: None,
79                        })
80                        .collect(),
81                }
82            })
83        }
84    }
85}
86
87fn encode_qot_right_request(
88    quote_change_notify: Option<
89        futu_backend::proto_internal::ftcmd6651_qta_auth_chg::QuoteChangeNotify,
90    >,
91) -> Vec<u8> {
92    use futu_backend::proto_internal::qta_auth;
93
94    let req = qta_auth::QtaAuth6024Req {
95        req_hk_auth: None,
96        req_us_auth: None,
97        req_cn_auth: None,
98        // v1.4.44: proto 补齐后新增字段(device_type / quote_change_notify),
99        // 沿用 C++ "空请求"语义;行情互踢重刷时才由 mode 注入 notify。
100        device_type: None,
101        quote_change_notify,
102    };
103    prost::Message::encode_to_vec(&req)
104}
105
106fn decode_qot_right_response(
107    body_bytes: &[u8],
108) -> Result<futu_backend::proto_internal::qta_auth::QtaAuth6024Rsp, String> {
109    crate::handlers_shared::decode_srpc_or_direct(body_bytes, is_valid_qta_auth_6024_rsp)
110        .map_err(|e| format!("decode failed (body_len={}): {}", body_bytes.len(), e))
111}
112
113fn is_valid_qta_auth_6024_rsp(r: &futu_backend::proto_internal::qta_auth::QtaAuth6024Rsp) -> bool {
114    r.hk_qut_got_auth.is_some()
115        || r.us_qut_got_auth.is_some()
116        || r.cn_qut_got_auth.is_some()
117        || r.shanghai_qut_auth.is_some()
118        || r.shenzhen_qut_auth.is_some()
119        || r.hk_option_auth.is_some()
120        || r.hk_future_auth.is_some()
121        || r.hk_option_orderbook_depth.is_some()
122        || r.hk_future_orderbook_depth.is_some()
123        || r.us_option_flag.is_some()
124        || r.us_future_cme_cboe_auth.is_some()
125        || r.us_future_auth.is_some()
126        || r.us_lv2_arca_flag.is_some()
127        || r.us_lv2_nyse_flag.is_some()
128        || r.us_lv2_nasdaq_tv_flag.is_some()
129        || r.us_lv2_edg_flag.is_some()
130        || r.us_lv2_bzx_flag.is_some()
131        || r.us_index_dow_jones_flag.is_some()
132        || r.us_index_nasdaq_flag.is_some()
133        || r.us_index_standard_poor_flag.is_some()
134        || r.us_otc_deal_data_auth.is_some()
135        || r.us_otc_order_book_auth.is_some()
136        || r.sgx_future_auth.is_some()
137        || r.sgx_stock_auth.is_some()
138        || r.jp_future_auth.is_some()
139        || r.jp_stock_tse_auth.is_some()
140        || r.myx_auth.is_some()
141        || r.digital_currency_auth.is_some()
142        || r.digital_pt_orderbook_auth.is_some()
143        || r.open_api_auth.is_some()
144}
145
146fn qot_right_backend_extras_from_rsp(
147    r: &futu_backend::proto_internal::qta_auth::QtaAuth6024Rsp,
148) -> QotRightBackendExtras {
149    QotRightBackendExtras {
150        us_lv2_flags: Some((
151            r.us_lv2_arca_flag,
152            r.us_lv2_nyse_flag,
153            r.us_lv2_nasdaq_tv_flag,
154            r.us_lv2_edg_flag,
155            r.us_lv2_bzx_flag,
156        )),
157        us_index_flags: Some((
158            r.us_index_dow_jones_flag,
159            r.us_index_nasdaq_flag,
160            r.us_index_standard_poor_flag,
161        )),
162        us_otc_auths: Some((r.us_otc_deal_data_auth, r.us_otc_order_book_auth)),
163    }
164}
165
166fn qot_right_backend_update_from_rsp(
167    r: &futu_backend::proto_internal::qta_auth::QtaAuth6024Rsp,
168) -> QotRightBackendUpdate {
169    let (sub_limit, kl_limit) = r
170        .open_api_auth
171        .as_ref()
172        .map(|api_auth| (api_auth.sub_limit, api_auth.history_k_line_limit))
173        .unwrap_or((None, None));
174    // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:396-478.
175    // C++ checks each open_api_*_auth subfield before updating that market.
176    let us_future_detail = r.us_future_auth.as_ref().and_then(|f| {
177        let detail = (
178            f.open_api_cme_auth,
179            f.open_api_cbot_auth,
180            f.open_api_nymex_auth,
181            f.open_api_comex_auth,
182        );
183        if detail.0.is_none() && detail.1.is_none() && detail.2.is_none() && detail.3.is_none() {
184            None
185        } else {
186            Some(detail)
187        }
188    });
189
190    // Ref: FutuOpenD/Src/NNProtoCenter/Quote/NNBiz_Qot_Right.cpp:649-754,798-882,907-919.
191    // These parsers run on every full CMD6024 rights reply and initialize the
192    // new value to None before reading optional fields, so a missing field
193    // clears stale SG/MY/JP/crypto rights instead of preserving the previous cache.
194    let clear_missing_sg_future = Some(r.sgx_future_auth.unwrap_or(0));
195    let clear_missing_jp_future = Some(r.jp_future_auth.unwrap_or(0));
196    let clear_missing_sg_stock = Some(r.sgx_stock_auth.unwrap_or(0));
197    let clear_missing_my_stock = Some(r.myx_auth.as_ref().and_then(|m| m.myx_auth).unwrap_or(0));
198    let clear_missing_jp_stock = Some(r.jp_stock_tse_auth.unwrap_or(0));
199    let clear_missing_crypto = Some(r.digital_currency_auth.unwrap_or(0));
200    let clear_missing_crypto_pt_orderbook = Some(r.digital_pt_orderbook_auth.unwrap_or(0));
201
202    QotRightBackendUpdate {
203        hk_got: r.hk_qut_got_auth,
204        us_got: r.us_qut_got_auth,
205        cn_got: r.cn_qut_got_auth,
206        sh_auth: r.shanghai_qut_auth,
207        sz_auth: r.shenzhen_qut_auth,
208        hk_option: r.hk_option_auth,
209        hk_future: r.hk_future_auth,
210        us_option: r.us_option_flag,
211        us_future_cme_cboe: r.us_future_cme_cboe_auth,
212        us_future_detail,
213        sg_future: clear_missing_sg_future,
214        jp_future: clear_missing_jp_future,
215        sg_stock: clear_missing_sg_stock,
216        my_stock: clear_missing_my_stock,
217        jp_stock: clear_missing_jp_stock,
218        digital_currency_auth: clear_missing_crypto,
219        digital_pt_orderbook_auth: clear_missing_crypto_pt_orderbook,
220        sub_limit,
221        kl_limit,
222        extras: qot_right_backend_extras_from_rsp(r),
223    }
224}
225
226fn apply_qot_right_backend_response(
227    qot_right_cache: &QotRightCache,
228    rsp: &futu_backend::proto_internal::qta_auth::QtaAuth6024Rsp,
229) {
230    qot_right_cache.update_from_backend(qot_right_backend_update_from_rsp(rsp));
231    qot_right_cache
232        .set_orderbook_depths(rsp.hk_option_orderbook_depth, rsp.hk_future_orderbook_depth);
233}
234
235fn collect_decoded_fields(
236    r: &futu_backend::proto_internal::qta_auth::QtaAuth6024Rsp,
237) -> Vec<&'static str> {
238    let mut v: Vec<&'static str> = Vec::new();
239    if r.hk_qut_got_auth.is_some() {
240        v.push("hk_qut_got_auth");
241    }
242    if r.us_qut_got_auth.is_some() {
243        v.push("us_qut_got_auth");
244    }
245    if r.cn_qut_got_auth.is_some() {
246        v.push("cn_qut_got_auth");
247    }
248    if r.shanghai_qut_auth.is_some() {
249        v.push("shanghai_qut_auth");
250    }
251    if r.shenzhen_qut_auth.is_some() {
252        v.push("shenzhen_qut_auth");
253    }
254    if r.hk_option_auth.is_some() {
255        v.push("hk_option_auth");
256    }
257    if r.hk_future_auth.is_some() {
258        v.push("hk_future_auth");
259    }
260    if r.hk_option_orderbook_depth.is_some() {
261        v.push("hk_option_orderbook_depth");
262    }
263    if r.hk_future_orderbook_depth.is_some() {
264        v.push("hk_future_orderbook_depth");
265    }
266    if r.us_option_flag.is_some() {
267        v.push("us_option_flag");
268    }
269    if r.us_future_cme_cboe_auth.is_some() {
270        v.push("us_future_cme_cboe_auth");
271    }
272    if r.us_future_auth.is_some() {
273        v.push("us_future_auth");
274    }
275    if r.us_lv2_arca_flag.is_some() {
276        v.push("us_lv2_arca_flag");
277    }
278    if r.us_lv2_nyse_flag.is_some() {
279        v.push("us_lv2_nyse_flag");
280    }
281    if r.us_lv2_nasdaq_tv_flag.is_some() {
282        v.push("us_lv2_nasdaq_tv_flag");
283    }
284    if r.us_lv2_edg_flag.is_some() {
285        v.push("us_lv2_edg_flag");
286    }
287    if r.us_lv2_bzx_flag.is_some() {
288        v.push("us_lv2_bzx_flag");
289    }
290    if r.us_index_dow_jones_flag.is_some() {
291        v.push("us_index_dow_jones_flag");
292    }
293    if r.us_index_nasdaq_flag.is_some() {
294        v.push("us_index_nasdaq_flag");
295    }
296    if r.us_index_standard_poor_flag.is_some() {
297        v.push("us_index_standard_poor_flag");
298    }
299    if r.us_otc_deal_data_auth.is_some() {
300        v.push("us_otc_deal_data_auth");
301    }
302    if r.us_otc_order_book_auth.is_some() {
303        v.push("us_otc_order_book_auth");
304    }
305    if r.sgx_future_auth.is_some() {
306        v.push("sgx_future_auth");
307    }
308    if r.sgx_stock_auth.is_some() {
309        v.push("sgx_stock_auth");
310    }
311    if r.jp_future_auth.is_some() {
312        v.push("jp_future_auth");
313    }
314    if r.jp_stock_tse_auth.is_some() {
315        v.push("jp_stock_tse_auth");
316    }
317    if r.myx_auth.is_some() {
318        v.push("myx_auth");
319    }
320    if r.digital_currency_auth.is_some() {
321        v.push("digital_currency_auth");
322    }
323    if r.digital_pt_orderbook_auth.is_some() {
324        v.push("digital_pt_orderbook_auth");
325    }
326    if r.open_api_auth.is_some() {
327        v.push("open_api_auth");
328    }
329    v
330}
331
332impl GatewayBridge {
333    /// 请求行情权限 (CMD 6024)
334    /// 对齐 C++ RequestAPIQuota / RequestMkrQotRightWithPushedQotRight
335    ///
336    /// **v1.4.106 codex 1110 F7 [P2]**:升级为 `pub` 以供 RemoteCmd / TestCmd
337    /// `request_highest_quote_right` 调用真实 backend 路径(不再 silent log no-op)。
338    pub async fn request_qot_right(
339        backend: &futu_backend::conn::BackendConn,
340        qot_right_cache: &QotRightCache,
341    ) {
342        tracing::info!("CMD6024: requesting qot right...");
343
344        let body = encode_qot_right_request(None);
345
346        // C++ CMD ID = 6032 (NN_ProtoCmd_Qot_Request_Right)
347        // proto 文件名虽然叫 FTCmdQtaAuth6024,但实际 CMD ID 是 6032
348        match backend.request(6032, body).await {
349            Ok(frame) => {
350                let body_bytes = frame.body.as_ref();
351
352                // CMD 6032 响应是裸 protobuf (已验证不需要 SRPC 解包)
353                // v1.4.106 codex 1110 F3 [P2]: decode_srpc_or_direct 返 Result。
354                // 此处是 background self-heal task,decode 失败时 warn + skip update(保留旧 cache 状态比 reset 到 default 更安全)。
355                let rsp = match decode_qot_right_response(body_bytes) {
356                    Ok(r) => r,
357                    Err(e) => {
358                        tracing::warn!(
359                            error = %e,
360                            body_len = body_bytes.len(),
361                            "CMD6024 SRPC decode failed; preserving existing qot_right cache"
362                        );
363                        return;
364                    }
365                };
366
367                let open_api_limits = rsp
368                    .open_api_auth
369                    .as_ref()
370                    .map(|api_auth| (api_auth.sub_limit, api_auth.history_k_line_limit))
371                    .unwrap_or((None, None));
372                apply_qot_right_backend_response(qot_right_cache, &rsp);
373
374                tracing::info!(
375                    hk_got = rsp.hk_qut_got_auth.unwrap_or(0),
376                    us_got = rsp.us_qut_got_auth.unwrap_or(0),
377                    cn_got = rsp.cn_qut_got_auth.unwrap_or(0),
378                    raw_us_lv2_arca = ?rsp.us_lv2_arca_flag,
379                    raw_us_lv2_nyse = ?rsp.us_lv2_nyse_flag,
380                    raw_us_lv2_nasdaq_tv = ?rsp.us_lv2_nasdaq_tv_flag,
381                    raw_us_lv2_edg = ?rsp.us_lv2_edg_flag,
382                    raw_us_lv2_bzx = ?rsp.us_lv2_bzx_flag,
383                    raw_us_index_dow_jones = ?rsp.us_index_dow_jones_flag,
384                    raw_us_index_nasdaq = ?rsp.us_index_nasdaq_flag,
385                    raw_us_index_standard_poor = ?rsp.us_index_standard_poor_flag,
386                    raw_us_otc_deal_data = ?rsp.us_otc_deal_data_auth,
387                    raw_us_otc_order_book = ?rsp.us_otc_order_book_auth,
388                    raw_us_future_cme_cboe = ?rsp.us_future_cme_cboe_auth,
389                    raw_sgx_stock_auth = ?rsp.sgx_stock_auth,
390                    raw_myx_auth = ?rsp.myx_auth.as_ref().and_then(|m| m.myx_auth),
391                    raw_jp_stock_tse_auth = ?rsp.jp_stock_tse_auth,
392                    raw_digital_currency_auth = ?rsp.digital_currency_auth,
393                    raw_digital_pt_orderbook_auth = ?rsp.digital_pt_orderbook_auth,
394                    hk_future = rsp.hk_future_auth.unwrap_or(0),
395                    hk_option = rsp.hk_option_auth.unwrap_or(0),
396                    sub_limit = open_api_limits.0.unwrap_or(0),
397                    kl_limit = open_api_limits.1.unwrap_or(0),
398                    "CMD6024 qot right updated"
399                );
400            }
401            Err(e) => {
402                tracing::warn!(error = %e, "CMD6024 request failed (using defaults)");
403            }
404        }
405    }
406
407    pub async fn request_qot_right_v2(
408        backend: &futu_backend::conn::BackendConn,
409        qot_right_cache: &QotRightCache,
410        mode: QotRightRequestMode,
411    ) -> Result<QotRightRefreshReport, QotRightRefreshErr> {
412        qot_right_cache.mark_pending();
413
414        let quote_change_notify = qot_right_quote_change_notify(qot_right_cache, mode);
415
416        let mode_str = match mode {
417            QotRightRequestMode::HighestPriority => "HighestPriority",
418            QotRightRequestMode::WithPushedNotify => "WithPushedNotify",
419        };
420        tracing::info!(
421            mode = mode_str,
422            has_pushed_notify = quote_change_notify.is_some(),
423            "CMD6032: requesting qot right (v2)..."
424        );
425
426        let body = encode_qot_right_request(quote_change_notify);
427
428        let frame = match backend.request(6032, body).await {
429            Ok(f) => f,
430            Err(e) => {
431                let err_msg = format!("backend request failed: {e}");
432                qot_right_cache.mark_failed(err_msg.clone());
433                return Err(QotRightRefreshErr {
434                    stage: "request",
435                    detail: err_msg,
436                });
437            }
438        };
439
440        let body_bytes = frame.body.as_ref();
441
442        let rsp = match decode_qot_right_response(body_bytes) {
443            Ok(r) => r,
444            Err(err_msg) => {
445                qot_right_cache.mark_failed(err_msg.clone());
446                return Err(QotRightRefreshErr {
447                    stage: "decode",
448                    detail: err_msg,
449                });
450            }
451        };
452
453        let raw_hk_future_auth = rsp.hk_future_auth;
454        let raw_hk_option_auth = rsp.hk_option_auth;
455        let raw_hk_future_orderbook_depth = rsp.hk_future_orderbook_depth;
456        let raw_hk_option_orderbook_depth = rsp.hk_option_orderbook_depth;
457        let raw_us_lv2_arca = rsp.us_lv2_arca_flag;
458        let raw_us_lv2_nyse = rsp.us_lv2_nyse_flag;
459        let raw_us_lv2_nasdaq_tv = rsp.us_lv2_nasdaq_tv_flag;
460        let raw_us_lv2_edg = rsp.us_lv2_edg_flag;
461        let raw_us_lv2_bzx = rsp.us_lv2_bzx_flag;
462        let raw_us_index_dow_jones = rsp.us_index_dow_jones_flag;
463        let raw_us_index_nasdaq = rsp.us_index_nasdaq_flag;
464        let raw_us_index_standard_poor = rsp.us_index_standard_poor_flag;
465        let raw_us_otc_deal_data = rsp.us_otc_deal_data_auth;
466        let raw_us_otc_order_book = rsp.us_otc_order_book_auth;
467        let raw_us_future_cme_cboe = rsp.us_future_cme_cboe_auth;
468        let raw_sgx_stock_auth = rsp.sgx_stock_auth;
469        let raw_myx_auth = rsp.myx_auth.as_ref().and_then(|m| m.myx_auth);
470        let raw_jp_stock_tse_auth = rsp.jp_stock_tse_auth;
471        let raw_digital_currency_auth = rsp.digital_currency_auth;
472        let raw_digital_pt_orderbook_auth = rsp.digital_pt_orderbook_auth;
473        apply_qot_right_backend_response(qot_right_cache, &rsp);
474
475        let decoded_fields = collect_decoded_fields(&rsp);
476        let meta = qot_right_cache.meta_snapshot();
477        let data = qot_right_cache.get();
478        tracing::info!(
479            mode = mode_str,
480            decoded_fields = ?decoded_fields,
481            raw_hk_future_auth = ?raw_hk_future_auth,
482            raw_hk_option_auth = ?raw_hk_option_auth,
483            raw_hk_future_orderbook_depth = ?raw_hk_future_orderbook_depth,
484            raw_hk_option_orderbook_depth = ?raw_hk_option_orderbook_depth,
485            raw_us_lv2_arca = ?raw_us_lv2_arca,
486            raw_us_lv2_nyse = ?raw_us_lv2_nyse,
487            raw_us_lv2_nasdaq_tv = ?raw_us_lv2_nasdaq_tv,
488            raw_us_lv2_edg = ?raw_us_lv2_edg,
489            raw_us_lv2_bzx = ?raw_us_lv2_bzx,
490            raw_us_index_dow_jones = ?raw_us_index_dow_jones,
491            raw_us_index_nasdaq = ?raw_us_index_nasdaq,
492            raw_us_index_standard_poor = ?raw_us_index_standard_poor,
493            raw_us_otc_deal_data = ?raw_us_otc_deal_data,
494            raw_us_otc_order_book = ?raw_us_otc_order_book,
495            raw_us_future_cme_cboe = ?raw_us_future_cme_cboe,
496            raw_sgx_stock_auth = ?raw_sgx_stock_auth,
497            raw_myx_auth = ?raw_myx_auth,
498            raw_jp_stock_tse_auth = ?raw_jp_stock_tse_auth,
499            raw_digital_currency_auth = ?raw_digital_currency_auth,
500            raw_digital_pt_orderbook_auth = ?raw_digital_pt_orderbook_auth,
501            hk_future_qot_right = data.hk_future_qot_right,
502            hk_option_qot_right = data.hk_option_qot_right,
503            us_qot_right = data.us_qot_right,
504            us_index_qot_right = data.us_index_qot_right,
505            us_otc_qot_right = data.us_otc_qot_right,
506            us_cboe_future_qot_right = data.us_cboe_future_qot_right,
507            sg_stock_qot_right = data.sg_stock_qot_right,
508            my_stock_qot_right = data.my_stock_qot_right,
509            jp_stock_qot_right = data.jp_stock_qot_right,
510            cc_qot_right = data.cc_qot_right,
511            cc_pt_orderbook_qot_right = data.cc_pt_orderbook_qot_right,
512            hk_future_orderbook_depth = ?data.hk_future_orderbook_depth,
513            hk_option_orderbook_depth = ?data.hk_option_orderbook_depth,
514            freshness = ?meta.freshness,
515            "CMD6032: qot right updated (API-facing cache values)"
516        );
517
518        Ok(QotRightRefreshReport {
519            changed: true,
520            fresh_at_ms: meta.last_refresh_at_ms,
521            decoded_fields,
522            freshness_after: meta.freshness,
523            login_epoch: meta.login_epoch,
524        })
525    }
526}