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;