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}