Skip to main content

qmt_parser/
metadata.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::path::{Path, PathBuf};
4
5use crate::error::MetadataParseError;
6
7fn parse_holiday_token(token: &str) -> Option<i64> {
8    let token = token.trim();
9    if token.is_empty() {
10        return None;
11    }
12    if token.len() == 8 {
13        return token.parse::<i64>().ok();
14    }
15    token
16        .parse::<i64>()
17        .ok()
18        .map(|value| value.div_euclid(86_400_000))
19}
20
21fn detect_holiday_roots() -> Vec<PathBuf> {
22    let mut out = Vec::new();
23    if let Ok(path) = std::env::var("XTQUANT_HOLIDAY_DIR") {
24        out.push(PathBuf::from(path));
25    }
26    if let Ok(path) = std::env::var("XTQUANT_APPDATA_DIR") {
27        out.push(PathBuf::from(path));
28    }
29    if let Ok(path) = std::env::var("XTQUANT_DATA_DIR") {
30        out.push(PathBuf::from(path));
31    }
32    out
33}
34
35fn detect_weight_roots() -> Vec<PathBuf> {
36    let mut out = Vec::new();
37    if let Ok(path) = std::env::var("XTQUANT_WEIGHT_DIR") {
38        out.push(PathBuf::from(path));
39    }
40    if let Ok(path) = std::env::var("XTQUANT_APPDATA_DIR") {
41        out.push(PathBuf::from(path));
42    }
43    if let Ok(path) = std::env::var("XTQUANT_DATA_DIR") {
44        out.push(PathBuf::from(path));
45    }
46    out
47}
48
49fn detect_industry_roots() -> Vec<PathBuf> {
50    let mut out = Vec::new();
51    if let Ok(path) = std::env::var("XTQUANT_INDUSTRY_DIR") {
52        out.push(PathBuf::from(path));
53    }
54    if let Ok(path) = std::env::var("XTQUANT_APPDATA_DIR") {
55        out.push(PathBuf::from(path));
56    }
57    if let Ok(path) = std::env::var("XTQUANT_DATA_DIR") {
58        out.push(PathBuf::from(path));
59    }
60    out
61}
62
63fn detect_sector_roots() -> Vec<PathBuf> {
64    let mut out = Vec::new();
65    if let Ok(path) = std::env::var("XTQUANT_SECTOR_DIR") {
66        out.push(PathBuf::from(path));
67    }
68    if let Ok(path) = std::env::var("XTQUANT_APPDATA_DIR") {
69        out.push(PathBuf::from(path));
70    }
71    if let Ok(path) = std::env::var("XTQUANT_DATA_DIR") {
72        out.push(PathBuf::from(path));
73    }
74    out
75}
76
77fn resolve_holiday_csv() -> Result<PathBuf, MetadataParseError> {
78    resolve_holiday_csv_from_roots(&detect_holiday_roots())
79}
80
81fn resolve_holiday_dat() -> Result<PathBuf, MetadataParseError> {
82    resolve_holiday_dat_from_roots(&detect_holiday_roots())
83}
84
85fn resolve_sector_name_files() -> Vec<PathBuf> {
86    resolve_sector_name_files_from_roots(&detect_weight_roots())
87}
88
89fn resolve_sector_weight_file() -> Result<PathBuf, MetadataParseError> {
90    resolve_sector_weight_file_from_roots(&detect_weight_roots())
91}
92
93fn resolve_sectorlist_dat() -> Result<PathBuf, MetadataParseError> {
94    resolve_sectorlist_dat_from_roots(&detect_sector_roots())
95}
96
97fn resolve_industry_file() -> Result<PathBuf, MetadataParseError> {
98    resolve_industry_file_from_roots(&detect_industry_roots())
99}
100
101fn resolve_holiday_csv_from_roots(roots: &[PathBuf]) -> Result<PathBuf, MetadataParseError> {
102    for root in roots {
103        if !root.exists() {
104            continue;
105        }
106        for path in [
107            root.join("holiday.csv"),
108            root.join("holiday").join("holiday.csv"),
109        ] {
110            if path.exists() {
111                return Ok(path);
112            }
113        }
114    }
115    Err(MetadataParseError::Io(std::io::Error::new(
116        std::io::ErrorKind::NotFound,
117        "holiday.csv not found",
118    )))
119}
120
121fn resolve_holiday_dat_from_roots(roots: &[PathBuf]) -> Result<PathBuf, MetadataParseError> {
122    for root in roots {
123        if !root.exists() {
124            continue;
125        }
126        for path in [
127            root.join("holiday.dat"),
128            root.join("holiday").join("holiday.dat"),
129        ] {
130            if path.exists() {
131                return Ok(path);
132            }
133        }
134    }
135    Err(MetadataParseError::Io(std::io::Error::new(
136        std::io::ErrorKind::NotFound,
137        "holiday.dat not found",
138    )))
139}
140
141fn resolve_sector_name_files_from_roots(roots: &[PathBuf]) -> Vec<PathBuf> {
142    let mut out = Vec::new();
143    for root in roots {
144        if !root.exists() {
145            continue;
146        }
147        for path in [
148            root.join("systemSectorWeightData.txt"),
149            root.join("customSectorWeightData.txt"),
150            root.join("Weight").join("systemSectorWeightData.txt"),
151            root.join("Weight").join("customSectorWeightData.txt"),
152        ] {
153            if path.exists() {
154                out.push(path);
155            }
156        }
157    }
158    out
159}
160
161fn resolve_sector_weight_file_from_roots(roots: &[PathBuf]) -> Result<PathBuf, MetadataParseError> {
162    for root in roots {
163        if !root.exists() {
164            continue;
165        }
166        for path in [
167            root.join("sectorWeightData.txt"),
168            root.join("Weight").join("sectorWeightData.txt"),
169        ] {
170            if path.exists() {
171                return Ok(path);
172            }
173        }
174    }
175    Err(MetadataParseError::Io(std::io::Error::new(
176        std::io::ErrorKind::NotFound,
177        "sectorWeightData.txt not found",
178    )))
179}
180
181fn resolve_sectorlist_dat_from_roots(roots: &[PathBuf]) -> Result<PathBuf, MetadataParseError> {
182    for root in roots {
183        if !root.exists() {
184            continue;
185        }
186        for path in [
187            root.join("sectorlist.DAT"),
188            root.join("Sector").join("sectorlist.DAT"),
189            root.join("Sector").join("Temple").join("sectorlist.DAT"),
190        ] {
191            if path.exists() {
192                return Ok(path);
193            }
194        }
195    }
196    Err(MetadataParseError::Io(std::io::Error::new(
197        std::io::ErrorKind::NotFound,
198        "sectorlist.DAT not found",
199    )))
200}
201
202fn resolve_industry_file_from_roots(roots: &[PathBuf]) -> Result<PathBuf, MetadataParseError> {
203    for root in roots {
204        if !root.exists() {
205            continue;
206        }
207        for path in [
208            root.join("IndustryData.txt"),
209            root.join("Industry").join("IndustryData.txt"),
210        ] {
211            if path.exists() {
212                return Ok(path);
213            }
214        }
215    }
216    Err(MetadataParseError::Io(std::io::Error::new(
217        std::io::ErrorKind::NotFound,
218        "IndustryData.txt not found",
219    )))
220}
221
222/// 解析 xtquant `holiday.csv` / `holiday.dat`,返回 `YYYYMMDD` 数字列表。
223pub fn parse_holiday_file(path: impl AsRef<Path>) -> Result<Vec<i64>, MetadataParseError> {
224    let text = fs::read_to_string(path)?;
225    let mut out = Vec::new();
226    for line in text.lines() {
227        let line = line.trim();
228        if line.is_empty() {
229            continue;
230        }
231        let Some(first) = line.split(',').next() else {
232            continue;
233        };
234        let Some(day) = parse_holiday_token(first) else {
235            continue;
236        };
237        out.push(day);
238    }
239    if out.is_empty() {
240        return Err(MetadataParseError::NoRecords("holiday"));
241    }
242    Ok(out)
243}
244
245/// 从 xtquant 约定路径自动发现并解析节假日文件。
246pub fn load_holidays_from_standard_paths() -> Result<Vec<i64>, MetadataParseError> {
247    resolve_holiday_csv()
248        .and_then(parse_holiday_file)
249        .or_else(|_| resolve_holiday_dat().and_then(parse_holiday_file))
250}
251
252/// 从显式 datadir/root 路径发现并解析节假日文件。
253pub fn load_holidays_from_root(root: impl AsRef<Path>) -> Result<Vec<i64>, MetadataParseError> {
254    let roots = vec![root.as_ref().to_path_buf()];
255    resolve_holiday_csv_from_roots(&roots)
256        .and_then(parse_holiday_file)
257        .or_else(|_| resolve_holiday_dat_from_roots(&roots).and_then(parse_holiday_file))
258}
259
260/// 解析 xtquant split sector 文件,返回 sector 名称列表。
261pub fn parse_sector_name_file(path: impl AsRef<Path>) -> Result<Vec<String>, MetadataParseError> {
262    let text = fs::read_to_string(path)?;
263    let mut out = text
264        .lines()
265        .map(str::trim)
266        .filter(|line| !line.is_empty())
267        .filter_map(|line| line.split(';').next().map(str::trim))
268        .filter(|name| !name.is_empty())
269        .map(ToString::to_string)
270        .collect::<Vec<_>>();
271    out.sort();
272    out.dedup();
273    if out.is_empty() {
274        return Err(MetadataParseError::NoRecords("sector names"));
275    }
276    Ok(out)
277}
278
279/// 从 xtquant 约定路径自动发现并解析 split sector 文件。
280pub fn load_sector_names_from_standard_paths() -> Result<Vec<String>, MetadataParseError> {
281    let paths = resolve_sector_name_files();
282    if paths.is_empty() {
283        return load_sectorlist_from_standard_paths();
284    }
285    let mut out = Vec::new();
286    for path in paths {
287        out.extend(parse_sector_name_file(path)?);
288    }
289    out.sort();
290    out.dedup();
291    if out.is_empty() {
292        return Err(MetadataParseError::NoRecords("sector names"));
293    }
294    Ok(out)
295}
296
297/// 从显式 datadir/root 路径发现并解析 split sector 文件。
298pub fn load_sector_names_from_root(
299    root: impl AsRef<Path>,
300) -> Result<Vec<String>, MetadataParseError> {
301    let roots = vec![root.as_ref().to_path_buf()];
302    let paths = resolve_sector_name_files_from_roots(&roots);
303    if paths.is_empty() {
304        return load_sectorlist_from_root(root);
305    }
306    let mut out = Vec::new();
307    for path in paths {
308        out.extend(parse_sector_name_file(path)?);
309    }
310    out.sort();
311    out.dedup();
312    if out.is_empty() {
313        return Err(MetadataParseError::NoRecords("sector names"));
314    }
315    Ok(out)
316}
317
318/// 解析 xtquant `sectorlist.DAT`,返回板块名称列表。
319pub fn parse_sectorlist_dat(path: impl AsRef<Path>) -> Result<Vec<String>, MetadataParseError> {
320    let bytes = fs::read(path)?;
321    let text = String::from_utf8_lossy(&bytes);
322    let mut out = text
323        .lines()
324        .map(str::trim)
325        .filter(|line| !line.is_empty())
326        .map(ToString::to_string)
327        .collect::<Vec<_>>();
328    out.sort();
329    out.dedup();
330    if out.is_empty() {
331        return Err(MetadataParseError::NoRecords("sectorlist"));
332    }
333    Ok(out)
334}
335
336/// 从 xtquant 约定路径自动发现并解析 `sectorlist.DAT`。
337pub fn load_sectorlist_from_standard_paths() -> Result<Vec<String>, MetadataParseError> {
338    parse_sectorlist_dat(resolve_sectorlist_dat()?)
339}
340
341/// 从显式 datadir/root 路径发现并解析 `sectorlist.DAT`。
342pub fn load_sectorlist_from_root(
343    root: impl AsRef<Path>,
344) -> Result<Vec<String>, MetadataParseError> {
345    let roots = vec![root.as_ref().to_path_buf()];
346    parse_sectorlist_dat(resolve_sectorlist_dat_from_roots(&roots)?)
347}
348
349/// 解析 xtquant `sectorWeightData.txt`,返回 `sector -> members`。
350pub fn parse_sector_weight_members(
351    path: impl AsRef<Path>,
352) -> Result<BTreeMap<String, Vec<String>>, MetadataParseError> {
353    let text = fs::read_to_string(path)?;
354    let mut out = BTreeMap::new();
355
356    for line in text.lines() {
357        let line = line.trim();
358        if line.is_empty() {
359            continue;
360        }
361        let Some((sector, entries)) = parse_sector_weight_line(line) else {
362            continue;
363        };
364        let mut stocks = Vec::new();
365        for (stock_code, _weight) in entries {
366            stocks.push(stock_code);
367        }
368        stocks.sort();
369        stocks.dedup();
370        out.insert(sector, stocks);
371    }
372
373    if out.is_empty() {
374        return Err(MetadataParseError::NoRecords("sector weight members"));
375    }
376    Ok(out)
377}
378
379/// 从 xtquant 约定路径自动发现并解析 `sectorWeightData.txt` 成员映射。
380pub fn load_sector_weight_members_from_standard_paths()
381-> Result<BTreeMap<String, Vec<String>>, MetadataParseError> {
382    parse_sector_weight_members(resolve_sector_weight_file()?)
383}
384
385/// 从显式 datadir/root 路径发现并解析 `sectorWeightData.txt` 成员映射。
386pub fn load_sector_weight_members_from_root(
387    root: impl AsRef<Path>,
388) -> Result<BTreeMap<String, Vec<String>>, MetadataParseError> {
389    let roots = vec![root.as_ref().to_path_buf()];
390    parse_sector_weight_members(resolve_sector_weight_file_from_roots(&roots)?)
391}
392
393/// 解析 xtquant `sectorWeightData.txt`,返回指定 index/sector 的 `stock -> weight`。
394pub fn parse_sector_weight_index(
395    path: impl AsRef<Path>,
396    index_code: &str,
397) -> Result<BTreeMap<String, f64>, MetadataParseError> {
398    let text = fs::read_to_string(path)?;
399    let mut out = BTreeMap::new();
400
401    for line in text.lines() {
402        let line = line.trim();
403        if line.is_empty() {
404            continue;
405        }
406        let Some((sector, entries)) = parse_sector_weight_line(line) else {
407            continue;
408        };
409        if !sector.eq_ignore_ascii_case(index_code) {
410            continue;
411        }
412        for (stock_code, weight) in entries {
413            out.insert(stock_code, weight);
414        }
415    }
416
417    if out.is_empty() {
418        return Err(MetadataParseError::NoRecords("sector weight index"));
419    }
420    Ok(out)
421}
422
423/// 从 xtquant 约定路径自动发现并解析 `sectorWeightData.txt` 指定 index/sector 权重映射。
424pub fn load_sector_weight_index_from_standard_paths(
425    index_code: &str,
426) -> Result<BTreeMap<String, f64>, MetadataParseError> {
427    parse_sector_weight_index(resolve_sector_weight_file()?, index_code)
428}
429
430/// 从显式 datadir/root 路径发现并解析指定 index/sector 权重映射。
431pub fn load_sector_weight_index_from_root(
432    root: impl AsRef<Path>,
433    index_code: &str,
434) -> Result<BTreeMap<String, f64>, MetadataParseError> {
435    let roots = vec![root.as_ref().to_path_buf()];
436    parse_sector_weight_index(resolve_sector_weight_file_from_roots(&roots)?, index_code)
437}
438
439fn parse_sector_weight_line(line: &str) -> Option<(String, Vec<(String, f64)>)> {
440    let parts = line
441        .split(';')
442        .map(str::trim)
443        .filter(|part| !part.is_empty())
444        .collect::<Vec<_>>();
445    if parts.len() < 2 {
446        return None;
447    }
448
449    let sector = parts[0].to_ascii_uppercase();
450    let mut entries = Vec::new();
451    let mut i = 1;
452
453    while i < parts.len() {
454        if let Some((stock_code, weight)) = parse_compact_weight_entry(parts[i]) {
455            entries.push((stock_code, weight));
456            i += 1;
457            continue;
458        }
459
460        if i + 1 >= parts.len() {
461            break;
462        }
463
464        let stock_code = parts[i].trim();
465        let Ok(weight) = parts[i + 1].trim().parse::<f64>() else {
466            i += 1;
467            continue;
468        };
469        if !stock_code.is_empty() {
470            entries.push((stock_code.to_ascii_uppercase(), weight));
471        }
472        i += 2;
473    }
474
475    if entries.is_empty() {
476        return None;
477    }
478
479    Some((sector, entries))
480}
481
482fn parse_compact_weight_entry(entry: &str) -> Option<(String, f64)> {
483    let (stock_code, weight) = entry.split_once(',')?;
484    let stock_code = stock_code.trim();
485    let weight = weight.trim().parse::<f64>().ok()?;
486    if stock_code.is_empty() {
487        return None;
488    }
489    Some((stock_code.to_ascii_uppercase(), weight))
490}
491
492/// 解析 xtquant `IndustryData.txt`,返回 `industry -> members`。
493pub fn parse_industry_file(
494    path: impl AsRef<Path>,
495) -> Result<BTreeMap<String, Vec<String>>, MetadataParseError> {
496    let text = fs::read_to_string(path)?;
497    let mut out = BTreeMap::new();
498
499    for line in text.lines() {
500        let line = line.trim();
501        if line.is_empty() {
502            continue;
503        }
504        let parts = line
505            .split(',')
506            .map(str::trim)
507            .filter(|part| !part.is_empty())
508            .collect::<Vec<_>>();
509        if parts.len() < 2 {
510            continue;
511        }
512        let industry = parts[0].to_string();
513        let mut stocks = parts[1..]
514            .iter()
515            .map(|s| s.to_ascii_uppercase())
516            .collect::<Vec<_>>();
517        stocks.sort();
518        stocks.dedup();
519        out.insert(industry, stocks);
520    }
521
522    if out.is_empty() {
523        return Err(MetadataParseError::NoRecords("industry"));
524    }
525    Ok(out)
526}
527
528/// 从 xtquant 约定路径自动发现并解析 `IndustryData.txt`。
529pub fn load_industry_from_standard_paths()
530-> Result<BTreeMap<String, Vec<String>>, MetadataParseError> {
531    parse_industry_file(resolve_industry_file()?)
532}
533
534/// 从显式 datadir/root 路径发现并解析 `IndustryData.txt`。
535pub fn load_industry_from_root(
536    root: impl AsRef<Path>,
537) -> Result<BTreeMap<String, Vec<String>>, MetadataParseError> {
538    let roots = vec![root.as_ref().to_path_buf()];
539    parse_industry_file(resolve_industry_file_from_roots(&roots)?)
540}