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//!   `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 bytes::Bytes;
37use futu_command_spec::CMD_QOT_PULL_LV2_RELATED_EXCHANGE;
38use futu_core::error::{FutuError, Result};
39use futu_domain_qot_orderbook::crypto_exchange::ensure_crypto_exchange_backend_success;
40use prost::Message;
41
42use crate::conn::BackendConn;
43use crate::proto_internal::ft_mdf_related_svr::{
44    ExchangeInfo, GetLv2RelatedExchangeReq, GetLv2RelatedExchangeRsp,
45};
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
58#[derive(Debug, Clone, PartialEq)]
59pub struct CryptoExchangeDiscovery {
60    pub raw_exchange_count: usize,
61    pub filtered_exchanges: Vec<CryptoExchangeInfo>,
62}
63
64fn info_from_pb(pb: &ExchangeInfo) -> CryptoExchangeInfo {
65    CryptoExchangeInfo {
66        lv2_prob: pb.lv2_prob.unwrap_or(0),
67        exchange_name: pb.exchange_name.clone().unwrap_or_default(),
68        listed_exchange: pb.listed_exchange.clone().unwrap_or_default(),
69        is_pick: pb.is_pick.unwrap_or(false),
70    }
71}
72
73fn discovery_from_rsp(
74    rsp: &GetLv2RelatedExchangeRsp,
75    has_pt_orderbook_permission: bool,
76) -> CryptoExchangeDiscovery {
77    let raw_exchange_count = rsp.exchange_list.len();
78    let filtered_exchanges = rsp
79        .exchange_list
80        .iter()
81        .filter_map(|pb| {
82            let info = info_from_pb(pb);
83            // C++ NNBiz_Qot_CryptoExchange.cpp:123-126: 无 PT 权限 → skip PT exchange.
84            if info.exchange_name == CRYPTO_PT_EXCHANGE_NAME && !has_pt_orderbook_permission {
85                return None;
86            }
87            Some(info)
88        })
89        .collect();
90    CryptoExchangeDiscovery {
91        raw_exchange_count,
92        filtered_exchanges,
93    }
94}
95
96fn build_get_lv2_related_exchange_req(
97    stock_id: u64,
98    broker_id_list: &[u32],
99) -> GetLv2RelatedExchangeReq {
100    GetLv2RelatedExchangeReq {
101        stock_id: Some(stock_id),
102        // C++ `NNBiz_Qot_CryptoExchange.cpp:31-35` 只写 broker_id_list:
103        // `if (stKey.GetBrokerID() > 0) pbReq.add_broker_id_list(...)`.
104        // optional broker_id 字段在 proto 里存在, 但 C++ 此路径不写它.
105        broker_id: None,
106        broker_id_list: broker_id_list.to_vec(),
107    }
108}
109
110/// 发 CMD18012 GetLv2RelatedExchange, 解析 response 成 `Vec<CryptoExchangeInfo>`.
111///
112/// 入参:
113/// - `stock_id`: crypto 综合报价 stock_id (e.g. BTCUSDT 的内部 ID)
114/// - `broker_id_list`: 单 broker 时长度 1, multi-broker 时 N>=1; 空 list ==
115///   C++ "无 broker_id_list 字段" 等价.
116/// - `has_pt_orderbook_permission`: caller 传入 `cc_pt_orderbook_qot_right ==
117///   QOT_RIGHT_LEVEL1` 的 boolean. `false` 时过滤 `exchange_name == "PT"` 条目.
118///
119/// 失败模式:
120/// - 网络错误 / decode 错 → Err (caller log + fallback BBO-only)
121/// - `ret != 0` → Err with backend ret 字段
122///
123/// 行为对齐 C++ `NNBiz_Qot_CryptoExchange::OnReply_GetLv2RelatedExchange` 全 path,
124/// 包括 PT 过滤 (line 123-126).
125pub async fn get_lv2_related_exchange(
126    backend: &BackendConn,
127    stock_id: u64,
128    broker_id_list: &[u32],
129    has_pt_orderbook_permission: bool,
130) -> Result<CryptoExchangeDiscovery> {
131    let req = build_get_lv2_related_exchange_req(stock_id, broker_id_list);
132    let body = req.encode_to_vec();
133    tracing::debug!(
134        body_len = body.len(),
135        stock_id,
136        broker_count = broker_id_list.len(),
137        has_pt = has_pt_orderbook_permission,
138        "sending CMD18012 GetLv2RelatedExchangeReq (crypto LV2 related exchange query)"
139    );
140
141    let resp = crate::command_runtime::execute_qot_plaintext(
142        backend,
143        CMD_QOT_PULL_LV2_RELATED_EXCHANGE,
144        Bytes::from(body),
145        [0_u8; 10],
146    )
147    .await?;
148
149    let rsp = GetLv2RelatedExchangeRsp::decode(resp.body.as_ref())
150        .map_err(|e| FutuError::Codec(format!("CMD18012 decode: {e}")))?;
151
152    if let Err(err) = ensure_crypto_exchange_backend_success(rsp.ret) {
153        let ret = err.result_code();
154        return Err(FutuError::ServerError {
155            ret_type: ret,
156            msg: format!("CMD18012 ret={ret} stock_id={stock_id}"),
157        });
158    }
159
160    let discovery = discovery_from_rsp(&rsp, has_pt_orderbook_permission);
161
162    tracing::info!(
163        stock_id,
164        raw_exchange_count = discovery.raw_exchange_count,
165        exchange_count = discovery.filtered_exchanges.len(),
166        broker_count = broker_id_list.len(),
167        "v1.4.110 audit Phase 4 Slice 7: CMD18012 GetLv2RelatedExchangeRsp parsed"
168    );
169    Ok(discovery)
170}
171
172#[cfg(test)]
173mod tests;