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;