Skip to main content

futu_backend/
suspend_data.rs

1//! 停牌数据 HTTP 下载 + 解析
2//!
3//! C++ 对应: NNBiz_Qot_Suspend / NNData_Qot_Suspend
4//!
5//! 从腾讯云 CDN 下载 zip 文件,解压后得到 .dat 二进制文件,格式:
6//! - 2 字节 version (big-endian u16)
7//! - 4 字节 group_count (big-endian u32)
8//! - group_count 个组,每个:
9//!   - 4 字节 group_len (big-endian u32)
10//!   - group_len 字节 protobuf (StockSuspendRecordGroup)
11//!
12//! 解析后缓存在 DashMap<stock_id, Vec<timestamp>> 中,供 GetSuspend handler 查询。
13
14use std::collections::HashMap;
15use std::sync::Arc;
16
17/// 停牌数据缓存:stock_id → Vec<timestamp>(已排序的停牌日期时间戳列表)
18pub type SuspendCache = Arc<dashmap::DashMap<u64, Vec<u64>>>;
19
20/// CDN URLs 和对应的 zip 内文件名
21const 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
36/// 下载并解析所有市场的停牌数据
37pub 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                // v1.4.27:从 WARN 降 DEBUG。CDN 偶尔返 404 / HTML 错误页对终端
51                // 用户无 actionable 价值,也不影响交易 / 行情主功能;有兴趣的
52                // 运维可以用 `--log-level debug` 看到完整错误。
53                tracing::debug!(market = *name, error = %e, "failed to load suspend data");
54            }
55        }
56    }
57
58    cache
59}
60
61/// 下载单个市场的 zip 文件并解析
62///
63/// v1.4.27 修(BUG-3,加拿大同事 v1.4.26 回归测试发现):
64/// - HTTP 非 200 → 直接降级,不尝试解 ZIP
65/// - 内容不是 ZIP(magic number 不是 `PK\x03\x04`)→ 降级 + 用 DEBUG 打
66///   出前 N 字节 + Content-Type(帮助判断是否拿到了 HTML 错误页)
67/// - 只有真正是 ZIP 才进 `ZipArchive` 解析,避免 "Could not find EOCD" 这种
68///   对终端用户无 actionable 价值的 WARN
69async 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    // ZIP magic number: `PK\x03\x04` (or `PK\x05\x06` for empty archive, `PK\x07\x08` for spanned)
92    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    // 真正是 ZIP 才进解压(至此若报 "Could not find EOCD" 就是 ZIP 实际损坏,
122    // 值得 WARN;否则 magic-check 已经把 HTML 错误页 / 空响应挡在外面)
123    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
139/// 解析 .dat 二进制格式
140///
141/// C++ 对应: NNBiz_Qot_Suspend::LoadFile
142fn 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    // C++ 校验: nVersion <= 0 || nGroupCnt <= 0
155    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        // C++ 校验: nGroupLen <= 0
184        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                    // C++ 中 SetSuspendData 会 sort,GetSuspendData 用 lower_bound/upper_bound
217                    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;