futu_backend/
suspend_data.rs1use std::collections::HashMap;
15use std::sync::Arc;
16
17pub type SuspendCache = Arc<dashmap::DashMap<u64, Vec<u64>>>;
19
20const SUSPEND_SOURCES: [(&str, &str); 3] = [
22 (
23 "http://openquote-1251001049.cosgz.myqcloud.com/hk_stock_suspend_record.zip",
24 "hk_stock_suspend_record",
25 ),
26 (
27 "http://openquote-1251001049.cosgz.myqcloud.com/us_stock_suspend_record.zip",
28 "us_stock_suspend_record",
29 ),
30 (
31 "http://openquote-1251001049.cosgz.myqcloud.com/cn_stock_suspend_record.zip",
32 "cn_stock_suspend_record",
33 ),
34];
35
36pub async fn load_suspend_data_with_client(client: &reqwest::Client) -> SuspendCache {
38 let cache: SuspendCache = Arc::new(dashmap::DashMap::new());
39
40 for (url, name) in &SUSPEND_SOURCES {
41 match download_and_parse(client, url, name).await {
42 Ok(data) => {
43 let count = data.len();
44 for (stock_id, timestamps) in data {
45 cache.insert(stock_id, timestamps);
46 }
47 tracing::info!(market = *name, stocks = count, "loaded suspend data");
48 }
49 Err(e) => {
50 tracing::debug!(market = *name, error = %e, "failed to load suspend data");
54 }
55 }
56 }
57
58 cache
59}
60
61async fn download_and_parse(
70 client: &reqwest::Client,
71 url: &str,
72 name: &str,
73) -> Result<HashMap<u64, Vec<u64>>, Box<dyn std::error::Error + Send + Sync>> {
74 tracing::debug!(url, "downloading suspend data");
75 let resp = client.get(url).send().await?;
76 let status = resp.status();
77 let content_type = resp
78 .headers()
79 .get(reqwest::header::CONTENT_TYPE)
80 .and_then(|v| v.to_str().ok())
81 .unwrap_or("<none>")
82 .to_string();
83
84 if !status.is_success() {
85 return Err(format!("HTTP {status} from {url} (content-type={content_type})").into());
86 }
87
88 let zip_bytes = resp.bytes().await?;
89 tracing::debug!(url, bytes = zip_bytes.len(), "downloaded suspend response");
90
91 let is_zip = zip_bytes.len() >= 4
93 && zip_bytes[0] == b'P'
94 && zip_bytes[1] == b'K'
95 && (zip_bytes[2] == 0x03 || zip_bytes[2] == 0x05 || zip_bytes[2] == 0x07);
96
97 if !is_zip {
98 let preview: String = zip_bytes
99 .iter()
100 .take(120)
101 .map(|&b| {
102 if b.is_ascii_graphic() || b == b' ' {
103 b as char
104 } else {
105 '·'
106 }
107 })
108 .collect();
109 tracing::debug!(
110 url,
111 content_type,
112 bytes = zip_bytes.len(),
113 preview,
114 "suspend data response is not a ZIP (magic number mismatch); CDN may have returned HTML error page"
115 );
116 return Err(
117 format!("not a ZIP archive (magic mismatch, content-type={content_type})").into(),
118 );
119 }
120
121 parse_suspend_zip(&zip_bytes, name)
124}
125
126fn parse_suspend_zip(
127 zip_bytes: &[u8],
128 name: &str,
129) -> Result<HashMap<u64, Vec<u64>>, Box<dyn std::error::Error + Send + Sync>> {
130 let cursor = std::io::Cursor::new(zip_bytes);
131 let mut archive = zip::ZipArchive::new(cursor)?;
132 let mut file = archive.by_name(name)?;
133 let mut dat_bytes = Vec::new();
134 std::io::Read::read_to_end(&mut file, &mut dat_bytes)?;
135
136 parse_suspend_dat(&dat_bytes)
137}
138
139fn parse_suspend_dat(
143 data: &[u8],
144) -> Result<HashMap<u64, Vec<u64>>, Box<dyn std::error::Error + Send + Sync>> {
145 use prost::Message;
146
147 if data.len() < 6 {
148 return Err("suspend dat too short".into());
149 }
150
151 let version = u16::from_be_bytes([data[0], data[1]]);
152 let group_count = u32::from_be_bytes([data[2], data[3], data[4], data[5]]);
153
154 if version == 0 || group_count == 0 {
156 return Err(
157 format!("invalid suspend dat: version={version}, group_count={group_count}").into(),
158 );
159 }
160
161 let mut result = HashMap::new();
162 let mut offset = 6_usize;
163 let mut read_groups = 0_u32;
164
165 while read_groups < group_count {
166 if offset + 4 > data.len() {
167 tracing::warn!(
168 offset,
169 remaining = data.len() - offset,
170 "suspend dat truncated at group_len"
171 );
172 break;
173 }
174
175 let group_len = u32::from_be_bytes([
176 data[offset],
177 data[offset + 1],
178 data[offset + 2],
179 data[offset + 3],
180 ]) as usize;
181 offset += 4;
182
183 if group_len == 0 {
185 tracing::warn!(offset, "suspend dat: zero group_len");
186 break;
187 }
188
189 if offset + group_len > data.len() {
190 tracing::warn!(
191 offset,
192 group_len,
193 data_len = data.len(),
194 "suspend dat truncated at group data"
195 );
196 break;
197 }
198
199 let group: super::proto_internal::stock_suspend::StockSuspendRecordGroup =
200 match Message::decode(&data[offset..offset + group_len]) {
201 Ok(g) => g,
202 Err(e) => {
203 tracing::warn!(error = %e, "suspend dat: protobuf decode failed");
204 break;
205 }
206 };
207
208 for record in &group.stock_suspend_record_list {
209 if let Some(stock_id) = record.stock_id {
210 let mut timestamps: Vec<u64> = record
211 .stock_sus_seq_list
212 .iter()
213 .filter_map(|seq| seq.time)
214 .collect();
215 if !timestamps.is_empty() {
216 timestamps.sort_unstable();
218 timestamps.dedup();
219 result.insert(stock_id, timestamps);
220 }
221 }
222 }
223
224 offset += group_len;
225 read_groups += 1;
226 }
227
228 Ok(result)
229}
230
231#[cfg(test)]
232mod tests;