1use serde::{Deserialize, Deserializer, Serialize, Serializer};
4use std::fmt::Display;
5use ustr::Ustr;
6
7use crate::{
8 Result,
9 chart::StudyOptions,
10 models::{
11 FinancialPeriod, UserCookies,
12 pine_indicator::{PineIndicator, PineInfo, ScriptType},
13 },
14};
15
16mod period_serde {
23 use super::*;
24
25 pub fn serialize<S>(
26 period: &Option<FinancialPeriod>,
27 serializer: S,
28 ) -> std::result::Result<S::Ok, S::Error>
29 where
30 S: Serializer,
31 {
32 match period {
33 Some(p) => serializer.serialize_some(&p.to_string()),
34 None => serializer.serialize_none(),
35 }
36 }
37
38 pub fn deserialize<'de, D>(
39 deserializer: D,
40 ) -> std::result::Result<Option<FinancialPeriod>, D::Error>
41 where
42 D: Deserializer<'de>,
43 {
44 let opt: Option<String> = Option::deserialize(deserializer)?;
45 Ok(opt.map(|s| match s.as_str() {
46 "FY" => FinancialPeriod::FiscalYear,
47 "FQ" => FinancialPeriod::FiscalQuarter,
48 "FH" => FinancialPeriod::FiscalHalfYear,
49 "TTM" => FinancialPeriod::TrailingTwelveMonths,
50 _ => FinancialPeriod::UnknownPeriod(s),
51 }))
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct FundamentalRegistryEntry {
62 pub fund_id: Ustr,
64 pub script_id: Ustr,
66 pub script_version: Ustr,
68 pub script_name: Ustr,
70 #[serde(
72 default,
73 skip_serializing_if = "Option::is_none",
74 with = "period_serde"
75 )]
76 pub financial_period: Option<FinancialPeriod>,
77 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub fundamental_category: Option<Ustr>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub short_description: Option<Ustr>,
83}
84
85fn cmp_period(a: Option<&FinancialPeriod>, b: Option<&FinancialPeriod>) -> std::cmp::Ordering {
86 match (a, b) {
87 (None, None) => std::cmp::Ordering::Equal,
88 (None, Some(_)) => std::cmp::Ordering::Less,
89 (Some(_), None) => std::cmp::Ordering::Greater,
90 (Some(pa), Some(pb)) => pa.to_string().cmp(&pb.to_string()),
91 }
92}
93
94impl Eq for FundamentalRegistryEntry {}
95
96impl Ord for FundamentalRegistryEntry {
97 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
98 self.fund_id
99 .as_str()
100 .cmp(other.fund_id.as_str())
101 .then_with(|| {
102 cmp_period(
103 self.financial_period.as_ref(),
104 other.financial_period.as_ref(),
105 )
106 })
107 .then_with(|| self.script_id.as_str().cmp(other.script_id.as_str()))
108 .then_with(|| {
109 self.script_version
110 .as_str()
111 .cmp(other.script_version.as_str())
112 })
113 }
114}
115
116impl PartialOrd for FundamentalRegistryEntry {
117 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
118 Some(self.cmp(other))
119 }
120}
121
122impl Display for FundamentalRegistryEntry {
123 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
124 write!(f, "{} ({})", self.script_name, self.fund_id)?;
125 if let Some(period) = &self.financial_period {
126 write!(f, " [{period}]")?;
127 }
128 write!(f, " v{}", self.script_version)
129 }
130}
131
132impl FundamentalRegistryEntry {
133 pub fn from_pine_info(info: PineInfo) -> Option<Self> {
138 if !info.extra.is_fundamental_study {
139 return None;
140 }
141 let fund_id = info.extra.fund_id?;
142 Some(Self {
143 fund_id,
144 script_id: info.script_id,
145 script_version: info.script_version,
146 script_name: info.script_name,
147 financial_period: info.extra.financial_period,
148 fundamental_category: info.extra.fundamental_category,
149 short_description: if info.extra.short_description.as_str().trim().is_empty() {
150 None
151 } else {
152 Some(info.extra.short_description)
153 },
154 })
155 }
156
157 #[inline]
159 pub fn fund_id(&self) -> &str {
160 self.fund_id.as_str()
161 }
162
163 #[inline]
165 pub fn script_id(&self) -> &str {
166 self.script_id.as_str()
167 }
168
169 #[inline]
171 pub fn script_version(&self) -> &str {
172 self.script_version.as_str()
173 }
174
175 #[inline]
177 pub fn script_name(&self) -> &str {
178 self.script_name.as_str()
179 }
180
181 #[inline]
183 pub fn financial_period(&self) -> Option<&FinancialPeriod> {
184 self.financial_period.as_ref()
185 }
186
187 #[inline]
189 pub fn fundamental_category(&self) -> Option<&str> {
190 self.fundamental_category.map(|c| c.as_str())
191 }
192
193 #[inline]
195 pub fn short_description(&self) -> Option<&str> {
196 self.short_description.map(|d| d.as_str())
197 }
198
199 pub fn base_metric(&self) -> &str {
208 let fid = self.fund_id.as_str();
209 if let Some(period) = &self.financial_period {
210 let p_suffix = format!("_{}", period.to_string().to_lowercase());
211 if let Some(stripped) = fid.strip_suffix(&p_suffix) {
212 return stripped;
213 }
214 }
215 for suffix in &[
217 "_fy", "_fq", "_fh", "_ttm", "_noagg", "_nfq", "_nfy", "_nfh", "_n4fy", "_n4fq",
218 "_n4fh", "_ntm", "_agg",
219 ] {
220 if let Some(stripped) = fid.strip_suffix(suffix) {
221 return stripped;
222 }
223 }
224 fid
225 }
226
227 #[inline]
232 pub fn to_study_options(&self) -> StudyOptions {
233 StudyOptions {
234 script_id: self.script_id,
235 script_version: self.script_version,
236 script_type: ScriptType::IntervalScript,
237 }
238 }
239
240 pub async fn fetch_indicator(&self, user: Option<&UserCookies>) -> Result<PineIndicator> {
244 let mut builder = PineIndicator::build();
245 if let Some(cookies) = user {
246 builder.user(cookies.clone());
247 }
248 builder
249 .fetch(
250 self.script_id.as_str(),
251 self.script_version.as_str(),
252 ScriptType::IntervalScript,
253 )
254 .await
255 }
256}
257
258impl TryFrom<PineInfo> for FundamentalRegistryEntry {
259 type Error = crate::Error;
260
261 fn try_from(info: PineInfo) -> Result<Self> {
262 Self::from_pine_info(info).ok_or_else(|| {
263 crate::Error::Internal(
264 "not a fundamental study (extra.is_fundamental_study is false or missing fund_id)"
265 .into(),
266 )
267 })
268 }
269}