Skip to main content

finance_query/models/calendar/
mod.rs

1//! Financial event calendar models.
2//!
3//! A [`CalendarEvent`] is a single upcoming financial event — earnings,
4//! ex-dividend/dividend-payment, options expiration, or (with the `fred`
5//! feature) a market-wide economic-data release.
6//!
7//! Construct calendars via [`Ticker::calendar`](crate::Ticker::calendar) and
8//! [`Tickers::calendar`](crate::Tickers::calendar).
9
10use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::models::options::Options;
14use crate::models::quote::CalendarEvents;
15
16/// A single upcoming financial event.
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[non_exhaustive]
19pub struct CalendarEvent {
20    /// Unix timestamp (seconds) when the event occurs.
21    pub timestamp: i64,
22    /// ISO 8601 date string for display (e.g. `"2026-01-23"`).
23    pub date: String,
24    /// Ticker symbol this event belongs to. `None` for market-wide events.
25    pub symbol: Option<String>,
26    /// The specific event.
27    pub event: EventKind,
28}
29
30/// The kind of financial event, with its event-specific payload.
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[non_exhaustive]
33#[serde(tag = "type", rename_all = "snake_case")]
34pub enum EventKind {
35    /// Upcoming earnings report with analyst estimate data.
36    Earnings {
37        /// Low analyst EPS estimate for the quarter.
38        eps_estimate_low: Option<f64>,
39        /// Average analyst EPS estimate for the quarter.
40        eps_estimate_avg: Option<f64>,
41        /// High analyst EPS estimate for the quarter.
42        eps_estimate_high: Option<f64>,
43        /// Average analyst revenue estimate for the quarter.
44        revenue_estimate_avg: Option<i64>,
45        /// Whether the date is an estimate (Yahoo flags upcoming dates as such).
46        is_estimate: bool,
47    },
48    /// Ex-dividend date — shares must be held before this date to receive the dividend.
49    ExDividend {
50        /// Dividend amount per share, when known.
51        amount: Option<f64>,
52    },
53    /// Dividend payment date — cash arrives in the account.
54    DividendPayment {
55        /// Dividend amount per share, when known.
56        amount: Option<f64>,
57    },
58    /// Standard monthly options expiration (3rd Friday) for this ticker.
59    ///
60    /// Only standard monthly expirations are surfaced — daily and weekly
61    /// expirations are omitted to keep the calendar focused on the dates that
62    /// carry meaningful open interest.
63    OptionsExpiration {
64        /// Number of listed contracts (calls + puts) expiring on this date when
65        /// a chain was loaded. `None` if only the expiration date is known.
66        contract_count: Option<usize>,
67    },
68    /// Economic data release (requires the `fred` feature).
69    #[cfg(feature = "fred")]
70    EconomicRelease {
71        /// Human-readable release name (e.g. "Consumer Price Index").
72        name: String,
73        /// FRED release identifier as a string.
74        series_id: String,
75    },
76}
77
78impl CalendarEvent {
79    /// Construct an event, deriving the ISO date string from the timestamp.
80    pub(crate) fn new(timestamp: i64, symbol: Option<String>, event: EventKind) -> Self {
81        Self {
82            timestamp,
83            date: iso_date(timestamp),
84            symbol,
85            event,
86        }
87    }
88}
89
90/// Format a Unix-second timestamp as an ISO `YYYY-MM-DD` UTC date string.
91pub(crate) fn iso_date(timestamp: i64) -> String {
92    DateTime::<Utc>::from_timestamp(timestamp, 0)
93        .map(|dt| dt.format("%Y-%m-%d").to_string())
94        .unwrap_or_default()
95}
96
97/// Build the per-symbol calendar events from already-fetched data.
98///
99/// Pure and synchronous: callers fetch `calendar` (the `calendarEvents` quote
100/// module) and `options` once, then hand them here. Only events whose timestamp
101/// falls within `window` (`[start, end]`, inclusive) are emitted. The result is
102/// **not** sorted — callers merge across symbols and sort once.
103pub(crate) fn build_symbol_events(
104    symbol: &str,
105    calendar: Option<&CalendarEvents>,
106    options: Option<&Options>,
107    window: (i64, i64),
108) -> Vec<CalendarEvent> {
109    let (start, end) = window;
110    let in_window = |ts: i64| ts >= start && ts <= end;
111    let mut events = Vec::new();
112
113    if let Some(cal) = calendar {
114        if let Some(earnings) = &cal.earnings {
115            let eps_estimate_low = earnings.earnings_low.as_ref().and_then(|v| v.raw);
116            let eps_estimate_avg = earnings.earnings_average.as_ref().and_then(|v| v.raw);
117            let eps_estimate_high = earnings.earnings_high.as_ref().and_then(|v| v.raw);
118            let revenue_estimate_avg = earnings.revenue_average.as_ref().and_then(|v| v.raw);
119            if let Some(dates) = &earnings.earnings_date {
120                for ts in dates
121                    .iter()
122                    .filter_map(|d| d.raw)
123                    .filter(|&ts| in_window(ts))
124                {
125                    events.push(CalendarEvent::new(
126                        ts,
127                        Some(symbol.to_string()),
128                        EventKind::Earnings {
129                            eps_estimate_low,
130                            eps_estimate_avg,
131                            eps_estimate_high,
132                            revenue_estimate_avg,
133                            is_estimate: true,
134                        },
135                    ));
136                }
137            }
138        }
139
140        if let Some(ts) = cal.ex_dividend_timestamp().filter(|&ts| in_window(ts)) {
141            events.push(CalendarEvent::new(
142                ts,
143                Some(symbol.to_string()),
144                EventKind::ExDividend { amount: None },
145            ));
146        }
147
148        if let Some(ts) = cal.dividend_timestamp().filter(|&ts| in_window(ts)) {
149            events.push(CalendarEvent::new(
150                ts,
151                Some(symbol.to_string()),
152                EventKind::DividendPayment { amount: None },
153            ));
154        }
155    }
156
157    if let Some(opts) = options {
158        let counts = opts.contract_counts();
159        for ts in opts
160            .expiration_dates()
161            .into_iter()
162            .filter(|&ts| in_window(ts) && is_monthly_expiration(ts))
163        {
164            let contract_count = counts.get(&ts).copied();
165            events.push(CalendarEvent::new(
166                ts,
167                Some(symbol.to_string()),
168                EventKind::OptionsExpiration { contract_count },
169            ));
170        }
171    }
172
173    events
174}
175
176/// Whether a timestamp falls on a standard monthly options expiration — the
177/// third Friday of the month (a Friday with day-of-month in 15..=21).
178fn is_monthly_expiration(timestamp: i64) -> bool {
179    use chrono::{Datelike, Weekday};
180    DateTime::<Utc>::from_timestamp(timestamp, 0).is_some_and(|dt| {
181        let d = dt.date_naive();
182        d.weekday() == Weekday::Fri && (15..=21).contains(&d.day())
183    })
184}
185
186/// FRED release IDs for the major, market-moving US economic releases surfaced
187/// in the calendar.
188///
189/// FRED's `releases/dates` feed lists ~300 releases, most of them niche or
190/// sub-national (state retail sales, research indices, etc.). Restricting to
191/// this curated set keeps the calendar focused on the releases that actually
192/// move markets, instead of burying per-ticker events under hundreds of rows.
193#[cfg(feature = "fred")]
194const MAJOR_ECONOMIC_RELEASE_IDS: &[u64] = &[
195    9,   // Advance Monthly Sales for Retail and Food Services (Retail Sales)
196    10,  // Consumer Price Index (CPI)
197    13,  // Industrial Production and Capacity Utilization
198    46,  // Producer Price Index (PPI)
199    50,  // Employment Situation (Nonfarm Payrolls)
200    53,  // Gross Domestic Product (GDP)
201    54,  // Personal Income and Outlays (PCE)
202    101, // FOMC Press Release
203    180, // Unemployment Insurance Weekly Claims (Jobless Claims)
204    192, // Job Openings and Labor Turnover Survey (JOLTS)
205];
206
207/// Build market-wide economic-release events from FRED scheduled release dates.
208///
209/// FRED returns dates as `YYYY-MM-DD`; each is interpreted as midnight UTC.
210/// Only releases in [`MAJOR_ECONOMIC_RELEASE_IDS`] that fall within `window`
211/// are emitted.
212#[cfg(feature = "fred")]
213pub(crate) fn build_economic_events(
214    releases: Vec<crate::adapters::fred::ReleaseDate>,
215    window: (i64, i64),
216) -> Vec<CalendarEvent> {
217    let (start, end) = window;
218    let releases = drop_phantom_daily_fills(
219        releases
220            .into_iter()
221            .filter(|r| MAJOR_ECONOMIC_RELEASE_IDS.contains(&r.release_id))
222            .collect(),
223    );
224    releases
225        .into_iter()
226        .filter_map(|r| {
227            let ts = parse_iso_date(&r.date)?;
228            (ts >= start && ts <= end).then(|| {
229                CalendarEvent::new(
230                    ts,
231                    None,
232                    EventKind::EconomicRelease {
233                        name: r.release_name,
234                        series_id: r.release_id.to_string(),
235                    },
236                )
237            })
238        })
239        .collect()
240}
241
242/// Drop FRED "no-data" phantom fills.
243///
244/// Queried with `include_release_dates_with_no_data`, FRED returns one row per
245/// calendar day for releases that lack scheduled-date data — notably the FOMC
246/// Press Release, which comes back as a run of ~30 consecutive daily rows. No
247/// genuine macro release recurs on consecutive days, so any date inside a run
248/// of 3+ consecutive calendar days for the same release is a phantom fill and
249/// is discarded. Isolated dates and 2-day adjacencies are kept, so the filter
250/// is independent of the calendar window size.
251#[cfg(feature = "fred")]
252fn drop_phantom_daily_fills(
253    releases: Vec<crate::adapters::fred::ReleaseDate>,
254) -> Vec<crate::adapters::fred::ReleaseDate> {
255    use std::collections::{HashMap, HashSet};
256
257    let day = |r: &crate::adapters::fred::ReleaseDate| {
258        parse_iso_date(&r.date).map(|ts| ts.div_euclid(86_400))
259    };
260
261    let mut days_by_release: HashMap<u64, HashSet<i64>> = HashMap::new();
262    for r in &releases {
263        if let Some(ord) = day(r) {
264            days_by_release.entry(r.release_id).or_default().insert(ord);
265        }
266    }
267
268    releases
269        .into_iter()
270        .filter(|r| {
271            let Some(ord) = day(r) else { return false };
272            let days = &days_by_release[&r.release_id];
273            let has = |d: i64| days.contains(&d);
274            // Part of a 3+-day run iff one of these consecutive triples holds.
275            let in_run = (has(ord - 2) && has(ord - 1))
276                || (has(ord - 1) && has(ord + 1))
277                || (has(ord + 1) && has(ord + 2));
278            !in_run
279        })
280        .collect()
281}
282
283/// Parse an ISO `YYYY-MM-DD` date as the Unix timestamp of midnight UTC.
284#[cfg(feature = "fred")]
285fn parse_iso_date(date: &str) -> Option<i64> {
286    use chrono::NaiveDate;
287    NaiveDate::parse_from_str(date, "%Y-%m-%d")
288        .ok()?
289        .and_hms_opt(0, 0, 0)?
290        .and_utc()
291        .timestamp()
292        .into()
293}
294
295/// Sort events ascending by timestamp in place.
296pub(crate) fn sort_events(events: &mut [CalendarEvent]) {
297    events.sort_by_key(|e| e.timestamp);
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use serde_json::json;
304
305    fn sample_calendar() -> CalendarEvents {
306        serde_json::from_value(json!({
307            "maxAge": 1,
308            "earnings": {
309                "earningsDate": [{"fmt": "2026-02-25", "raw": 1_772_000_000_i64}],
310                "earningsAverage": {"fmt": "1.52", "raw": 1.52},
311                "earningsLow": {"fmt": "1.40", "raw": 1.40},
312                "earningsHigh": {"fmt": "1.65", "raw": 1.65},
313                "revenueAverage": {"fmt": "120B", "raw": 120_000_000_000_i64}
314            },
315            "exDividendDate": {"fmt": "2026-02-10", "raw": 1_770_700_000_i64},
316            "dividendDate": {"fmt": "2026-02-20", "raw": 1_771_560_000_i64}
317        }))
318        .unwrap()
319    }
320
321    #[test]
322    fn iso_date_formats_utc() {
323        assert_eq!(iso_date(1_772_000_000), "2026-02-25");
324    }
325
326    #[test]
327    fn builds_earnings_dividend_events_in_window() {
328        let cal = sample_calendar();
329        let window = (1_770_000_000, 1_773_000_000);
330        let events = build_symbol_events("AAPL", Some(&cal), None, window);
331
332        assert_eq!(events.len(), 3);
333        let earnings = events
334            .iter()
335            .find(|e| matches!(e.event, EventKind::Earnings { .. }))
336            .unwrap();
337        assert_eq!(earnings.symbol.as_deref(), Some("AAPL"));
338        match &earnings.event {
339            EventKind::Earnings {
340                eps_estimate_avg,
341                revenue_estimate_avg,
342                is_estimate,
343                ..
344            } => {
345                assert_eq!(*eps_estimate_avg, Some(1.52));
346                assert_eq!(*revenue_estimate_avg, Some(120_000_000_000));
347                assert!(*is_estimate);
348            }
349            _ => unreachable!(),
350        }
351        assert!(
352            events
353                .iter()
354                .any(|e| matches!(e.event, EventKind::ExDividend { .. }))
355        );
356        assert!(
357            events
358                .iter()
359                .any(|e| matches!(e.event, EventKind::DividendPayment { .. }))
360        );
361    }
362
363    #[test]
364    fn filters_events_outside_window() {
365        let cal = sample_calendar();
366        // Window entirely before all sample dates.
367        let window = (1_000_000_000, 1_100_000_000);
368        let events = build_symbol_events("AAPL", Some(&cal), None, window);
369        assert!(events.is_empty());
370    }
371
372    #[test]
373    fn options_expirations_filter_to_monthly_with_optional_counts() {
374        // 2026-07-17 (3rd Fri, monthly) has a loaded chain (2 calls + 1 put);
375        // 2026-08-21 (3rd Fri, monthly) is listed but not detailed;
376        // 2026-07-24 (weekly Fri) must be filtered out entirely.
377        const JUL_17: i64 = 1_784_246_400; // 2026-07-17 Fri
378        const JUL_24: i64 = 1_784_851_200; // 2026-07-24 Fri (weekly)
379        const AUG_21: i64 = 1_787_270_400; // 2026-08-21 Fri
380        let opts: Options = serde_json::from_value(json!({
381            "optionChain": {
382                "result": [{
383                    "underlyingSymbol": "AAPL",
384                    "expirationDates": [JUL_17, JUL_24, AUG_21],
385                    "strikes": [100.0, 105.0, 110.0],
386                    "options": [{
387                        "expirationDate": JUL_17,
388                        "calls": [{"contractSymbol":"A","strike":100.0},{"contractSymbol":"B","strike":105.0}],
389                        "puts": [{"contractSymbol":"C","strike":100.0}]
390                    }]
391                }],
392                "error": null
393            }
394        }))
395        .unwrap();
396
397        let window = (1_783_000_000, 1_790_000_000);
398        let mut events = build_symbol_events("AAPL", None, Some(&opts), window);
399        sort_events(&mut events);
400
401        // Weekly JUL_24 excluded → only the two monthlies remain.
402        assert_eq!(events.len(), 2);
403        assert_eq!(events[0].timestamp, JUL_17);
404        assert_eq!(events[1].timestamp, AUG_21);
405        match events[0].event {
406            EventKind::OptionsExpiration { contract_count } => assert_eq!(contract_count, Some(3)),
407            _ => unreachable!(),
408        }
409        match events[1].event {
410            EventKind::OptionsExpiration { contract_count } => assert_eq!(contract_count, None),
411            _ => unreachable!(),
412        }
413    }
414
415    #[test]
416    fn sort_events_orders_ascending() {
417        let mut events = vec![
418            CalendarEvent::new(300, None, EventKind::ExDividend { amount: None }),
419            CalendarEvent::new(100, None, EventKind::ExDividend { amount: None }),
420            CalendarEvent::new(200, None, EventKind::ExDividend { amount: None }),
421        ];
422        sort_events(&mut events);
423        assert_eq!(
424            events.iter().map(|e| e.timestamp).collect::<Vec<_>>(),
425            vec![100, 200, 300]
426        );
427    }
428
429    #[test]
430    fn serializes_event_kind_with_type_tag() {
431        let event = CalendarEvent::new(
432            1_772_000_000,
433            Some("TSLA".to_string()),
434            EventKind::OptionsExpiration {
435                contract_count: Some(312),
436            },
437        );
438        let v = serde_json::to_value(&event).unwrap();
439        assert_eq!(v["event"]["type"], "options_expiration");
440        assert_eq!(v["event"]["contract_count"], 312);
441        assert_eq!(v["date"], "2026-02-25");
442    }
443
444    #[cfg(feature = "fred")]
445    #[test]
446    fn economic_events_filter_to_major_releases_in_window() {
447        use crate::adapters::fred::ReleaseDate;
448        let releases = vec![
449            ReleaseDate {
450                release_id: 10,
451                release_name: "Consumer Price Index".to_string(),
452                date: "2026-07-15".to_string(),
453            },
454            // Niche release → excluded by the curated whitelist.
455            ReleaseDate {
456                release_id: 742,
457                release_name: "Bankrate Monitor".to_string(),
458                date: "2026-07-15".to_string(),
459            },
460            // Major release but outside the window → excluded.
461            ReleaseDate {
462                release_id: 50,
463                release_name: "Employment Situation".to_string(),
464                date: "2099-01-01".to_string(),
465            },
466        ];
467        let window = (
468            parse_iso_date("2026-07-01").unwrap(),
469            parse_iso_date("2026-07-31").unwrap(),
470        );
471        let events = build_economic_events(releases, window);
472
473        assert_eq!(events.len(), 1);
474        assert_eq!(events[0].symbol, None);
475        match &events[0].event {
476            EventKind::EconomicRelease { name, series_id } => {
477                assert_eq!(name, "Consumer Price Index");
478                assert_eq!(series_id, "10");
479            }
480            _ => unreachable!(),
481        }
482    }
483
484    #[cfg(feature = "fred")]
485    #[test]
486    fn drops_phantom_daily_fills_keeps_real_schedules() {
487        use crate::adapters::fred::ReleaseDate;
488        // FOMC (id 101): FRED returns one phantom row per day, Jul 10–20 (11
489        // consecutive days) — all must be dropped. CPI (id 10): a single real
490        // scheduled date survives. Jobless Claims (id 180): weekly (7-day gaps)
491        // survive as isolated dates.
492        let mut releases = Vec::new();
493        for day in 10..=20 {
494            releases.push(ReleaseDate {
495                release_id: 101,
496                release_name: "FOMC Press Release".to_string(),
497                date: format!("2026-07-{day:02}"),
498            });
499        }
500        releases.push(ReleaseDate {
501            release_id: 10,
502            release_name: "Consumer Price Index".to_string(),
503            date: "2026-07-14".to_string(),
504        });
505        releases.push(ReleaseDate {
506            release_id: 180,
507            release_name: "Unemployment Insurance Weekly Claims Report".to_string(),
508            date: "2026-07-09".to_string(),
509        });
510        releases.push(ReleaseDate {
511            release_id: 180,
512            release_name: "Unemployment Insurance Weekly Claims Report".to_string(),
513            date: "2026-07-16".to_string(),
514        });
515
516        let window = (
517            parse_iso_date("2026-07-01").unwrap(),
518            parse_iso_date("2026-07-31").unwrap(),
519        );
520        let events = build_economic_events(releases, window);
521
522        // No FOMC (all phantom), CPI once, two weekly-claims dates.
523        assert!(
524            !events
525                .iter()
526                .any(|e| matches!(&e.event, EventKind::EconomicRelease { series_id, .. } if series_id == "101")),
527            "phantom FOMC daily fills must be dropped"
528        );
529        assert_eq!(
530            events
531                .iter()
532                .filter(|e| matches!(&e.event, EventKind::EconomicRelease { series_id, .. } if series_id == "10"))
533                .count(),
534            1
535        );
536        assert_eq!(
537            events
538                .iter()
539                .filter(|e| matches!(&e.event, EventKind::EconomicRelease { series_id, .. } if series_id == "180"))
540                .count(),
541            2
542        );
543    }
544}