Skip to main content

futu_backend/
crypto_exchange.rs

1//! CMD 18012 `kCmdGetLv2RelatedExchange` — 数字货币 LV2 摆盘关联交易所查询.
2//!
3//! 对齐 C++:
4//! - `NNProtoCenter/Quote/NNBiz_Qot_CryptoExchange.cpp:24-51` `GetLv2RelatedExchange()`:
5//!   构造 `FTMdfRelatedSvr::GetLv2RelatedExchangeReq`, 填 `stock_id` + 可选
6//!   `broker_id_list`, 走 `NN_TCPProtoCategory_Quote` 发 CMD 18012.
7//! - `NNProtoCenter/Quote/NNBiz_Qot_CryptoExchange.cpp:82-154` `OnReply_*`:
8//!   遍历 `pbRsp.exchange_list()`, 用 `UnpackExchangeInfo` 解每条 ExchangeInfo,
9//!   **若 `exchange_name == "PT"` 且 `GetCryptoPTOrderBookQotRight() !=
10//!   NN_QotRight_CC_Level1` 则过滤掉** (line 123-126).
11//! - `NNProtoCenter/Quote/INNBiz_Qot_CryptoExchange.cpp:15-18`:
12//!   FTGateway/APIServer 通过 `INNBiz_Qot_CryptoExchange::GetLv2RelatedExchange`
13//!   触发, response 写入 `INNData_Qot_CryptoExchange::SetLv2RelatedExchange`
14//!   并触发 `INNBiz_Qot_PushQot::ReSubCryptoOrderBook` 重新订阅.
15//!
16//! ## v1.4.110 codex QOT Phase 4 Slice 7
17//!
18//! 之前 Rust 数字货币 LV2 摆盘只走普通 OrderBook bit=3 路径, 拿不到 multi-exchange
19//! 合并后的 40 档摆盘. 本模块补 CMD18012 caller, 给 GetOrderBookHandler / push
20//! parser 提供 stock→exchange 列表 + PT 过滤, 是 broker-aware crypto LV2
21//! orderbook 完整 sub-system 的入口.
22//!
23//! ## Hardcoded / Assumption Ledger
24//!
25//! - CMD 18012 (`NN_ProtoCmd_Qot_Pull_GetLv2RelatedExchange`) 来源:
26//!   `/Users/leaf/ai-lab/o-src/FutuOpenD/Src/NNBase/NNBase_Define_ProtoCmd.h`
27//!   (在 18000 系列 quote-related cmd 范围). 已加入 `is_unencrypted_proto` 白名单
28//!   (`nn_codec.rs::is_unencrypted_proto`).
29//! - PT 过滤阈值 `cc_pt_orderbook_qot_right == NN_QotRight_CC_Level1 (==2)` 来源:
30//!   `NNBiz_Qot_CryptoExchange.cpp:123` `INNData_Qot_Right::GetCryptoPTOrderBookQotRight()
31//!   != NN_QotRight_CC_Level1`. Rust `cc_pt_orderbook_qot_right` 即对应字段;
32//!   constants 见 `futu-cache::qot_right` (`QOT_RIGHT_LEVEL1 = 2`).
33//! - `exchange_name == "PT"` 是 C++ `#define CRYPTO_PTExchange _TStr("PT")` 协议常量
34//!   (NNBiz_Qot_CryptoExchange.cpp:14). 不是服务端动态下发的列表名, Rust 复刻可接受.
35
36use futu_core::error::{FutuError, Result};
37use prost::Message;
38
39use crate::conn::BackendConn;
40use crate::proto_internal::ft_mdf_related_svr::{
41    ExchangeInfo, GetLv2RelatedExchangeReq, GetLv2RelatedExchangeRsp,
42};
43
44/// CMD 号 — 数字货币 LV2 关联交易所查询.
45pub const CMD_GET_LV2_RELATED_EXCHANGE: u16 = 18012;
46
47/// C++ `#define CRYPTO_PTExchange _TStr("PT")` (NNBiz_Qot_CryptoExchange.cpp:14).
48/// 当 `cc_pt_orderbook_qot_right != QOT_RIGHT_LEVEL1` 时, 跳过此 exchange_name 的 entry.
49pub const CRYPTO_PT_EXCHANGE_NAME: &str = "PT";
50
51/// Re-export `CryptoExchangeInfo` from `futu_cache` — single source of truth.
52///
53/// 对齐 C++ `Ndt_Qot_CryptoExchangeInfo` (NNBiz_Qot_CryptoExchange.cpp:55-58).
54/// 在 cache crate 定义 (而非这里) 是为了让 push_parser / GatewayBridge 等下游
55/// 不需依赖 futu-backend, 同时 backend 反向依赖 cache 已存在 (Cargo.toml).
56pub use futu_cache::crypto_exchange_cache::CryptoExchangeInfo;
57
58fn info_from_pb(pb: &ExchangeInfo) -> CryptoExchangeInfo {
59    CryptoExchangeInfo {
60        lv2_prob: pb.lv2_prob.unwrap_or(0),
61        exchange_name: pb.exchange_name.clone().unwrap_or_default(),
62        listed_exchange: pb.listed_exchange.clone().unwrap_or_default(),
63        is_pick: pb.is_pick.unwrap_or(false),
64    }
65}
66
67fn build_get_lv2_related_exchange_req(
68    stock_id: u64,
69    broker_id_list: &[u32],
70) -> GetLv2RelatedExchangeReq {
71    GetLv2RelatedExchangeReq {
72        stock_id: Some(stock_id),
73        // C++ `NNBiz_Qot_CryptoExchange.cpp:31-35` 只写 broker_id_list:
74        // `if (stKey.GetBrokerID() > 0) pbReq.add_broker_id_list(...)`.
75        // optional broker_id 字段在 proto 里存在, 但 C++ 此路径不写它.
76        broker_id: None,
77        broker_id_list: broker_id_list.to_vec(),
78    }
79}
80
81/// 发 CMD18012 GetLv2RelatedExchange, 解析 response 成 `Vec<CryptoExchangeInfo>`.
82///
83/// 入参:
84/// - `stock_id`: crypto 综合报价 stock_id (e.g. BTCUSDT 的内部 ID)
85/// - `broker_id_list`: 单 broker 时长度 1, multi-broker 时 N>=1; 空 list ==
86///   C++ "无 broker_id_list 字段" 等价.
87/// - `has_pt_orderbook_permission`: caller 传入 `cc_pt_orderbook_qot_right ==
88///   QOT_RIGHT_LEVEL1` 的 boolean. `false` 时过滤 `exchange_name == "PT"` 条目.
89///
90/// 失败模式:
91/// - 网络错误 / decode 错 → Err (caller log + fallback BBO-only)
92/// - `ret != 0` → Err with backend ret 字段
93///
94/// 行为对齐 C++ `NNBiz_Qot_CryptoExchange::OnReply_GetLv2RelatedExchange` 全 path,
95/// 包括 PT 过滤 (line 123-126).
96pub async fn get_lv2_related_exchange(
97    backend: &BackendConn,
98    stock_id: u64,
99    broker_id_list: &[u32],
100    has_pt_orderbook_permission: bool,
101) -> Result<Vec<CryptoExchangeInfo>> {
102    let req = build_get_lv2_related_exchange_req(stock_id, broker_id_list);
103    let body = req.encode_to_vec();
104    tracing::debug!(
105        body_len = body.len(),
106        stock_id,
107        broker_count = broker_id_list.len(),
108        has_pt = has_pt_orderbook_permission,
109        "sending CMD18012 GetLv2RelatedExchangeReq (crypto LV2 related exchange query)"
110    );
111
112    let resp = backend.request(CMD_GET_LV2_RELATED_EXCHANGE, body).await?;
113
114    let rsp = GetLv2RelatedExchangeRsp::decode(resp.body.as_ref())
115        .map_err(|e| FutuError::Codec(format!("CMD18012 decode: {e}")))?;
116
117    let ret = rsp.ret.unwrap_or(-1);
118    if ret != 0 {
119        return Err(FutuError::ServerError {
120            ret_type: ret,
121            msg: format!("CMD18012 ret={ret} stock_id={stock_id}"),
122        });
123    }
124
125    let mut out: Vec<CryptoExchangeInfo> = Vec::with_capacity(rsp.exchange_list.len());
126    for pb in &rsp.exchange_list {
127        let info = info_from_pb(pb);
128        // C++ NNBiz_Qot_CryptoExchange.cpp:123-126: 无 PT 权限 → skip PT exchange.
129        if info.exchange_name == CRYPTO_PT_EXCHANGE_NAME && !has_pt_orderbook_permission {
130            tracing::debug!(
131                stock_id,
132                lv2_prob = info.lv2_prob,
133                "CMD18012: skipping PT exchange (cc_pt_orderbook_qot_right != Level1)"
134            );
135            continue;
136        }
137        out.push(info);
138    }
139
140    tracing::info!(
141        stock_id,
142        exchange_count = out.len(),
143        broker_count = broker_id_list.len(),
144        "v1.4.110 codex Phase 4 Slice 7: CMD18012 GetLv2RelatedExchangeRsp parsed"
145    );
146    Ok(out)
147}
148
149#[cfg(test)]
150mod tests;