Skip to main content

qmt_parser/
dividend.rs

1//! QMT 分红送配 LevelDB 查询。
2//!
3//! 这个模块读取 `DividData` 目录中的 LevelDB 数据,并把单条 value 解码为
4//! [`DividendRecord`]。
5
6use byteorder::{ByteOrder, LittleEndian};
7use chrono::{DateTime, FixedOffset, NaiveDate, Utc};
8use rusty_leveldb::{DB, LdbIterator, Options};
9use std::path::Path;
10use std::str;
11use thiserror::Error;
12
13/// 单条除权除息记录。
14#[derive(Debug, Clone)]
15pub struct DividendRecord {
16    /// 除权除息日。
17    pub ex_dividend_date: NaiveDate,
18    /// 股权登记日。
19    pub record_date: Option<NaiveDate>,
20    /// 每股现金红利。
21    pub interest: f64,
22    /// 每股送股。
23    pub stock_bonus: f64,
24    /// 每股转赠。
25    pub stock_gift: f64,
26    /// 配股数量。
27    pub allot_num: f64,
28    /// 配股价格。
29    pub allot_price: f64,
30    /// 股改相关值,当前仍保留原始含义。
31    pub gugai: f64,
32    /// 当前版本存在但语义未完全确认的额外槽位。
33    pub unknown64_raw: f64,
34    /// 复权系数。
35    pub adjust_factor: f64,
36    /// Value 中记录的原始时间戳。
37    pub timestamp_raw: i64,
38}
39
40/// 分红数据库读取错误。
41#[derive(Debug, Error)]
42pub enum DividendError {
43    /// LevelDB 打开失败。
44    #[error("无法打开 LevelDB: {0}")]
45    OpenDb(String),
46    /// 无法创建数据库迭代器。
47    #[error("无法创建 LevelDB 迭代器")]
48    IteratorUnavailable,
49    /// Key 结构不符合预期。
50    #[error("非法的分红 Key: {0}")]
51    InvalidKey(String),
52    /// Key 不是合法 UTF-8。
53    #[error("分红 Key 不是有效 UTF-8")]
54    InvalidUtf8Key,
55    /// 时间戳非法。
56    #[error("无效的分红时间戳: {0}")]
57    InvalidTimestamp(i64),
58    /// Value 结构不符合预期。
59    #[error("无法解析分红 Value: {0}")]
60    InvalidValue(String),
61}
62
63/// 分红 LevelDB 查询句柄。
64pub struct DividendDb {
65    db: DB,
66}
67
68impl DividendDb {
69    /// 打开 `DividData` 数据库目录。
70    ///
71    /// 注意:LevelDB 同时只能被一个进程加锁访问;如果 QMT 正在运行,
72    /// 打开原目录可能失败,通常建议传入备份目录。
73    ///
74    /// # Examples
75    ///
76    /// ```no_run
77    /// use qmt_parser::dividend::DividendDb;
78    ///
79    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
80    /// let _db = DividendDb::new("/path/to/DividData")?;
81    /// # Ok(())
82    /// # }
83    /// ```
84    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self, DividendError> {
85        let options = Options {
86            create_if_missing: false,
87            ..Options::default()
88        };
89
90        match DB::open(path, options) {
91            Ok(db) => Ok(Self { db }),
92            Err(e) => Err(DividendError::OpenDb(e.to_string())),
93        }
94    }
95
96    /// 查询指定市场和证券代码的除权除息记录。
97    ///
98    /// `market` 典型值为 `SH`、`SZ`、`BJ`。
99    ///
100    /// # Examples
101    ///
102    /// ```no_run
103    /// use qmt_parser::dividend::DividendDb;
104    ///
105    /// # fn main() -> Result<(), Box<dyn std::error::Error>> {
106    /// let mut db = DividendDb::new("/path/to/DividData")?;
107    /// let records = db.query("SH", "600000")?;
108    /// println!("records = {}", records.len());
109    /// # Ok(())
110    /// # }
111    /// ```
112    pub fn query(
113        &mut self,
114        market: &str,
115        code: &str,
116    ) -> Result<Vec<DividendRecord>, DividendError> {
117        let mut results = Vec::new();
118
119        let prefix = format!("{}|{}|", market, code);
120        let prefix_bytes = prefix.as_bytes();
121
122        let mut iter = self
123            .db
124            .new_iter()
125            .map_err(|_| DividendError::IteratorUnavailable)?;
126
127        iter.seek(prefix_bytes);
128
129        while let Some((key, value)) = iter.next() {
130            if !key.starts_with(prefix_bytes) {
131                break;
132            }
133
134            let ts_key = match Self::parse_key_timestamp(&key)? {
135                Some(ts_key) => ts_key,
136                None => continue,
137            };
138
139            if ts_key == 0 || ts_key > 3_000_000_000_000 {
140                continue;
141            }
142
143            if let Some(record) = Self::parse_value(&value)? {
144                results.push(record);
145            }
146        }
147
148        Ok(results)
149    }
150
151    /// 解析二进制 Value
152    /// 当前观测布局:
153    /// [8 bytes unknown] [8 bytes TS]
154    /// [interest] [stock_bonus] [stock_gift] [allot_num] [allot_price]
155    /// [gugai] [unknown64] [adjust_factor]
156    /// [record_date: u32] [padding: u32] [ex_dividend_date: u32] [padding: u32]
157    fn parse_value(data: &[u8]) -> Result<Option<DividendRecord>, DividendError> {
158        if data.is_empty() {
159            return Ok(None);
160        }
161        if data.len() < 96 {
162            return Err(DividendError::InvalidValue(format!(
163                "value too short: expected at least 96 bytes, got {}",
164                data.len()
165            )));
166        }
167
168        let ts_val = LittleEndian::read_i64(&data[8..16]);
169        if ts_val <= 0 {
170            return Err(DividendError::InvalidTimestamp(ts_val));
171        }
172        let interest = LittleEndian::read_f64(&data[16..24]);
173        let stock_bonus = LittleEndian::read_f64(&data[24..32]);
174        let stock_gift = LittleEndian::read_f64(&data[32..40]);
175        let allot_num = LittleEndian::read_f64(&data[40..48]);
176        let allot_price = LittleEndian::read_f64(&data[48..56]);
177        let gugai = LittleEndian::read_f64(&data[56..64]);
178        let unknown64_raw = LittleEndian::read_f64(&data[64..72]);
179        let adjust_factor = LittleEndian::read_f64(&data[72..80]);
180
181        let record_date = Self::parse_yyyymmdd_u32(LittleEndian::read_u32(&data[80..84]));
182        let ex_dividend_date = Self::parse_yyyymmdd_u32(LittleEndian::read_u32(&data[88..92]))
183            .or_else(|| Self::date_from_timestamp_bj(ts_val))
184            .ok_or(DividendError::InvalidTimestamp(ts_val))?;
185
186        Ok(Some(DividendRecord {
187            ex_dividend_date,
188            record_date,
189            interest,
190            stock_bonus,
191            stock_gift,
192            allot_num,
193            allot_price,
194            gugai,
195            unknown64_raw,
196            adjust_factor,
197            timestamp_raw: ts_val,
198        }))
199    }
200
201    fn parse_key_timestamp(key: &[u8]) -> Result<Option<i64>, DividendError> {
202        let key_str = str::from_utf8(key).map_err(|_| DividendError::InvalidUtf8Key)?;
203        let parts: Vec<&str> = key_str.split('|').collect();
204        if parts.len() < 4 {
205            return Err(DividendError::InvalidKey(key_str.to_string()));
206        }
207
208        let ts = parts
209            .last()
210            .ok_or_else(|| DividendError::InvalidKey(key_str.to_string()))?
211            .parse::<i64>()
212            .map_err(|_| DividendError::InvalidKey(key_str.to_string()))?;
213
214        if ts == 0 || ts > 3_000_000_000_000 {
215            return Ok(None);
216        }
217
218        Ok(Some(ts))
219    }
220
221    fn parse_yyyymmdd_u32(raw: u32) -> Option<NaiveDate> {
222        if raw == 0 {
223            return None;
224        }
225
226        let year = (raw / 10_000) as i32;
227        let month = raw / 100 % 100;
228        let day = raw % 100;
229        NaiveDate::from_ymd_opt(year, month, day)
230    }
231
232    fn date_from_timestamp_bj(ts_val: i64) -> Option<NaiveDate> {
233        let seconds = ts_val / 1000;
234        let nanoseconds = (ts_val % 1000) * 1_000_000;
235        let dt_utc = DateTime::<Utc>::from_timestamp(seconds, nanoseconds as u32)?;
236        let bj = FixedOffset::east_opt(8 * 3600)?;
237        Some(dt_utc.with_timezone(&bj).date_naive())
238    }
239}
240
241#[test]
242fn test_dividend() {
243    // 假设这是你复制出来的临时目录,避免锁冲突
244    let db_path = "/mnt/data/trade/qmtdata/datadir/DividData";
245
246    // 初始化
247    let mut qmt_db = match DividendDb::new(db_path) {
248        Ok(db) => db,
249        Err(e) => {
250            eprintln!("错误: {}", e);
251            return;
252        }
253    };
254
255    // 查询 21国债16 (SH.185222)
256    println!("正在查询 SH.185222 ...");
257    let records = qmt_db.query("SH", "185222").expect("query dividend");
258
259    if records.is_empty() {
260        eprintln!("未找到记录或解析失败。");
261    }
262
263    for record in records {
264        println!("--------------------------------");
265        println!("除权日   : {}", record.ex_dividend_date);
266        println!("登记日   : {:?}", record.record_date);
267        println!("每股红利 : {:.4}", record.interest);
268        println!("每股送转 : {:.4}", record.stock_bonus);
269        println!("每股转赠 : {:.4}", record.stock_gift);
270        println!("配股数量 : {:.4}", record.allot_num);
271        println!("配股价格 : {:.4}", record.allot_price);
272        println!("股改值   : {:.4}", record.gugai);
273        println!("复权系数 : {:.6}", record.adjust_factor);
274    }
275}
276
277#[test]
278fn test_parse_dividend_value_cash_dates_and_factor() {
279    let raw = decode_hex(
280        "2087c6faff7f000000488fa1850100005c8fc2f5285c09400000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000ce8853b1786f03fdfaf340100000000e0af340100000000",
281    )
282    .unwrap();
283
284    let record = DividendDb::parse_value(&raw)
285        .expect("should parse")
286        .expect("record");
287    assert_eq!(
288        record.ex_dividend_date,
289        NaiveDate::from_ymd_opt(2023, 1, 12).unwrap()
290    );
291    assert_eq!(
292        record.record_date,
293        Some(NaiveDate::from_ymd_opt(2023, 1, 11).unwrap())
294    );
295    assert_eq!(record.interest, 3.17);
296    assert_eq!(record.stock_bonus, 0.0);
297    assert_eq!(record.stock_gift, 0.0);
298    assert_eq!(record.allot_num, 0.0);
299    assert_eq!(record.allot_price, 0.0);
300    assert_eq!(record.gugai, 0.0);
301    assert!((record.adjust_factor - 1.032737).abs() < 1e-9);
302}
303
304#[test]
305fn test_parse_dividend_value_bonus_gift_and_rights_issue() {
306    let bonus_raw = decode_hex(
307        "2087c6faff7f000000e4f9da630100009a9999999999b93f0000000000000000000000000000e03f0000000000000000000000000000000000000000000000000000000000000000b56b425a6350f83f7fee33010000000080ee330100000000",
308    )
309    .unwrap();
310    let bonus_record = DividendDb::parse_value(&bonus_raw)
311        .expect("should parse")
312        .expect("record");
313    assert_eq!(
314        bonus_record.ex_dividend_date,
315        NaiveDate::from_ymd_opt(2018, 6, 8).unwrap()
316    );
317    assert_eq!(
318        bonus_record.record_date,
319        Some(NaiveDate::from_ymd_opt(2018, 6, 7).unwrap())
320    );
321    assert_eq!(bonus_record.interest, 0.1);
322    assert_eq!(bonus_record.stock_bonus, 0.0);
323    assert_eq!(bonus_record.stock_gift, 0.5);
324    assert_eq!(bonus_record.allot_num, 0.0);
325    assert_eq!(bonus_record.allot_price, 0.0);
326    assert_eq!(bonus_record.gugai, 0.0);
327    assert!((bonus_record.adjust_factor - 1.519626).abs() < 1e-9);
328
329    let rights_raw = decode_hex(
330        "2087c6faff7f00000040675d27010000000000000000000000000000000000000000000000000000a4703d0ad7a3c03f3333333333b3214000000000000000000000000000000000ae9b525e2be1f03fd0b43201000000000000000000000000",
331    )
332    .unwrap();
333    let rights_record = DividendDb::parse_value(&rights_raw)
334        .expect("should parse")
335        .expect("record");
336    assert_eq!(
337        rights_record.ex_dividend_date,
338        NaiveDate::from_ymd_opt(2010, 3, 15).unwrap()
339    );
340    assert_eq!(
341        rights_record.record_date,
342        Some(NaiveDate::from_ymd_opt(2010, 3, 4).unwrap())
343    );
344    assert_eq!(rights_record.interest, 0.0);
345    assert_eq!(rights_record.stock_bonus, 0.0);
346    assert_eq!(rights_record.stock_gift, 0.0);
347    assert!((rights_record.allot_num - 0.13).abs() < 1e-12);
348    assert!((rights_record.allot_price - 8.85).abs() < 1e-12);
349    assert_eq!(rights_record.gugai, 0.0);
350    assert!((rights_record.adjust_factor - 1.054973).abs() < 1e-9);
351}
352
353#[test]
354fn test_parse_dividend_value_gugai_slot() {
355    let raw = decode_hex(
356        "2087c6faff7f000000583e5b940100005c8fc2f5285c0940000000000000000000000000000000000000000000000000000000000000000000000000000059400000000000000000199293895b85f03ffefd34010000000000fe340100000000",
357    )
358    .unwrap();
359
360    let record = DividendDb::parse_value(&raw)
361        .expect("should parse")
362        .expect("record");
363    assert_eq!(
364        record.ex_dividend_date,
365        NaiveDate::from_ymd_opt(2025, 1, 12).unwrap()
366    );
367    assert_eq!(
368        record.record_date,
369        Some(NaiveDate::from_ymd_opt(2025, 1, 10).unwrap())
370    );
371    assert_eq!(record.interest, 3.17);
372    assert_eq!(record.stock_bonus, 0.0);
373    assert_eq!(record.stock_gift, 0.0);
374    assert_eq!(record.allot_num, 0.0);
375    assert_eq!(record.allot_price, 0.0);
376    assert_eq!(record.gugai, 100.0);
377    assert!((record.adjust_factor - 1.032558).abs() < 1e-9);
378}
379
380#[test]
381fn test_dividend_open_missing_db_returns_typed_error() {
382    match DividendDb::new("/definitely/missing/dividend-db") {
383        Err(DividendError::OpenDb(_)) => {}
384        Err(other) => panic!("unexpected error: {other}"),
385        Ok(_) => panic!("expected missing db to fail"),
386    }
387}
388
389#[test]
390fn test_parse_dividend_key_timestamp_rejects_invalid_key() {
391    let err = DividendDb::parse_key_timestamp(b"SH|185222").unwrap_err();
392    assert!(matches!(err, DividendError::InvalidKey(_)));
393}
394
395#[cfg(test)]
396fn decode_hex(input: &str) -> Result<Vec<u8>, String> {
397    if !input.len().is_multiple_of(2) {
398        return Err("hex length must be even".to_string());
399    }
400
401    let mut out = Vec::with_capacity(input.len() / 2);
402    let bytes = input.as_bytes();
403    let mut i = 0;
404    while i < bytes.len() {
405        let hi = (bytes[i] as char)
406            .to_digit(16)
407            .ok_or_else(|| format!("invalid hex at {}", i))?;
408        let lo = (bytes[i + 1] as char)
409            .to_digit(16)
410            .ok_or_else(|| format!("invalid hex at {}", i + 1))?;
411        out.push(((hi << 4) | lo) as u8);
412        i += 2;
413    }
414    Ok(out)
415}