use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use chrono::NaiveDate;
use crate::day::{
DailyKlineData, parse_daily_file_to_structs, parse_daily_to_structs,
parse_daily_to_structs_in_range,
};
use crate::dividend::DividendDb;
use crate::error::DataDirError;
use crate::finance::{FileType, FinanceReader, FinanceRecord};
use crate::metadata::{
load_holidays_from_root, load_industry_from_root, load_sector_names_from_root,
load_sector_weight_index_from_root, load_sector_weight_members_from_root,
load_sectorlist_from_root,
};
use crate::min::{MinKlineData, parse_min_to_structs};
use crate::tick::{TickData, parse_ticks_to_structs};
#[cfg(feature = "polars")]
use crate::day::{
parse_daily_file_to_dataframe, parse_daily_to_dataframe, parse_daily_to_dataframe_in_range,
};
#[cfg(feature = "polars")]
use crate::min::parse_min_to_dataframe;
#[cfg(feature = "polars")]
use crate::tick::parse_ticks_to_dataframe;
#[cfg(feature = "polars")]
use polars::prelude::DataFrame;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Market {
Sh,
Sz,
Bj,
}
impl Market {
pub fn as_str(self) -> &'static str {
match self {
Self::Sh => "SH",
Self::Sz => "SZ",
Self::Bj => "BJ",
}
}
}
impl TryFrom<&str> for Market {
type Error = DataDirError;
fn try_from(value: &str) -> Result<Self, Self::Error> {
let normalized = value.trim().to_ascii_uppercase();
match normalized.as_str() {
"SH" => Ok(Self::Sh),
"SZ" => Ok(Self::Sz),
"BJ" => Ok(Self::Bj),
_ => Err(DataDirError::InvalidInput(format!(
"unknown market: {value}"
))),
}
}
}
pub fn parse_security_code(value: &str) -> Result<(Market, String), DataDirError> {
let raw = value.trim();
validate_non_empty("security_code", raw)?;
if let Some((symbol, market)) = raw.rsplit_once('.') {
validate_symbol(symbol)?;
return Ok((Market::try_from(market)?, symbol.to_string()));
}
if raw.len() <= 2 {
return Err(DataDirError::InvalidInput(format!(
"unsupported security code: {value}"
)));
}
let (market, symbol) = raw.split_at(2);
validate_symbol(symbol)?;
Ok((Market::try_from(market)?, symbol.to_string()))
}
#[derive(Debug, Clone)]
pub struct QmtDataDir {
root: PathBuf,
}
impl QmtDataDir {
pub fn new(path: impl AsRef<Path>) -> Result<Self, DataDirError> {
let root = path.as_ref().to_path_buf();
if !root.is_dir() {
return Err(DataDirError::InvalidRoot(root));
}
Ok(Self { root })
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn tick_path(
&self,
market: Market,
symbol: &str,
date: &str,
) -> Result<PathBuf, DataDirError> {
validate_symbol(symbol)?;
validate_date(date)?;
first_existing(
"tick file",
vec![
self.root
.join(market.as_str())
.join("0")
.join(symbol)
.join(format!("{date}.dat")),
self.root
.join(market.as_str())
.join("0")
.join(symbol)
.join(format!("{date}.DAT")),
],
)
}
pub fn min_path(&self, market: Market, symbol: &str) -> Result<PathBuf, DataDirError> {
validate_symbol(symbol)?;
first_existing(
"minute file",
vec![
self.root
.join(market.as_str())
.join("60")
.join(format!("{symbol}.dat")),
self.root
.join(market.as_str())
.join("60")
.join(format!("{symbol}.DAT")),
],
)
}
pub fn day_path(&self, market: Market, symbol: &str) -> Result<PathBuf, DataDirError> {
validate_symbol(symbol)?;
first_existing(
"daily file",
vec![
self.root
.join(market.as_str())
.join("86400")
.join(format!("{symbol}.DAT")),
self.root
.join(market.as_str())
.join("86400")
.join(format!("{symbol}.dat")),
],
)
}
pub fn finance_path(&self, symbol: &str, file_type: FileType) -> Result<PathBuf, DataDirError> {
validate_symbol(symbol)?;
let file_id = file_type as u16;
let filename_upper = format!("{symbol}_{file_id}.DAT");
let filename_lower = format!("{symbol}_{file_id}.dat");
first_existing(
"finance file",
vec![
self.root.join("financial").join(&filename_upper),
self.root.join("financial").join(&filename_lower),
self.root.join("finance").join(&filename_upper),
self.root.join("finance").join(&filename_lower),
self.root.join("Finance").join(&filename_upper),
self.root.join("Finance").join(&filename_lower),
],
)
}
pub fn dividend_db_path(&self) -> Result<PathBuf, DataDirError> {
first_existing("dividend db", vec![self.root.join("DividData")])
}
pub fn parse_ticks_to_structs(
&self,
market: Market,
symbol: &str,
date: &str,
) -> Result<Vec<TickData>, DataDirError> {
Ok(parse_ticks_to_structs(
self.tick_path(market, symbol, date)?,
)?)
}
#[cfg(feature = "polars")]
pub fn parse_ticks_to_dataframe(
&self,
market: Market,
symbol: &str,
date: &str,
) -> Result<DataFrame, DataDirError> {
Ok(parse_ticks_to_dataframe(
self.tick_path(market, symbol, date)?,
)?)
}
pub fn parse_min_to_structs(
&self,
market: Market,
symbol: &str,
) -> Result<Vec<MinKlineData>, DataDirError> {
Ok(parse_min_to_structs(self.min_path(market, symbol)?)?)
}
#[cfg(feature = "polars")]
pub fn parse_min_to_dataframe(
&self,
market: Market,
symbol: &str,
) -> Result<DataFrame, DataDirError> {
Ok(parse_min_to_dataframe(self.min_path(market, symbol)?)?)
}
pub fn parse_daily_file_to_structs(
&self,
market: Market,
symbol: &str,
) -> Result<Vec<DailyKlineData>, DataDirError> {
Ok(parse_daily_file_to_structs(self.day_path(market, symbol)?)?)
}
pub fn parse_daily_to_structs(
&self,
market: Market,
symbol: &str,
start: &str,
end: &str,
) -> Result<Vec<DailyKlineData>, DataDirError> {
Ok(parse_daily_to_structs(
self.day_path(market, symbol)?,
start,
end,
)?)
}
pub fn parse_daily_to_structs_in_range(
&self,
market: Market,
symbol: &str,
start: Option<NaiveDate>,
end: Option<NaiveDate>,
) -> Result<Vec<DailyKlineData>, DataDirError> {
Ok(parse_daily_to_structs_in_range(
self.day_path(market, symbol)?,
start,
end,
)?)
}
#[cfg(feature = "polars")]
pub fn parse_daily_file_to_dataframe(
&self,
market: Market,
symbol: &str,
) -> Result<DataFrame, DataDirError> {
Ok(parse_daily_file_to_dataframe(
self.day_path(market, symbol)?,
)?)
}
#[cfg(feature = "polars")]
pub fn parse_daily_to_dataframe(
&self,
market: Market,
symbol: &str,
start: &str,
end: &str,
) -> Result<DataFrame, DataDirError> {
Ok(parse_daily_to_dataframe(
self.day_path(market, symbol)?,
start,
end,
)?)
}
#[cfg(feature = "polars")]
pub fn parse_daily_to_dataframe_in_range(
&self,
market: Market,
symbol: &str,
start: Option<NaiveDate>,
end: Option<NaiveDate>,
) -> Result<DataFrame, DataDirError> {
Ok(parse_daily_to_dataframe_in_range(
self.day_path(market, symbol)?,
start,
end,
)?)
}
pub fn read_finance(
&self,
symbol: &str,
file_type: FileType,
) -> Result<Vec<FinanceRecord>, DataDirError> {
Ok(FinanceReader::read_file(
self.finance_path(symbol, file_type)?,
)?)
}
pub fn open_dividend_db(&self) -> Result<DividendDb, DataDirError> {
Ok(DividendDb::new(self.dividend_db_path()?)?)
}
pub fn load_holidays(&self) -> Result<Vec<i64>, DataDirError> {
Ok(load_holidays_from_root(&self.root)?)
}
pub fn load_sector_names(&self) -> Result<Vec<String>, DataDirError> {
Ok(load_sector_names_from_root(&self.root)?)
}
pub fn load_sectorlist(&self) -> Result<Vec<String>, DataDirError> {
Ok(load_sectorlist_from_root(&self.root)?)
}
pub fn load_sector_weight_members(
&self,
) -> Result<BTreeMap<String, Vec<String>>, DataDirError> {
Ok(load_sector_weight_members_from_root(&self.root)?)
}
pub fn load_sector_weight_index(
&self,
index_code: &str,
) -> Result<BTreeMap<String, f64>, DataDirError> {
validate_non_empty("index_code", index_code)?;
Ok(load_sector_weight_index_from_root(&self.root, index_code)?)
}
pub fn load_industry(&self) -> Result<BTreeMap<String, Vec<String>>, DataDirError> {
Ok(load_industry_from_root(&self.root)?)
}
}
fn validate_symbol(symbol: &str) -> Result<(), DataDirError> {
validate_non_empty("symbol", symbol)
}
fn validate_date(date: &str) -> Result<(), DataDirError> {
validate_non_empty("date", date)?;
if date.len() != 8 || !date.chars().all(|ch| ch.is_ascii_digit()) {
return Err(DataDirError::InvalidInput(format!(
"date must be YYYYMMDD, got {date}"
)));
}
Ok(())
}
fn validate_non_empty(field: &str, value: &str) -> Result<(), DataDirError> {
if value.trim().is_empty() {
return Err(DataDirError::InvalidInput(format!(
"{field} cannot be empty"
)));
}
Ok(())
}
fn first_existing(kind: &'static str, tried: Vec<PathBuf>) -> Result<PathBuf, DataDirError> {
for path in &tried {
if path.exists() {
return Ok(path.clone());
}
}
Err(DataDirError::PathNotFound { kind, tried })
}