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