1use chrono::{DateTime, Utc};
11use serde::{Deserialize, Serialize};
12
13use crate::models::options::Options;
14use crate::models::quote::CalendarEvents;
15
16#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
18#[non_exhaustive]
19pub struct CalendarEvent {
20 pub timestamp: i64,
22 pub date: String,
24 pub symbol: Option<String>,
26 pub event: EventKind,
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
32#[non_exhaustive]
33#[serde(tag = "type", rename_all = "snake_case")]
34pub enum EventKind {
35 Earnings {
37 eps_estimate_low: Option<f64>,
39 eps_estimate_avg: Option<f64>,
41 eps_estimate_high: Option<f64>,
43 revenue_estimate_avg: Option<i64>,
45 is_estimate: bool,
47 },
48 ExDividend {
50 amount: Option<f64>,
52 },
53 DividendPayment {
55 amount: Option<f64>,
57 },
58 OptionsExpiration {
64 contract_count: Option<usize>,
67 },
68 #[cfg(feature = "fred")]
70 EconomicRelease {
71 name: String,
73 series_id: String,
75 },
76}
77
78impl CalendarEvent {
79 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
90pub(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
97pub(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
176fn 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#[cfg(feature = "fred")]
194const MAJOR_ECONOMIC_RELEASE_IDS: &[u64] = &[
195 9, 10, 13, 46, 50, 53, 54, 101, 180, 192, ];
206
207#[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#[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 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#[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
295pub(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 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 const JUL_17: i64 = 1_784_246_400; const JUL_24: i64 = 1_784_851_200; const AUG_21: i64 = 1_787_270_400; 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 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 ReleaseDate {
456 release_id: 742,
457 release_name: "Bankrate Monitor".to_string(),
458 date: "2026-07-15".to_string(),
459 },
460 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 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 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}