use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt::Display;
use ustr::Ustr;
use crate::{
Result,
chart::StudyOptions,
models::{
FinancialPeriod, UserCookies,
pine_indicator::{PineIndicator, PineInfo, ScriptType},
},
};
mod period_serde {
use super::*;
pub fn serialize<S>(
period: &Option<FinancialPeriod>,
serializer: S,
) -> std::result::Result<S::Ok, S::Error>
where
S: Serializer,
{
match period {
Some(p) => serializer.serialize_some(&p.to_string()),
None => serializer.serialize_none(),
}
}
pub fn deserialize<'de, D>(
deserializer: D,
) -> std::result::Result<Option<FinancialPeriod>, D::Error>
where
D: Deserializer<'de>,
{
let opt: Option<String> = Option::deserialize(deserializer)?;
Ok(opt.map(|s| match s.as_str() {
"FY" => FinancialPeriod::FiscalYear,
"FQ" => FinancialPeriod::FiscalQuarter,
"FH" => FinancialPeriod::FiscalHalfYear,
"TTM" => FinancialPeriod::TrailingTwelveMonths,
_ => FinancialPeriod::UnknownPeriod(s),
}))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct FundamentalRegistryEntry {
pub fund_id: Ustr,
pub script_id: Ustr,
pub script_version: Ustr,
pub script_name: Ustr,
#[serde(
default,
skip_serializing_if = "Option::is_none",
with = "period_serde"
)]
pub financial_period: Option<FinancialPeriod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub fundamental_category: Option<Ustr>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub short_description: Option<Ustr>,
}
fn cmp_period(a: Option<&FinancialPeriod>, b: Option<&FinancialPeriod>) -> std::cmp::Ordering {
match (a, b) {
(None, None) => std::cmp::Ordering::Equal,
(None, Some(_)) => std::cmp::Ordering::Less,
(Some(_), None) => std::cmp::Ordering::Greater,
(Some(pa), Some(pb)) => pa.to_string().cmp(&pb.to_string()),
}
}
impl Eq for FundamentalRegistryEntry {}
impl Ord for FundamentalRegistryEntry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.fund_id
.as_str()
.cmp(other.fund_id.as_str())
.then_with(|| {
cmp_period(
self.financial_period.as_ref(),
other.financial_period.as_ref(),
)
})
.then_with(|| self.script_id.as_str().cmp(other.script_id.as_str()))
.then_with(|| {
self.script_version
.as_str()
.cmp(other.script_version.as_str())
})
}
}
impl PartialOrd for FundamentalRegistryEntry {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Display for FundamentalRegistryEntry {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} ({})", self.script_name, self.fund_id)?;
if let Some(period) = &self.financial_period {
write!(f, " [{period}]")?;
}
write!(f, " v{}", self.script_version)
}
}
impl FundamentalRegistryEntry {
pub fn from_pine_info(info: PineInfo) -> Option<Self> {
if !info.extra.is_fundamental_study {
return None;
}
let fund_id = info.extra.fund_id?;
Some(Self {
fund_id,
script_id: info.script_id,
script_version: info.script_version,
script_name: info.script_name,
financial_period: info.extra.financial_period,
fundamental_category: info.extra.fundamental_category,
short_description: if info.extra.short_description.as_str().trim().is_empty() {
None
} else {
Some(info.extra.short_description)
},
})
}
#[inline]
pub fn fund_id(&self) -> &str {
self.fund_id.as_str()
}
#[inline]
pub fn script_id(&self) -> &str {
self.script_id.as_str()
}
#[inline]
pub fn script_version(&self) -> &str {
self.script_version.as_str()
}
#[inline]
pub fn script_name(&self) -> &str {
self.script_name.as_str()
}
#[inline]
pub fn financial_period(&self) -> Option<&FinancialPeriod> {
self.financial_period.as_ref()
}
#[inline]
pub fn fundamental_category(&self) -> Option<&str> {
self.fundamental_category.map(|c| c.as_str())
}
#[inline]
pub fn short_description(&self) -> Option<&str> {
self.short_description.map(|d| d.as_str())
}
pub fn base_metric(&self) -> &str {
let fid = self.fund_id.as_str();
if let Some(period) = &self.financial_period {
let p_suffix = format!("_{}", period.to_string().to_lowercase());
if let Some(stripped) = fid.strip_suffix(&p_suffix) {
return stripped;
}
}
for suffix in &[
"_fy", "_fq", "_fh", "_ttm", "_noagg", "_nfq", "_nfy", "_nfh", "_n4fy", "_n4fq",
"_n4fh", "_ntm", "_agg",
] {
if let Some(stripped) = fid.strip_suffix(suffix) {
return stripped;
}
}
fid
}
#[inline]
pub fn to_study_options(&self) -> StudyOptions {
StudyOptions {
script_id: self.script_id,
script_version: self.script_version,
script_type: ScriptType::IntervalScript,
}
}
pub async fn fetch_indicator(&self, user: Option<&UserCookies>) -> Result<PineIndicator> {
let mut builder = PineIndicator::build();
if let Some(cookies) = user {
builder.user(cookies.clone());
}
builder
.fetch(
self.script_id.as_str(),
self.script_version.as_str(),
ScriptType::IntervalScript,
)
.await
}
}
impl TryFrom<PineInfo> for FundamentalRegistryEntry {
type Error = crate::Error;
fn try_from(info: PineInfo) -> Result<Self> {
Self::from_pine_info(info).ok_or_else(|| {
crate::Error::Internal(
"not a fundamental study (extra.is_fundamental_study is false or missing fund_id)"
.into(),
)
})
}
}