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