Skip to main content

tradingview/fundamental/
entry.rs

1//! Fundamental registry entry representation.
2
3use 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
16/// Custom serde module for `Option<FinancialPeriod>`.
17///
18/// TradingView represents financial reporting periods as strings
19/// (`"FY"`, `"FQ"`, `"FH"`, `"TTM"`, `"NOAGG"`, etc.). Serializing directly
20/// with derived untagged serde on unit variants produces `null`. This helper
21/// ensures lossless string round-trip using [`Display`].
22mod 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/// A single fundamental Pine study metric registered in TradingView's catalog.
56///
57/// Each fundamental indicator corresponds to a built-in Pine Script study
58/// that computes a fundamental financial metric (e.g., Total Revenue, Net Income,
59/// Debt to Equity) for a particular reporting period.
60#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
61pub struct FundamentalRegistryEntry {
62    /// Canonical TradingView metric identifier (e.g., `"total_revenue_fy"`, `"ebitda_ttm"`).
63    pub fund_id: Ustr,
64    /// Pine study identifier part (e.g., `"STD;Total_Revenue_FY"`).
65    pub script_id: Ustr,
66    /// Pine study version string (e.g., `"1.0"` or `"4"`).
67    pub script_version: Ustr,
68    /// Human-readable study title (e.g., `"Total Revenue"`).
69    pub script_name: Ustr,
70    /// Reporting period, if applicable (`FY`, `FQ`, `FH`, `TTM`, `NOAGG`, etc.).
71    #[serde(
72        default,
73        skip_serializing_if = "Option::is_none",
74        with = "period_serde"
75    )]
76    pub financial_period: Option<FinancialPeriod>,
77    /// High-level fundamental accounting category (e.g., `"income_statement"`).
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub fundamental_category: Option<Ustr>,
80    /// Brief textual description of the metric.
81    #[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    /// Attempts to create a [`FundamentalRegistryEntry`] from a raw [`PineInfo`].
134    ///
135    /// Returns `None` if `info.extra.is_fundamental_study` is `false` or
136    /// if `info.extra.fund_id` is missing.
137    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    /// Returns the fundamental metric identifier as a string slice.
158    #[inline]
159    pub fn fund_id(&self) -> &str {
160        self.fund_id.as_str()
161    }
162
163    /// Returns the Pine Script study identifier part as a string slice.
164    #[inline]
165    pub fn script_id(&self) -> &str {
166        self.script_id.as_str()
167    }
168
169    /// Returns the Pine Script study version as a string slice.
170    #[inline]
171    pub fn script_version(&self) -> &str {
172        self.script_version.as_str()
173    }
174
175    /// Returns the human-readable script name as a string slice.
176    #[inline]
177    pub fn script_name(&self) -> &str {
178        self.script_name.as_str()
179    }
180
181    /// Returns the financial period, if present.
182    #[inline]
183    pub fn financial_period(&self) -> Option<&FinancialPeriod> {
184        self.financial_period.as_ref()
185    }
186
187    /// Returns the accounting category, if present.
188    #[inline]
189    pub fn fundamental_category(&self) -> Option<&str> {
190        self.fundamental_category.map(|c| c.as_str())
191    }
192
193    /// Returns the short description, if present.
194    #[inline]
195    pub fn short_description(&self) -> Option<&str> {
196        self.short_description.map(|d| d.as_str())
197    }
198
199    /// Derives the base metric name by stripping known period suffixes from `fund_id`.
200    ///
201    /// For example:
202    /// - `"total_revenue_fy"` -> `"total_revenue"`
203    /// - `"net_income_fq"` -> `"net_income"`
204    /// - `"free_cash_flow_ttm"` -> `"free_cash_flow"`
205    /// - `"shares_outstanding_noagg"` -> `"shares_outstanding"`
206    /// - `"pe_ratio"` -> `"pe_ratio"`
207    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        // Fallback for suffixes without explicit period field:
216        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    /// Converts this entry into a chart [`StudyOptions`] configuration.
228    ///
229    /// Note: Fundamental studies use [`ScriptType::IntervalScript`] on TradingView's
230    /// WebSocket data feed.
231    #[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    /// Asynchronously fetches the full Pine indicator metadata, returning a [`PineIndicator`].
241    ///
242    /// The resulting [`PineIndicator`] can be directly passed into chart study configurations.
243    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}