futu_backend/
overnight_whitelist.rs1use 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
41pub const CMD_TRD_OVERNIGHT_WHITE_LIST: u16 = 20874;
43
44pub const DEFAULT_UPDATE_INTERVAL_SECS: u64 = 3600;
47
48pub 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#[derive(Debug, Clone, Default)]
62pub struct OvernightWhitelistCache {
63 inner: Arc<dashmap::DashMap<u32, OvernightWhitelistEntry>>,
64}
65
66#[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;