use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::{
Result,
fundamental::{entry::FundamentalRegistryEntry, filter::FundamentalRegistryFilter},
models::{FinancialPeriod, pine_indicator::PineInfo},
};
pub(crate) fn extract_base_metric<'a>(
fund_id: &'a str,
period: Option<&FinancialPeriod>,
) -> &'a str {
if let Some(p) = period {
let suffix = format!("_{}", p.to_string().to_lowercase());
if let Some(stripped) = fund_id.strip_suffix(&suffix) {
return stripped;
}
}
for suffix in &[
"_fy", "_fq", "_fh", "_ttm", "_noagg", "_nfq", "_nfy", "_nfh", "_n4fy", "_n4fq", "_n4fh",
"_ntm", "_agg",
] {
if let Some(stripped) = fund_id.strip_suffix(suffix) {
return stripped;
}
}
fund_id
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(from = "RawFundamentalRegistry")]
pub struct FundamentalRegistry {
pub date: NaiveDate,
pub version: u32,
pub entries: Vec<FundamentalRegistryEntry>,
}
#[derive(Deserialize)]
struct RawFundamentalRegistry {
date: NaiveDate,
#[serde(default = "default_registry_version")]
version: u32,
entries: Vec<FundamentalRegistryEntry>,
}
fn default_registry_version() -> u32 {
1
}
impl From<RawFundamentalRegistry> for FundamentalRegistry {
fn from(raw: RawFundamentalRegistry) -> Self {
let mut reg = Self::new(raw.date, raw.entries);
reg.version = raw.version;
reg
}
}
impl Default for FundamentalRegistry {
fn default() -> Self {
Self::new(NaiveDate::from_ymd_opt(1970, 1, 1).unwrap(), Vec::new())
}
}
impl FundamentalRegistry {
pub fn new(date: NaiveDate, mut entries: Vec<FundamentalRegistryEntry>) -> Self {
entries.sort();
entries.dedup();
Self {
date,
version: 1,
entries,
}
}
pub fn from_pine_infos(date: NaiveDate, infos: impl IntoIterator<Item = PineInfo>) -> Self {
let entries: Vec<FundamentalRegistryEntry> = infos
.into_iter()
.filter(|info| info.extra.is_fundamental_study)
.filter_map(FundamentalRegistryEntry::from_pine_info)
.collect();
Self::new(date, entries)
}
#[inline]
pub fn len(&self) -> usize {
self.entries.len()
}
#[inline]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[inline]
pub fn date(&self) -> NaiveDate {
self.date
}
#[inline]
pub fn version(&self) -> u32 {
self.version
}
#[inline]
pub fn version_tag(&self) -> String {
format!("fundamentals-{}", self.date)
}
#[inline]
pub fn entries(&self) -> &[FundamentalRegistryEntry] {
&self.entries
}
#[inline]
pub fn iter(&self) -> std::slice::Iter<'_, FundamentalRegistryEntry> {
self.entries.iter()
}
pub fn get(&self, fund_id: &str) -> Option<&FundamentalRegistryEntry> {
let idx = self
.entries
.binary_search_by(|e| e.fund_id.as_str().cmp(fund_id))
.ok()?;
self.entries.get(idx)
}
pub fn lookup(
&self,
fund_id: &str,
period: Option<&FinancialPeriod>,
) -> Option<&FundamentalRegistryEntry> {
match period {
None => {
if let Some(entry) = self.get(fund_id) {
return Some(entry);
}
let mut first = None;
let mut ttm = None;
for v in self.iter_variants(fund_id) {
if let Some(FinancialPeriod::FiscalYear) = v.financial_period {
return Some(v);
}
if let Some(FinancialPeriod::TrailingTwelveMonths) = v.financial_period {
ttm = Some(v);
}
if first.is_none() {
first = Some(v);
}
}
ttm.or(first)
}
Some(target_period) => {
if let Some(entry) = self.get(fund_id)
&& entry.financial_period.as_ref() == Some(target_period)
{
return Some(entry);
}
let p_str = target_period.to_string().to_lowercase();
let candidate_id = format!("{fund_id}_{p_str}");
if let Some(entry) = self.get(&candidate_id) {
return Some(entry);
}
let base_str = extract_base_metric(fund_id, Some(target_period));
if base_str != fund_id {
let base_candidate = format!("{base_str}_{p_str}");
if let Some(entry) = self.get(&base_candidate) {
return Some(entry);
}
}
for v in self.iter_variants(base_str) {
if v.financial_period.as_ref() == Some(target_period) {
return Some(v);
}
}
None
}
}
}
#[inline]
pub fn get_with_period(
&self,
base_metric: &str,
period: &FinancialPeriod,
) -> Option<&FundamentalRegistryEntry> {
self.lookup(base_metric, Some(period))
}
pub fn iter_variants<'a>(
&'a self,
metric: &str,
) -> impl Iterator<Item = &'a FundamentalRegistryEntry> {
let base_str = extract_base_metric(metric, None);
let prefix = format!("{base_str}_");
let start = self
.entries
.partition_point(|e| e.fund_id.as_str() < prefix.as_str());
let count = self.entries[start..]
.iter()
.take_while(|e| e.fund_id.as_str().starts_with(&prefix))
.count();
let prefix_slice = &self.entries[start..start + count];
let exact = self
.get(base_str)
.filter(|e| !prefix_slice.iter().any(|p| p.fund_id == e.fund_id));
prefix_slice.iter().chain(exact)
}
#[inline]
pub fn get_variants(&self, metric: &str) -> Vec<&FundamentalRegistryEntry> {
self.iter_variants(metric).collect()
}
pub fn filter(&self, filter: &FundamentalRegistryFilter) -> Vec<&FundamentalRegistryEntry> {
self.entries.iter().filter(|e| filter.matches(e)).collect()
}
pub fn search(&self, query: &str) -> Vec<&FundamentalRegistryEntry> {
let filter = FundamentalRegistryFilter::builder()
.query(query.to_string())
.build();
self.filter(&filter)
}
pub fn find_by_category(&self, category: &str) -> Vec<&FundamentalRegistryEntry> {
let filter = FundamentalRegistryFilter::builder()
.category(category.to_string())
.build();
self.filter(&filter)
}
pub fn to_json(&self) -> Result<String> {
serde_json::to_string(self).map_err(|e| crate::Error::JsonParse(e.to_string().into()))
}
pub fn to_json_pretty(&self) -> Result<String> {
serde_json::to_string_pretty(self)
.map_err(|e| crate::Error::JsonParse(e.to_string().into()))
}
pub fn from_json(json: &str) -> Result<Self> {
serde_json::from_str(json).map_err(|e| crate::Error::JsonParse(e.to_string().into()))
}
pub fn save_to_file<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let path_ref = path.as_ref();
if let Some(parent) = path_ref.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
let json = self.to_json_pretty()?;
std::fs::write(path_ref, json)?;
Ok(())
}
pub fn load_from_file<P: AsRef<Path>>(path: P) -> Result<Self> {
let path = path.as_ref();
let content = std::fs::read_to_string(path)?;
Self::from_json(&content)
}
pub async fn save_to_file_async<P: AsRef<Path>>(&self, path: P) -> Result<()> {
let json = self.to_json_pretty()?;
let path_buf = path.as_ref().to_path_buf();
tokio::task::spawn_blocking(move || {
if let Some(parent) = path_buf.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent)?;
}
std::fs::write(&path_buf, json)?;
Ok(())
})
.await
.map_err(|e| crate::Error::TokioJoin(e.to_string().into()))?
}
pub async fn load_from_file_async<P: AsRef<Path>>(path: P) -> Result<Self> {
let path_buf = path.as_ref().to_path_buf();
tokio::task::spawn_blocking(move || Self::load_from_file(path_buf))
.await
.map_err(|e| crate::Error::TokioJoin(e.to_string().into()))?
}
}
impl<'a> IntoIterator for &'a FundamentalRegistry {
type Item = &'a FundamentalRegistryEntry;
type IntoIter = std::slice::Iter<'a, FundamentalRegistryEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.iter()
}
}
impl IntoIterator for FundamentalRegistry {
type Item = FundamentalRegistryEntry;
type IntoIter = std::vec::IntoIter<FundamentalRegistryEntry>;
fn into_iter(self) -> Self::IntoIter {
self.entries.into_iter()
}
}