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;
31use std::sync::atomic::{AtomicU64, Ordering};
32
33use bytes::Bytes;
34use flate2::read::GzDecoder;
35use futu_command_spec::BackendExtensionOperation;
36use futu_core::error::{FutuError, Result};
37use futu_domain_trade_write::{
38    OvernightWhitelistCacheAction, OvernightWhitelistResponseFacts,
39    plan_overnight_whitelist_response_like_cpp,
40};
41use prost::Message;
42
43use crate::conn::BackendConn;
44use crate::proto_internal::securities_switch::{
45    QryNightWhitelistClientReq, QryNightWhitelistClientRsp,
46};
47
48/// Compatibility export for existing callers and diagnostics.
49pub use futu_command_spec::CMD_TRD_OVERNIGHT_WHITELIST as CMD_TRD_OVERNIGHT_WHITE_LIST;
50
51/// C++ fallback interval in `OMEvProc_OvernightWhiteList` when backend does
52/// not provide a usable `update_interval`.
53pub use futu_domain_trade_write::DEFAULT_OVERNIGHT_WHITELIST_REFRESH_INTERVAL_SECS as DEFAULT_UPDATE_INTERVAL_SECS;
54pub use futu_domain_trade_write::is_valid_overnight_whitelist_broker_like_cpp as is_valid_overnight_whitelist_broker;
55
56#[derive(Debug, Clone)]
57struct OvernightWhitelistEntry {
58    hash: Option<String>,
59    stock_ids: Arc<HashSet<u64>>,
60}
61
62/// Shared per-broker overnight whitelist cache.
63#[derive(Debug, Clone, Default)]
64pub struct OvernightWhitelistCache {
65    inner: Arc<dashmap::DashMap<u32, OvernightWhitelistEntry>>,
66    generation: Arc<AtomicU64>,
67}
68
69/// Decoded CMD20874 response.
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct OvernightWhitelistSnapshot {
72    pub response_broker_id: Option<u32>,
73    pub hash: Option<String>,
74    pub stock_ids: Option<Vec<u64>>,
75    pub update_interval_secs: Option<u64>,
76}
77
78impl OvernightWhitelistCache {
79    pub fn new() -> Self {
80        Self::default()
81    }
82
83    pub fn is_stock_in_whitelist(&self, broker_id: u32, stock_id: u64) -> bool {
84        self.inner
85            .get(&broker_id)
86            .is_some_and(|entry| entry.stock_ids.contains(&stock_id))
87    }
88
89    /// Monotonic publication generation for writer-admission fencing.
90    #[must_use]
91    pub fn generation(&self) -> u64 {
92        self.generation.load(Ordering::Acquire)
93    }
94
95    fn broker_hash(&self, broker_id: u32) -> Option<String> {
96        self.inner
97            .get(&broker_id)
98            .and_then(|entry| entry.hash.clone())
99    }
100
101    fn set_whitelist(
102        &self,
103        broker_id: u32,
104        hash: Option<String>,
105        stock_ids: impl IntoIterator<Item = u64>,
106    ) {
107        let stock_ids = stock_ids.into_iter().collect::<HashSet<_>>();
108        self.inner.insert(
109            broker_id,
110            OvernightWhitelistEntry {
111                hash,
112                stock_ids: Arc::new(stock_ids),
113            },
114        );
115        self.generation.fetch_add(1, Ordering::AcqRel);
116    }
117}
118
119pub fn build_overnight_whitelist_request(
120    cache: &OvernightWhitelistCache,
121    broker_id: u32,
122) -> Vec<u8> {
123    QryNightWhitelistClientReq {
124        hash: cache.broker_hash(broker_id),
125    }
126    .encode_to_vec()
127}
128
129pub fn parse_overnight_whitelist_rsp(body: &[u8]) -> Result<OvernightWhitelistSnapshot> {
130    let rsp = QryNightWhitelistClientRsp::decode(body)
131        .map_err(|e| FutuError::Codec(format!("CMD20874 decode: {e}")))?;
132    let stock_ids = match rsp.stock_id_list.as_deref() {
133        Some(raw) => Some(unpack_stock_id_list_gzip(raw)?),
134        None => None,
135    };
136    Ok(OvernightWhitelistSnapshot {
137        response_broker_id: rsp.broker_id,
138        hash: rsp.hash,
139        stock_ids,
140        update_interval_secs: rsp.update_interval,
141    })
142}
143
144pub async fn refresh_overnight_whitelist(
145    backend: &BackendConn,
146    cache: &OvernightWhitelistCache,
147    broker_id: u32,
148) -> Result<u64> {
149    if !is_valid_overnight_whitelist_broker(broker_id) {
150        tracing::debug!(
151            broker_id,
152            "CMD20874 overnight whitelist skipped for broker not in C++ valid set"
153        );
154        return Ok(DEFAULT_UPDATE_INTERVAL_SECS);
155    }
156
157    let body = build_overnight_whitelist_request(cache, broker_id);
158    tracing::debug!(
159        broker_id,
160        old_hash_present = cache.broker_hash(broker_id).is_some(),
161        "sending CMD20874 QryNightWhitelistClientReq"
162    );
163    let resp = crate::command_runtime::execute_backend_extension(
164        backend,
165        BackendExtensionOperation::OvernightWhitelist,
166        Bytes::from(body),
167    )
168    .await?;
169    let snapshot = parse_overnight_whitelist_rsp(resp.body.as_ref())?;
170    let plan = plan_overnight_whitelist_response_like_cpp(OvernightWhitelistResponseFacts {
171        request_broker_id: broker_id,
172        response_broker_id: snapshot.response_broker_id,
173        hash: snapshot.hash,
174        stock_ids: snapshot.stock_ids,
175        update_interval_secs: snapshot.update_interval_secs,
176    });
177
178    match plan.cache_action {
179        OvernightWhitelistCacheAction::Replace {
180            broker_id,
181            hash,
182            stock_ids,
183        } => {
184            let count = stock_ids.len();
185            cache.set_whitelist(broker_id, Some(hash), stock_ids);
186            tracing::info!(
187                broker_id,
188                stock_id_count = count,
189                update_interval_secs = plan.next_refresh_interval_secs,
190                "CMD20874 overnight whitelist refreshed"
191            );
192        }
193        OvernightWhitelistCacheAction::KeepExisting => {
194            tracing::debug!(
195                broker_id,
196                update_interval_secs = plan.next_refresh_interval_secs,
197                "CMD20874 overnight whitelist response did not contain a non-empty hash and stock list; keeping existing cache"
198            );
199        }
200    }
201
202    Ok(plan.next_refresh_interval_secs)
203}
204
205pub fn unpack_stock_id_list_gzip(raw: &[u8]) -> Result<Vec<u64>> {
206    let mut decoder = GzDecoder::new(raw);
207    let mut decoded = Vec::new();
208    decoder
209        .read_to_end(&mut decoded)
210        .map_err(|e| FutuError::Codec(format!("CMD20874 gzip decode: {e}")))?;
211
212    let (chunks, remainder) = decoded.as_chunks::<8>();
213    if !remainder.is_empty() {
214        return Err(FutuError::Codec(format!(
215            "CMD20874 stock_id_list length {} is not a multiple of u64",
216            decoded.len()
217        )));
218    }
219
220    Ok(chunks
221        .iter()
222        .map(|bytes| u64::from_le_bytes(*bytes))
223        .collect())
224}
225
226#[cfg(test)]
227mod tests;