1pub mod market;
12
13use chrono::{DateTime, Utc};
14use serde::{Deserialize, Serialize};
15
16use crate::models::options::Options;
17use crate::models::quote::CalendarEvents;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
21#[non_exhaustive]
22pub struct CalendarEvent {
23 pub timestamp: i64,
25 pub date: String,
27 pub symbol: Option<String>,
29 pub event: EventKind,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
35#[non_exhaustive]
36#[serde(tag = "type", rename_all = "snake_case")]
37pub enum EventKind {
38 Earnings {
40 eps_estimate_low: Option<f64>,
42 eps_estimate_avg: Option<f64>,
44 eps_estimate_high: Option<f64>,
46 revenue_estimate_avg: Option<i64>,
48 is_estimate: bool,
50 },
51 ExDividend {
53 amount: Option<f64>,
55 },
56 DividendPayment {
58 amount: Option<f64>,
60 },
61 OptionsExpiration {
67 contract_count: Option<usize>,
70 },
71 #[cfg(feature = "fred")]
73 EconomicRelease {
74 name: String,
76 series_id: String,
78 },
79}
80
81impl CalendarEvent {
82 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
93pub(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
100pub(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
179fn 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#[cfg(feature = "fred")]
197const MAJOR_ECONOMIC_RELEASE_IDS: &[u64] = &[
198 9, 10, 13, 46, 50, 53, 54, 101, 180, 192, ];
209
210#[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#[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 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#[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
298pub(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 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 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!({
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 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 ReleaseDate {
459 release_id: 742,
460 release_name: "Bankrate Monitor".to_string(),
461 date: "2026-07-15".to_string(),
462 },
463 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 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 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}