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