1use 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#[derive(Debug, Clone)]
15pub struct DividendRecord {
16 pub ex_dividend_date: NaiveDate,
18 pub record_date: Option<NaiveDate>,
20 pub interest: f64,
22 pub stock_bonus: f64,
24 pub stock_gift: f64,
26 pub allot_num: f64,
28 pub allot_price: f64,
30 pub gugai: f64,
32 pub unknown64_raw: f64,
34 pub adjust_factor: f64,
36 pub timestamp_raw: i64,
38}
39
40#[derive(Debug, Error)]
42pub enum DividendError {
43 #[error("无法打开 LevelDB: {0}")]
45 OpenDb(String),
46 #[error("无法创建 LevelDB 迭代器")]
48 IteratorUnavailable,
49 #[error("非法的分红 Key: {0}")]
51 InvalidKey(String),
52 #[error("分红 Key 不是有效 UTF-8")]
54 InvalidUtf8Key,
55 #[error("无效的分红时间戳: {0}")]
57 InvalidTimestamp(i64),
58 #[error("无法解析分红 Value: {0}")]
60 InvalidValue(String),
61}
62
63pub struct DividendDb {
65 db: DB,
66}
67
68impl DividendDb {
69 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 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 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 let db_path = "/mnt/data/trade/qmtdata/datadir/DividData";
245
246 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 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}