futu_backend/
overnight_whitelist.rs1use 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
47pub use futu_command_spec::CMD_TRD_OVERNIGHT_WHITELIST as CMD_TRD_OVERNIGHT_WHITE_LIST;
49
50pub 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#[derive(Debug, Clone, Default)]
63pub struct OvernightWhitelistCache {
64 inner: Arc<dashmap::DashMap<u32, OvernightWhitelistEntry>>,
65}
66
67#[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;