Skip to main content

fin_primitives/events/
mod.rs

1//! Event Study Framework
2//!
3//! Analyzes abnormal returns around discrete market events such as earnings
4//! announcements, buy-the-rumor / sell-the-news dynamics, and post-earnings drift.
5//!
6//! # Methodology
7//!
8//! The market model is used to estimate expected returns: the benchmark's
9//! return over the same interval is the expected return for the security.
10//! Abnormal return AR(d) = raw_return(d) - expected_return(d).
11//! CAR is the running sum of ARs from the start of the window.
12
13/// A discrete market event anchored to a calendar date.
14#[derive(Debug, Clone)]
15pub struct MarketEvent {
16    /// Unique identifier for this event.
17    pub event_id: String,
18    /// Event date as a Unix timestamp (seconds since epoch).
19    pub event_date: u64,
20    /// Category of event (e.g. "earnings", "macro", "guidance").
21    pub event_type: String,
22    /// Human-readable description.
23    pub description: String,
24}
25
26/// Defines the pre- and post-event window in trading days.
27#[derive(Debug, Clone, Copy)]
28pub struct EventWindow {
29    /// Number of days before the event (negative means before; e.g. -10).
30    pub pre_days: i32,
31    /// Number of days after the event (positive means after; e.g. +10).
32    pub post_days: i32,
33}
34
35/// A single day's abnormal return observation within an event window.
36#[derive(Debug, Clone)]
37pub struct AbnormalReturn {
38    /// Day relative to the event (negative = before, 0 = event day, positive = after).
39    pub day: i32,
40    /// Observed log-return of the security on this day.
41    pub raw_return: f64,
42    /// Expected return (benchmark return as market model proxy).
43    pub expected_return: f64,
44    /// Abnormal return: `raw_return - expected_return`.
45    pub abnormal_return: f64,
46    /// Cumulative abnormal return from the start of the window up to and including this day.
47    pub car: f64,
48}
49
50/// Full event study result for a single event.
51#[derive(Debug, Clone)]
52pub struct EventResult {
53    /// The event that was studied.
54    pub event: MarketEvent,
55    /// CAR over the pre-event window (days `pre_days..0`).
56    pub car_pre: f64,
57    /// CAR over the post-event window (days `1..=post_days`).
58    pub car_post: f64,
59    /// Day with the highest CAR within the window.
60    pub peak_day: i32,
61    /// Day with the lowest CAR within the window.
62    pub trough_day: i32,
63    /// Full time-series of abnormal returns within the window.
64    pub abnormal_returns: Vec<AbnormalReturn>,
65}
66
67/// The event study engine.
68pub struct EventStudy;
69
70impl EventStudy {
71    /// Compute abnormal returns around `event` using a market-model benchmark.
72    ///
73    /// # Arguments
74    /// - `event`: the market event to study.
75    /// - `price_series`: `(unix_ts_secs, price)` pairs for the security, chronological.
76    /// - `benchmark`: `(unix_ts_secs, price)` pairs for the benchmark, chronological.
77    /// - `window`: event window specification.
78    ///
79    /// # Returns
80    /// An [`EventResult`] with abnormal returns and summary statistics.
81    pub fn compute(
82        event: &MarketEvent,
83        price_series: &[(u64, f64)],
84        benchmark: &[(u64, f64)],
85        window: EventWindow,
86    ) -> EventResult {
87        // Build daily log-return series indexed by day offset from event_date
88        let sec_returns = daily_log_returns(price_series, event.event_date);
89        let bmk_returns = daily_log_returns(benchmark, event.event_date);
90
91        let mut abnormal_returns: Vec<AbnormalReturn> = Vec::new();
92        let mut cumulative = 0.0f64;
93
94        let day_start = window.pre_days;
95        let day_end = window.post_days;
96
97        for d in day_start..=day_end {
98            let raw = sec_returns.get(&d).copied().unwrap_or(0.0);
99            let exp = bmk_returns.get(&d).copied().unwrap_or(0.0);
100            let ar = raw - exp;
101            cumulative += ar;
102            abnormal_returns.push(AbnormalReturn {
103                day: d,
104                raw_return: raw,
105                expected_return: exp,
106                abnormal_return: ar,
107                car: cumulative,
108            });
109        }
110
111        // CAR pre (pre_days..0, not including event day)
112        let car_pre: f64 = abnormal_returns
113            .iter()
114            .filter(|ar| ar.day >= window.pre_days && ar.day < 0)
115            .map(|ar| ar.abnormal_return)
116            .sum();
117
118        // CAR post (1..=post_days)
119        let car_post: f64 = abnormal_returns
120            .iter()
121            .filter(|ar| ar.day >= 1 && ar.day <= window.post_days)
122            .map(|ar| ar.abnormal_return)
123            .sum();
124
125        // Peak and trough by CAR value
126        let (peak_day, trough_day) = abnormal_returns.iter().fold(
127            (0i32, 0i32),
128            |(peak_d, trough_d), ar| {
129                let peak_car = abnormal_returns.iter().find(|x| x.day == peak_d).map(|x| x.car).unwrap_or(0.0);
130                let trough_car = abnormal_returns.iter().find(|x| x.day == trough_d).map(|x| x.car).unwrap_or(0.0);
131                let new_peak = if ar.car > peak_car { ar.day } else { peak_d };
132                let new_trough = if ar.car < trough_car { ar.day } else { trough_d };
133                (new_peak, new_trough)
134            },
135        );
136
137        EventResult {
138            event: event.clone(),
139            car_pre,
140            car_post,
141            peak_day,
142            trough_day,
143            abnormal_returns,
144        }
145    }
146
147    /// Compute the t-statistic on average CAR across multiple event results.
148    ///
149    /// Formula: `t = mean_CAR / (std_CAR / sqrt(N))`
150    ///
151    /// The CAR used per event is the total window CAR: the cumulative abnormal
152    /// return on the last day of the window, which includes the event day
153    /// (`car_pre + AR(0) + car_post`).
154    /// Returns `0.0` if fewer than 2 results are provided, or if every event has
155    /// the same CAR (zero dispersion leaves the t-statistic undefined).
156    pub fn significance(results: &[EventResult]) -> f64 {
157        let n = results.len();
158        if n < 2 {
159            return 0.0;
160        }
161        let cars: Vec<f64> = results
162            .iter()
163            .map(|r| r.abnormal_returns.last().map_or(r.car_pre + r.car_post, |a| a.car))
164            .collect();
165        let mean = cars.iter().sum::<f64>() / n as f64;
166        let variance = cars.iter().map(|c| (c - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
167        let std_dev = variance.sqrt();
168        if std_dev < 1e-15 {
169            return 0.0;
170        }
171        mean / (std_dev / (n as f64).sqrt())
172    }
173}
174
175/// Converts a price series into a map of `day_offset → log_return`.
176///
177/// Day 0 is the day whose timestamp is closest to (and not before) `event_date`.
178/// Each subsequent index represents one calendar-day step in the series.
179fn daily_log_returns(series: &[(u64, f64)], event_date: u64) -> std::collections::HashMap<i32, f64> {
180    use std::collections::HashMap;
181
182    if series.len() < 2 {
183        return HashMap::new();
184    }
185
186    // Find event index: first entry with ts >= event_date
187    let event_idx = series.partition_point(|&(ts, _)| ts < event_date);
188    // Clamp to valid range for price access
189    let event_idx = event_idx.min(series.len() - 1);
190
191    let mut map = HashMap::new();
192
193    for i in 1..series.len() {
194        let p_prev = series[i - 1].1;
195        let p_curr = series[i].1;
196        let log_ret = if p_prev > 0.0 && p_curr > 0.0 {
197            (p_curr / p_prev).ln()
198        } else {
199            0.0
200        };
201        // Day offset: how many steps from event_idx this return index falls
202        // Return at index i corresponds to day (i as i32 - event_idx as i32)
203        let day = i as i32 - event_idx as i32;
204        map.insert(day, log_ret);
205    }
206    map
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212
213    /// Build a synthetic price series around an event date.
214    /// `event_ts` is index N in the series; series covers indices 0..=2N.
215    fn synthetic_prices(n: usize, event_idx: usize, drift: f64, vol: f64) -> Vec<(u64, f64)> {
216        let mut prices = Vec::with_capacity(n);
217        let mut p = 100.0f64;
218        let base_ts: u64 = 1_000_000;
219        let day_secs: u64 = 86_400;
220        // Use a simple deterministic pseudo-price series
221        for i in 0..n {
222            if i > 0 {
223                // Alternate up/down with drift
224                let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
225                p *= (drift + sign * vol).exp();
226            }
227            prices.push((base_ts + i as u64 * day_secs, p));
228        }
229        let _ = event_idx; // event_ts will be set by caller
230        prices
231    }
232
233    fn make_event(date: u64) -> MarketEvent {
234        MarketEvent {
235            event_id: "EVT001".into(),
236            event_date: date,
237            event_type: "earnings".into(),
238            description: "Q3 earnings release".into(),
239        }
240    }
241
242    #[test]
243    fn test_compute_returns_correct_window_length() {
244        let prices = synthetic_prices(30, 15, 0.001, 0.005);
245        let bench = synthetic_prices(30, 15, 0.0005, 0.003);
246        let event_ts = prices[15].0;
247        let event = make_event(event_ts);
248        let window = EventWindow { pre_days: -5, post_days: 5 };
249        let result = EventStudy::compute(&event, &prices, &bench, window);
250        assert_eq!(result.abnormal_returns.len(), 11); // -5..=+5 inclusive
251    }
252
253    #[test]
254    fn test_car_accumulates_correctly() {
255        let prices = synthetic_prices(20, 10, 0.001, 0.003);
256        let bench = synthetic_prices(20, 10, 0.001, 0.003); // same → zero AR
257        let event_ts = prices[10].0;
258        let event = make_event(event_ts);
259        let window = EventWindow { pre_days: -3, post_days: 3 };
260        let result = EventStudy::compute(&event, &prices, &bench, window);
261        // When security == benchmark, all ARs ≈ 0, CARs ≈ 0
262        for ar in &result.abnormal_returns {
263            assert!(ar.car.abs() < 1e-10, "CAR should be ~0 when security==benchmark");
264        }
265    }
266
267    #[test]
268    fn test_abnormal_return_equals_raw_minus_expected() {
269        let prices: Vec<(u64, f64)> = (0..20u64).map(|i| (1_000_000 + i * 86_400, 100.0 + i as f64)).collect();
270        let bench: Vec<(u64, f64)>  = (0..20u64).map(|i| (1_000_000 + i * 86_400, 100.0 + i as f64 * 0.5)).collect();
271        let event_ts = 1_000_000 + 10 * 86_400;
272        let event = make_event(event_ts);
273        let window = EventWindow { pre_days: -2, post_days: 2 };
274        let result = EventStudy::compute(&event, &prices, &bench, window);
275        for ar in &result.abnormal_returns {
276            let diff = (ar.raw_return - ar.expected_return - ar.abnormal_return).abs();
277            assert!(diff < 1e-12, "AR identity failed on day {}", ar.day);
278        }
279    }
280
281    #[test]
282    fn test_car_monotone_with_window_start() {
283        let prices = synthetic_prices(25, 12, 0.002, 0.004);
284        let bench = synthetic_prices(25, 12, 0.001, 0.002);
285        let event_ts = prices[12].0;
286        let event = make_event(event_ts);
287        let window = EventWindow { pre_days: -5, post_days: 5 };
288        let result = EventStudy::compute(&event, &prices, &bench, window);
289        // CAR should equal sum of prior ARs
290        let mut running = 0.0f64;
291        for ar in &result.abnormal_returns {
292            running += ar.abnormal_return;
293            assert!((ar.car - running).abs() < 1e-12, "CAR mismatch at day {}", ar.day);
294        }
295    }
296
297    #[test]
298    fn test_car_pre_and_post_split() {
299        let prices = synthetic_prices(25, 12, 0.001, 0.002);
300        let bench = synthetic_prices(25, 12, 0.0005, 0.001);
301        let event_ts = prices[12].0;
302        let event = make_event(event_ts);
303        let window = EventWindow { pre_days: -5, post_days: 5 };
304        let result = EventStudy::compute(&event, &prices, &bench, window);
305        let manual_pre: f64 = result.abnormal_returns.iter()
306            .filter(|ar| ar.day >= -5 && ar.day < 0)
307            .map(|ar| ar.abnormal_return)
308            .sum();
309        let manual_post: f64 = result.abnormal_returns.iter()
310            .filter(|ar| ar.day >= 1 && ar.day <= 5)
311            .map(|ar| ar.abnormal_return)
312            .sum();
313        assert!((result.car_pre - manual_pre).abs() < 1e-12);
314        assert!((result.car_post - manual_post).abs() < 1e-12);
315    }
316
317    #[test]
318    fn test_peak_day_is_highest_car() {
319        let prices = synthetic_prices(25, 12, 0.003, 0.001);
320        let bench = synthetic_prices(25, 12, 0.001, 0.001);
321        let event_ts = prices[12].0;
322        let event = make_event(event_ts);
323        let window = EventWindow { pre_days: -5, post_days: 5 };
324        let result = EventStudy::compute(&event, &prices, &bench, window);
325        let max_car = result.abnormal_returns.iter().map(|ar| ar.car).fold(f64::NEG_INFINITY, f64::max);
326        let peak_car = result.abnormal_returns.iter().find(|ar| ar.day == result.peak_day).map(|ar| ar.car).unwrap_or(0.0);
327        assert!((peak_car - max_car).abs() < 1e-12);
328    }
329
330    #[test]
331    fn test_trough_day_is_lowest_car() {
332        let prices = synthetic_prices(25, 12, -0.001, 0.003);
333        let bench = synthetic_prices(25, 12, 0.001, 0.001);
334        let event_ts = prices[12].0;
335        let event = make_event(event_ts);
336        let window = EventWindow { pre_days: -5, post_days: 5 };
337        let result = EventStudy::compute(&event, &prices, &bench, window);
338        let min_car = result.abnormal_returns.iter().map(|ar| ar.car).fold(f64::INFINITY, f64::min);
339        let trough_car = result.abnormal_returns.iter().find(|ar| ar.day == result.trough_day).map(|ar| ar.car).unwrap_or(0.0);
340        assert!((trough_car - min_car).abs() < 1e-12);
341    }
342
343    #[test]
344    fn test_significance_zero_for_less_than_two() {
345        let event = make_event(1_000_000);
346        let prices = synthetic_prices(20, 10, 0.001, 0.002);
347        let bench = synthetic_prices(20, 10, 0.001, 0.002);
348        let window = EventWindow { pre_days: -3, post_days: 3 };
349        let result = EventStudy::compute(&event, &prices, &bench, window);
350        assert_eq!(EventStudy::significance(&[result]), 0.0);
351        assert_eq!(EventStudy::significance(&[]), 0.0);
352    }
353
354    #[test]
355    fn test_significance_positive_when_cars_positive() {
356        let mut results = Vec::new();
357        for i in 0..5 {
358            // Security consistently outperforms benchmark, by a different margin per
359            // event (identical events would have zero CAR dispersion and no t-stat).
360            let prices: Vec<(u64, f64)> = (0..20u64)
361                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (1.01f64 + 0.002 * i as f64).powi(j as i32)))
362                .collect();
363            let bench: Vec<(u64, f64)> = (0..20u64)
364                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (1.005f64).powi(j as i32)))
365                .collect();
366            let event_ts = 1_000_000 + i * 1_000_000 + 10 * 86_400;
367            let event = make_event(event_ts);
368            let window = EventWindow { pre_days: -3, post_days: 3 };
369            results.push(EventStudy::compute(&event, &prices, &bench, window));
370        }
371        let t = EventStudy::significance(&results);
372        assert!(t > 0.0, "t-statistic should be positive when CAR is consistently positive");
373    }
374
375    #[test]
376    fn test_significance_negative_when_cars_negative() {
377        let mut results = Vec::new();
378        for i in 0..5 {
379            // Security consistently underperforms benchmark, by a different margin per
380            // event (identical events would have zero CAR dispersion and no t-stat).
381            let prices: Vec<(u64, f64)> = (0..20u64)
382                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (0.99f64 - 0.002 * i as f64).powi(j as i32)))
383                .collect();
384            let bench: Vec<(u64, f64)> = (0..20u64)
385                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (1.005f64).powi(j as i32)))
386                .collect();
387            let event_ts = 1_000_000 + i * 1_000_000 + 10 * 86_400;
388            let event = make_event(event_ts);
389            let window = EventWindow { pre_days: -3, post_days: 3 };
390            results.push(EventStudy::compute(&event, &prices, &bench, window));
391        }
392        let t = EventStudy::significance(&results);
393        assert!(t < 0.0, "t-statistic should be negative when CAR is consistently negative");
394    }
395
396    #[test]
397    fn test_day_range_in_window() {
398        let prices = synthetic_prices(30, 15, 0.001, 0.002);
399        let bench = synthetic_prices(30, 15, 0.001, 0.002);
400        let event_ts = prices[15].0;
401        let event = make_event(event_ts);
402        let window = EventWindow { pre_days: -10, post_days: 10 };
403        let result = EventStudy::compute(&event, &prices, &bench, window);
404        let days: Vec<i32> = result.abnormal_returns.iter().map(|ar| ar.day).collect();
405        assert!(days.contains(&-10));
406        assert!(days.contains(&0));
407        assert!(days.contains(&10));
408    }
409
410    #[test]
411    fn test_event_fields_preserved() {
412        let prices = synthetic_prices(20, 10, 0.001, 0.002);
413        let bench = synthetic_prices(20, 10, 0.001, 0.002);
414        let event_ts = prices[10].0;
415        let event = make_event(event_ts);
416        let window = EventWindow { pre_days: -2, post_days: 2 };
417        let result = EventStudy::compute(&event, &prices, &bench, window);
418        assert_eq!(result.event.event_id, "EVT001");
419        assert_eq!(result.event.event_type, "earnings");
420    }
421
422    #[test]
423    fn test_significance_t_stat_formula() {
424        // Manually construct results with known CARs
425        fn dummy_result(car: f64) -> EventResult {
426            EventResult {
427                event: make_event(1_000_000),
428                car_pre: car / 2.0,
429                car_post: car / 2.0,
430                peak_day: 1,
431                trough_day: -1,
432                abnormal_returns: vec![],
433            }
434        }
435        let cars = [0.02, 0.03, 0.025, 0.018, 0.022];
436        let results: Vec<EventResult> = cars.iter().map(|&c| dummy_result(c)).collect();
437        let t = EventStudy::significance(&results);
438        // Mean ≈ 0.023, should yield a significant positive t
439        assert!(t > 1.0, "t-stat = {t}, expected > 1");
440    }
441
442    #[test]
443    fn test_zero_price_series_gives_zero_returns() {
444        // prices of 0 should not panic, returns default to 0
445        let prices: Vec<(u64, f64)> = vec![(1_000_000, 0.0), (1_086_400, 0.0)];
446        let bench: Vec<(u64, f64)>  = vec![(1_000_000, 100.0), (1_086_400, 101.0)];
447        let event = make_event(1_000_000);
448        let window = EventWindow { pre_days: -1, post_days: 1 };
449        // Should not panic
450        let _ = EventStudy::compute(&event, &prices, &bench, window);
451    }
452
453    #[test]
454    fn test_asymmetric_window() {
455        let prices = synthetic_prices(30, 15, 0.001, 0.002);
456        let bench = synthetic_prices(30, 15, 0.001, 0.002);
457        let event_ts = prices[15].0;
458        let event = make_event(event_ts);
459        let window = EventWindow { pre_days: -2, post_days: 8 };
460        let result = EventStudy::compute(&event, &prices, &bench, window);
461        assert_eq!(result.abnormal_returns.len(), 11); // -2..=8 = 11 days
462    }
463
464    #[test]
465    fn test_significance_all_identical_cars_returns_zero() {
466        fn dummy_result(car: f64) -> EventResult {
467            EventResult {
468                event: make_event(1_000_000),
469                car_pre: car,
470                car_post: 0.0,
471                peak_day: 0,
472                trough_day: 0,
473                abnormal_returns: vec![],
474            }
475        }
476        // All identical → std_dev = 0 → return 0
477        let results: Vec<EventResult> = [0.01, 0.01, 0.01].iter().map(|&c| dummy_result(c)).collect();
478        assert_eq!(EventStudy::significance(&results), 0.0);
479    }
480}