Skip to main content

futu_backend/
overnight_whitelist.rs

1//! CMD20874 `QryNightWhitelistClient` — dynamic overnight white list.
2//!
3//! C++ source of truth:
4//! - `NNProtoCenter/Trade/NNProto_Trd_OvernightWhiteList.cpp`
5//! - `NNDataCenter/Trade/NNData_Trd_OvernightWhiteList.cpp`
6//! - `FTGateway/GTWCmdAndPushReply.cpp::Timer_PullOvernightWhiteList`
7//!
8//! OpenD startup loads cached `overnight_white_list.dat`, then periodically
9//! sends CMD20874 per broker. `AdjustTradeSession` uses the in-memory
10//! `IsStockInWhiteList(enBroker, secInfo.nSecID)` result: `Session_ALL` stays
11//! `ALL_DAY` only for broker+stock IDs in this list; otherwise it downgrades
12//! to `ETH`.
13//!
14//! ## Hardcoded / Assumption Ledger
15//!
16//! - CMD20874 is `NN_ProtoCmd_Trd_OvernightWhiteList`, from C++
17//!   `NNBase_Define_ProtoCmd.h`.
18//! - Valid brokers `{1001,1007,1008,1009}` are copied from C++
19//!   `NNData_Trd_OvernightWhiteList.cpp::IsValidBrokerID` (Futu HK/US/SG/AU).
20//! - `stock_id_list` is gzip-compressed bytes containing native `uint64_t`
21//!   stock IDs. Official OpenD targets little-endian macOS/Windows in our test
22//!   matrix, so Rust decodes explicit little-endian `u64` chunks rather than
23//!   host-native order.
24//! - Current `BackendConn::request` does not expose C++ `nExtErrCode == -1009`
25//!   cache-match replies. A decode/fetch error leaves the last cache intact;
26//!   normal response updates remain dynamic.
27
28use std::collections::HashSet;
29use std::io::Read;
30use std::sync::Arc;
31
32use flate2::read::GzDecoder;
33use futu_core::error::{FutuError, Result};
34use prost::Message;
35
36use crate::conn::BackendConn;
37use crate::proto_internal::securities_switch::{
38    QryNightWhitelistClientReq, QryNightWhitelistClientRsp,
39};
40
41/// CMD20874 `NN_ProtoCmd_Trd_OvernightWhiteList`.
42pub const CMD_TRD_OVERNIGHT_WHITE_LIST: u16 = 20874;
43
44/// C++ fallback interval in `OMEvProc_OvernightWhiteList` when backend does
45/// not provide a usable `update_interval`.
46pub const DEFAULT_UPDATE_INTERVAL_SECS: u64 = 3600;
47
48/// Minimum refresh interval guard for malformed backend values.
49pub const MIN_UPDATE_INTERVAL_SECS: u64 = 60;
50
51const VALID_OVERNIGHT_WHITELIST_BROKERS: &[u32] = &[1001, 1007, 1008, 1009];
52
53#[derive(Debug, Clone)]
54struct OvernightWhitelistEntry {
55    hash: Option<String>,
56    stock_ids: Arc<HashSet<u64>>,
57    update_interval_secs: u64,
58}
59
60/// Shared per-broker overnight whitelist cache.
61#[derive(Debug, Clone, Default)]
62pub struct OvernightWhitelistCache {
63    inner: Arc<dashmap::DashMap<u32, OvernightWhitelistEntry>>,
64}
65
66/// Decoded CMD20874 response.
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub struct OvernightWhitelistSnapshot {
69    pub broker_id: u32,
70    pub hash: Option<String>,
71    pub stock_ids: Option<Vec<u64>>,
72    pub update_interval_secs: Option<u64>,
73}
74
75impl OvernightWhitelistCache {
76    pub fn new() -> Self {
77        Self::default()
78    }
79
80    pub fn is_stock_in_whitelist(&self, broker_id: u32, stock_id: u64) -> bool {
81        self.inner
82            .get(&broker_id)
83            .is_some_and(|entry| entry.stock_ids.contains(&stock_id))
84    }
85
86    pub fn broker_hash(&self, broker_id: u32) -> Option<String> {
87        self.inner
88            .get(&broker_id)
89            .and_then(|entry| entry.hash.clone())
90    }
91
92    pub fn update_interval_secs(&self, broker_id: u32) -> Option<u64> {
93        self.inner
94            .get(&broker_id)
95            .map(|entry| entry.update_interval_secs)
96    }
97
98    pub fn set_whitelist(
99        &self,
100        broker_id: u32,
101        hash: Option<String>,
102        stock_ids: impl IntoIterator<Item = u64>,
103        update_interval_secs: Option<u64>,
104    ) {
105        let stock_ids = stock_ids.into_iter().collect::<HashSet<_>>();
106        let interval = normalized_update_interval(update_interval_secs);
107        self.inner.insert(
108            broker_id,
109            OvernightWhitelistEntry {
110                hash,
111                stock_ids: Arc::new(stock_ids),
112                update_interval_secs: interval,
113            },
114        );
115    }
116}
117
118pub fn is_valid_overnight_whitelist_broker(broker_id: u32) -> bool {
119    VALID_OVERNIGHT_WHITELIST_BROKERS.contains(&broker_id)
120}
121
122pub fn build_overnight_whitelist_request(
123    cache: &OvernightWhitelistCache,
124    broker_id: u32,
125) -> Vec<u8> {
126    QryNightWhitelistClientReq {
127        hash: cache.broker_hash(broker_id),
128    }
129    .encode_to_vec()
130}
131
132pub fn parse_overnight_whitelist_rsp(
133    broker_id_hint: u32,
134    body: &[u8],
135) -> Result<OvernightWhitelistSnapshot> {
136    let rsp = QryNightWhitelistClientRsp::decode(body)
137        .map_err(|e| FutuError::Codec(format!("CMD20874 decode: {e}")))?;
138    let stock_ids = match rsp.stock_id_list.as_deref() {
139        Some(raw) => Some(unpack_stock_id_list_gzip(raw)?),
140        None => None,
141    };
142    Ok(OvernightWhitelistSnapshot {
143        broker_id: rsp.broker_id.unwrap_or(broker_id_hint),
144        hash: rsp.hash,
145        stock_ids,
146        update_interval_secs: rsp.update_interval,
147    })
148}
149
150pub async fn refresh_overnight_whitelist(
151    backend: &BackendConn,
152    cache: &OvernightWhitelistCache,
153    broker_id: u32,
154) -> Result<u64> {
155    if !is_valid_overnight_whitelist_broker(broker_id) {
156        tracing::debug!(
157            broker_id,
158            "CMD20874 overnight whitelist skipped for broker not in C++ valid set"
159        );
160        return Ok(DEFAULT_UPDATE_INTERVAL_SECS);
161    }
162
163    let body = build_overnight_whitelist_request(cache, broker_id);
164    tracing::debug!(
165        broker_id,
166        old_hash_present = cache.broker_hash(broker_id).is_some(),
167        "sending CMD20874 QryNightWhitelistClientReq"
168    );
169    let resp = backend.request(CMD_TRD_OVERNIGHT_WHITE_LIST, body).await?;
170    let snapshot = parse_overnight_whitelist_rsp(broker_id, resp.body.as_ref())?;
171    let interval = normalized_update_interval(snapshot.update_interval_secs);
172
173    if let Some(stock_ids) = snapshot.stock_ids {
174        let count = stock_ids.len();
175        cache.set_whitelist(snapshot.broker_id, snapshot.hash, stock_ids, Some(interval));
176        tracing::info!(
177            broker_id = snapshot.broker_id,
178            stock_id_count = count,
179            update_interval_secs = interval,
180            "CMD20874 overnight whitelist refreshed"
181        );
182    } else {
183        tracing::debug!(
184            broker_id = snapshot.broker_id,
185            update_interval_secs = interval,
186            "CMD20874 overnight whitelist response without stock_id_list; keeping existing cache"
187        );
188    }
189
190    Ok(interval)
191}
192
193pub fn normalized_update_interval(update_interval_secs: Option<u64>) -> u64 {
194    update_interval_secs
195        .filter(|secs| *secs >= MIN_UPDATE_INTERVAL_SECS)
196        .unwrap_or(DEFAULT_UPDATE_INTERVAL_SECS)
197}
198
199pub fn unpack_stock_id_list_gzip(raw: &[u8]) -> Result<Vec<u64>> {
200    let mut decoder = GzDecoder::new(raw);
201    let mut decoded = Vec::new();
202    decoder
203        .read_to_end(&mut decoded)
204        .map_err(|e| FutuError::Codec(format!("CMD20874 gzip decode: {e}")))?;
205
206    if decoded.len() % std::mem::size_of::<u64>() != 0 {
207        return Err(FutuError::Codec(format!(
208            "CMD20874 stock_id_list length {} is not a multiple of u64",
209            decoded.len()
210        )));
211    }
212
213    Ok(decoded
214        .chunks_exact(std::mem::size_of::<u64>())
215        .map(|chunk| {
216            let mut bytes = [0u8; 8];
217            bytes.copy_from_slice(chunk);
218            u64::from_le_bytes(bytes)
219        })
220        .collect())
221}
222
223#[cfg(test)]
224mod tests;