Skip to main content

tvdata_rs/equity/
history.rs

1use crate::market_data::InstrumentIdentity;
2use crate::metadata::{DataLineage, DataSourceKind, HistoryKind};
3use crate::scanner::Column;
4use crate::scanner::fields::{analyst, fundamentals};
5use crate::time_series::{FiscalPeriod, HistoricalObservation};
6use crate::transport::quote_session::QuoteFieldValues;
7use time::OffsetDateTime;
8
9#[derive(Debug, Clone, PartialEq, Default)]
10pub struct EstimateMetrics {
11    pub revenue_forecast: Option<f64>,
12    pub revenue_actual: Option<f64>,
13    pub eps_forecast: Option<f64>,
14    pub eps_actual: Option<f64>,
15}
16
17pub type EstimateObservation = HistoricalObservation<EstimateMetrics>;
18pub type EarningsMetrics = EstimateMetrics;
19
20#[derive(Debug, Clone, PartialEq)]
21pub struct EstimateHistory {
22    pub instrument: InstrumentIdentity,
23    pub quarterly: Vec<EstimateObservation>,
24    pub annual: Vec<EstimateObservation>,
25    pub lineage: DataLineage,
26}
27
28#[derive(Debug, Clone, PartialEq, Default)]
29pub struct FundamentalMetrics {
30    pub total_revenue: Option<f64>,
31    pub net_income: Option<f64>,
32    pub total_assets: Option<f64>,
33    pub total_liabilities: Option<f64>,
34    pub cash_from_operations: Option<f64>,
35}
36
37pub type FundamentalObservation = HistoricalObservation<FundamentalMetrics>;
38
39#[derive(Debug, Clone, PartialEq)]
40pub struct PointInTimeFundamentals {
41    pub instrument: InstrumentIdentity,
42    pub quarterly: Vec<FundamentalObservation>,
43    pub annual: Vec<FundamentalObservation>,
44    pub lineage: DataLineage,
45}
46
47pub(crate) fn decode_estimate_history(
48    instrument: InstrumentIdentity,
49    values: &QuoteFieldValues,
50) -> EstimateHistory {
51    let quarterly = build_estimate_observations(
52        values.string_series(analyst::EARNINGS_FISCAL_PERIOD_FQ_H.as_str()),
53        values.timestamp_series(analyst::EARNINGS_RELEASE_DATE_FQ_H.as_str()),
54        values.number_series(analyst::REVENUE_FORECAST_FQ_H.as_str()),
55        Vec::new(),
56        values.number_series(analyst::EPS_FORECAST_FQ_H.as_str()),
57        values.number_series(analyst::EPS_ACTUAL_FQ_H.as_str()),
58    );
59    let annual = build_estimate_observations(
60        values.string_series(analyst::EARNINGS_FISCAL_PERIOD_FY_H.as_str()),
61        values.timestamp_series(analyst::EARNINGS_RELEASE_DATE_FY_H.as_str()),
62        values.number_series(analyst::REVENUE_FORECAST_FY_H.as_str()),
63        Vec::new(),
64        values.number_series(analyst::EPS_FORECAST_FY_H.as_str()),
65        values.number_series(analyst::EPS_ACTUAL_FY_H.as_str()),
66    );
67
68    EstimateHistory {
69        instrument,
70        quarterly,
71        annual,
72        lineage: native_history_lineage([
73            latest_release(values, analyst::EARNINGS_RELEASE_DATE_FQ_H.as_str()),
74            latest_release(values, analyst::EARNINGS_RELEASE_DATE_FY_H.as_str()),
75        ]),
76    }
77}
78
79pub(crate) fn decode_point_in_time_fundamentals(
80    instrument: InstrumentIdentity,
81    values: &QuoteFieldValues,
82) -> PointInTimeFundamentals {
83    let quarterly = build_fundamental_observations(
84        values.string_series(fundamentals::FISCAL_PERIOD_FQ_H.as_str()),
85        values.timestamp_series(analyst::EARNINGS_RELEASE_DATE_FQ_H.as_str()),
86        values.number_series(fundamentals::TOTAL_REVENUE_FQ_H.as_str()),
87        values.number_series(fundamentals::NET_INCOME_FQ_H.as_str()),
88        values.number_series(fundamentals::TOTAL_ASSETS_FQ_H.as_str()),
89        values.number_series(fundamentals::TOTAL_LIABILITIES_FQ_H.as_str()),
90        values.number_series(fundamentals::CASH_FROM_OPERATIONS_FQ_H.as_str()),
91    );
92    let annual = build_fundamental_observations(
93        values.string_series(fundamentals::FISCAL_PERIOD_FY_H.as_str()),
94        values.timestamp_series(analyst::EARNINGS_RELEASE_DATE_FY_H.as_str()),
95        values.number_series(fundamentals::TOTAL_REVENUE_FY_H.as_str()),
96        values.number_series(fundamentals::NET_INCOME_FY_H.as_str()),
97        values.number_series(fundamentals::TOTAL_ASSETS_FY_H.as_str()),
98        values.number_series(fundamentals::TOTAL_LIABILITIES_FY_H.as_str()),
99        values.number_series(fundamentals::CASH_FROM_OPERATIONS_FY_H.as_str()),
100    );
101
102    PointInTimeFundamentals {
103        instrument,
104        quarterly,
105        annual,
106        lineage: native_history_lineage([
107            latest_release(values, analyst::EARNINGS_RELEASE_DATE_FQ_H.as_str()),
108            latest_release(values, analyst::EARNINGS_RELEASE_DATE_FY_H.as_str()),
109        ]),
110    }
111}
112
113pub(crate) fn estimate_history_fields() -> Vec<Column> {
114    vec![
115        analyst::REVENUE_FORECAST_FQ_H,
116        analyst::REVENUE_FORECAST_FY_H,
117        analyst::EPS_FORECAST_FQ_H,
118        analyst::EPS_FORECAST_FY_H,
119        analyst::EPS_ACTUAL_FQ_H,
120        analyst::EPS_ACTUAL_FY_H,
121        analyst::EARNINGS_RELEASE_DATE_FQ_H,
122        analyst::EARNINGS_RELEASE_DATE_FY_H,
123        analyst::EARNINGS_FISCAL_PERIOD_FQ_H,
124        analyst::EARNINGS_FISCAL_PERIOD_FY_H,
125    ]
126}
127
128pub(crate) fn fundamentals_history_fields() -> Vec<Column> {
129    vec![
130        fundamentals::TOTAL_REVENUE_FQ_H,
131        fundamentals::TOTAL_REVENUE_FY_H,
132        fundamentals::NET_INCOME_FQ_H,
133        fundamentals::NET_INCOME_FY_H,
134        fundamentals::TOTAL_ASSETS_FQ_H,
135        fundamentals::TOTAL_ASSETS_FY_H,
136        fundamentals::TOTAL_LIABILITIES_FQ_H,
137        fundamentals::TOTAL_LIABILITIES_FY_H,
138        fundamentals::CASH_FROM_OPERATIONS_FQ_H,
139        fundamentals::CASH_FROM_OPERATIONS_FY_H,
140        fundamentals::FISCAL_PERIOD_FQ_H,
141        fundamentals::FISCAL_PERIOD_FY_H,
142        analyst::EARNINGS_RELEASE_DATE_FQ_H,
143        analyst::EARNINGS_RELEASE_DATE_FY_H,
144    ]
145}
146
147fn build_estimate_observations(
148    fiscal_periods: Vec<Option<String>>,
149    release_dates: Vec<Option<OffsetDateTime>>,
150    revenue_forecasts: Vec<Option<f64>>,
151    revenue_actuals: Vec<Option<f64>>,
152    eps_forecasts: Vec<Option<f64>>,
153    eps_actuals: Vec<Option<f64>>,
154) -> Vec<EstimateObservation> {
155    let len = [
156        fiscal_periods.len(),
157        release_dates.len(),
158        revenue_forecasts.len(),
159        revenue_actuals.len(),
160        eps_forecasts.len(),
161        eps_actuals.len(),
162    ]
163    .into_iter()
164    .max()
165    .unwrap_or(0);
166    let fiscal_periods = parse_fiscal_period_series(fiscal_periods);
167
168    (0..len)
169        .map(|index| {
170            let value = EstimateMetrics {
171                revenue_forecast: series_value(&revenue_forecasts, index),
172                revenue_actual: series_value(&revenue_actuals, index),
173                eps_forecast: series_value(&eps_forecasts, index),
174                eps_actual: series_value(&eps_actuals, index),
175            };
176
177            EstimateObservation::new(
178                series_value(&fiscal_periods, index),
179                series_value(&release_dates, index),
180                value,
181            )
182        })
183        .filter(|observation| {
184            observation.fiscal_period.is_some()
185                || observation.release_at.is_some()
186                || observation.value.revenue_forecast.is_some()
187                || observation.value.revenue_actual.is_some()
188                || observation.value.eps_forecast.is_some()
189                || observation.value.eps_actual.is_some()
190        })
191        .collect()
192}
193
194fn build_fundamental_observations(
195    fiscal_periods: Vec<Option<String>>,
196    release_dates: Vec<Option<OffsetDateTime>>,
197    total_revenue: Vec<Option<f64>>,
198    net_income: Vec<Option<f64>>,
199    total_assets: Vec<Option<f64>>,
200    total_liabilities: Vec<Option<f64>>,
201    cash_from_operations: Vec<Option<f64>>,
202) -> Vec<FundamentalObservation> {
203    let len = [
204        fiscal_periods.len(),
205        release_dates.len(),
206        total_revenue.len(),
207        net_income.len(),
208        total_assets.len(),
209        total_liabilities.len(),
210        cash_from_operations.len(),
211    ]
212    .into_iter()
213    .max()
214    .unwrap_or(0);
215    let fiscal_periods = parse_fiscal_period_series(fiscal_periods);
216
217    (0..len)
218        .map(|index| {
219            let value = FundamentalMetrics {
220                total_revenue: series_value(&total_revenue, index),
221                net_income: series_value(&net_income, index),
222                total_assets: series_value(&total_assets, index),
223                total_liabilities: series_value(&total_liabilities, index),
224                cash_from_operations: series_value(&cash_from_operations, index),
225            };
226
227            FundamentalObservation::new(
228                series_value(&fiscal_periods, index),
229                series_value(&release_dates, index),
230                value,
231            )
232        })
233        .filter(|observation| {
234            observation.fiscal_period.is_some()
235                || observation.release_at.is_some()
236                || observation.value.total_revenue.is_some()
237                || observation.value.net_income.is_some()
238                || observation.value.total_assets.is_some()
239                || observation.value.total_liabilities.is_some()
240                || observation.value.cash_from_operations.is_some()
241        })
242        .collect()
243}
244
245fn latest_release(values: &QuoteFieldValues, field: &str) -> Option<OffsetDateTime> {
246    values.timestamp_series(field).into_iter().flatten().next()
247}
248
249fn native_history_lineage<const N: usize>(
250    effective_candidates: [Option<OffsetDateTime>; N],
251) -> DataLineage {
252    DataLineage::new(
253        DataSourceKind::Composed,
254        HistoryKind::Native,
255        OffsetDateTime::now_utc(),
256        effective_candidates.into_iter().flatten().max(),
257    )
258}
259
260fn series_value<T: Clone>(series: &[Option<T>], index: usize) -> Option<T> {
261    series.get(index).cloned().flatten()
262}
263
264fn parse_fiscal_period_series(series: Vec<Option<String>>) -> Vec<Option<FiscalPeriod>> {
265    series
266        .into_iter()
267        .map(|value| value.map(FiscalPeriod::parse))
268        .collect()
269}
270
271#[cfg(test)]
272mod tests {
273    use std::collections::BTreeMap;
274
275    use serde_json::json;
276
277    use super::*;
278    use crate::scanner::Ticker;
279
280    fn instrument() -> InstrumentIdentity {
281        InstrumentIdentity {
282            ticker: Ticker::new("NASDAQ:AAPL"),
283            name: Some("Apple".to_owned()),
284            market: Some("america".to_owned()),
285            exchange: Some("NASDAQ".to_owned()),
286            currency: Some("USD".to_owned()),
287            country: Some("US".to_owned()),
288            instrument_type: Some("stock".to_owned()),
289            sector: None,
290            industry: None,
291        }
292    }
293
294    #[test]
295    fn estimate_history_decodes_native_quote_series() {
296        let values = QuoteFieldValues::from_values(BTreeMap::from([
297            (
298                analyst::EARNINGS_FISCAL_PERIOD_FQ_H.as_str().to_owned(),
299                json!(["2026-Q1", "2025-Q4"]),
300            ),
301            (
302                analyst::EARNINGS_RELEASE_DATE_FQ_H.as_str().to_owned(),
303                json!([1769722200, 1761856320]),
304            ),
305            (
306                analyst::REVENUE_FORECAST_FQ_H.as_str().to_owned(),
307                json!([138391007589.0, 102227074560.0]),
308            ),
309            (
310                analyst::EPS_FORECAST_FQ_H.as_str().to_owned(),
311                json!([2.673324, 1.777147]),
312            ),
313            (
314                analyst::EPS_ACTUAL_FQ_H.as_str().to_owned(),
315                json!([2.84, 1.85]),
316            ),
317            (
318                analyst::EARNINGS_FISCAL_PERIOD_FY_H.as_str().to_owned(),
319                json!(["2025", "2024"]),
320            ),
321            (
322                analyst::EARNINGS_RELEASE_DATE_FY_H.as_str().to_owned(),
323                json!([1761856320, 1730406900i64]),
324            ),
325            (
326                analyst::REVENUE_FORECAST_FY_H.as_str().to_owned(),
327                json!([415406882375.0, 390480701773.0]),
328            ),
329            (
330                analyst::EPS_FORECAST_FY_H.as_str().to_owned(),
331                json!([7.381826, 6.708209]),
332            ),
333            (
334                analyst::EPS_ACTUAL_FY_H.as_str().to_owned(),
335                json!([7.46, 6.75]),
336            ),
337        ]));
338
339        let history = decode_estimate_history(instrument(), &values);
340
341        assert_eq!(history.instrument.ticker.as_str(), "NASDAQ:AAPL");
342        assert_eq!(history.quarterly.len(), 2);
343        assert_eq!(
344            history.quarterly[0].fiscal_period,
345            Some(FiscalPeriod::FiscalQuarter {
346                year: 2026,
347                quarter: 1,
348            })
349        );
350        assert_eq!(history.quarterly[0].value.eps_actual, Some(2.84));
351        assert_eq!(
352            history.annual[0].value.revenue_forecast,
353            Some(415406882375.0)
354        );
355        assert_eq!(history.lineage.source, DataSourceKind::Composed);
356        assert_eq!(history.lineage.history_kind, HistoryKind::Native);
357    }
358
359    #[test]
360    fn point_in_time_fundamentals_decodes_native_quote_series() {
361        let values = QuoteFieldValues::from_values(BTreeMap::from([
362            (
363                fundamentals::FISCAL_PERIOD_FQ_H.as_str().to_owned(),
364                json!(["2026-Q1", "2025-Q4"]),
365            ),
366            (
367                analyst::EARNINGS_RELEASE_DATE_FQ_H.as_str().to_owned(),
368                json!([1769722200, 1761856320]),
369            ),
370            (
371                fundamentals::TOTAL_REVENUE_FQ_H.as_str().to_owned(),
372                json!([143756000000.0, 102466000000.0]),
373            ),
374            (
375                fundamentals::NET_INCOME_FQ_H.as_str().to_owned(),
376                json!([42097000000.0, 27466000000.0]),
377            ),
378            (
379                fundamentals::TOTAL_ASSETS_FQ_H.as_str().to_owned(),
380                json!([379297000000.0, 359241000000.0]),
381            ),
382            (
383                fundamentals::TOTAL_LIABILITIES_FQ_H.as_str().to_owned(),
384                json!([290437000000.0, 308030000000.0]),
385            ),
386            (
387                fundamentals::CASH_FROM_OPERATIONS_FQ_H.as_str().to_owned(),
388                json!([53925000000.0, 29728000000.0]),
389            ),
390            (
391                fundamentals::FISCAL_PERIOD_FY_H.as_str().to_owned(),
392                json!(["2025", "2024"]),
393            ),
394            (
395                analyst::EARNINGS_RELEASE_DATE_FY_H.as_str().to_owned(),
396                json!([1761856320, 1730406900i64]),
397            ),
398            (
399                fundamentals::TOTAL_REVENUE_FY_H.as_str().to_owned(),
400                json!([416161000000.0, 391035000000.0]),
401            ),
402            (
403                fundamentals::NET_INCOME_FY_H.as_str().to_owned(),
404                json!([112010000000.0, 93736000000.0]),
405            ),
406            (
407                fundamentals::TOTAL_ASSETS_FY_H.as_str().to_owned(),
408                json!([359241000000.0, 364980000000.0]),
409            ),
410            (
411                fundamentals::TOTAL_LIABILITIES_FY_H.as_str().to_owned(),
412                json!([264090000000.0, 308030000000.0]),
413            ),
414            (
415                fundamentals::CASH_FROM_OPERATIONS_FY_H.as_str().to_owned(),
416                json!([111482000000.0, 118254000000.0]),
417            ),
418        ]));
419
420        let history = decode_point_in_time_fundamentals(instrument(), &values);
421
422        assert_eq!(history.quarterly.len(), 2);
423        assert_eq!(
424            history.quarterly[0].fiscal_period,
425            Some(FiscalPeriod::FiscalQuarter {
426                year: 2026,
427                quarter: 1,
428            })
429        );
430        assert_eq!(history.quarterly[0].value.net_income, Some(42097000000.0));
431        assert_eq!(
432            history.annual[0].value.cash_from_operations,
433            Some(111482000000.0)
434        );
435        assert_eq!(history.lineage.source, DataSourceKind::Composed);
436        assert_eq!(history.lineage.history_kind, HistoryKind::Native);
437    }
438}