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 `car_pre + car_post` (total window CAR).
152    /// Returns `0.0` if fewer than 2 results are provided.
153    pub fn significance(results: &[EventResult]) -> f64 {
154        let n = results.len();
155        if n < 2 {
156            return 0.0;
157        }
158        let cars: Vec<f64> = results
159            .iter()
160            .map(|r| r.car_pre + r.car_post)
161            .collect();
162        let mean = cars.iter().sum::<f64>() / n as f64;
163        let variance = cars.iter().map(|c| (c - mean).powi(2)).sum::<f64>() / (n - 1) as f64;
164        let std_dev = variance.sqrt();
165        if std_dev < 1e-15 {
166            return 0.0;
167        }
168        mean / (std_dev / (n as f64).sqrt())
169    }
170}
171
172/// Converts a price series into a map of `day_offset → log_return`.
173///
174/// Day 0 is the day whose timestamp is closest to (and not before) `event_date`.
175/// Each subsequent index represents one calendar-day step in the series.
176fn daily_log_returns(series: &[(u64, f64)], event_date: u64) -> std::collections::HashMap<i32, f64> {
177    use std::collections::HashMap;
178
179    if series.len() < 2 {
180        return HashMap::new();
181    }
182
183    // Find event index: first entry with ts >= event_date
184    let event_idx = series.partition_point(|&(ts, _)| ts < event_date);
185    // Clamp to valid range for price access
186    let event_idx = event_idx.min(series.len() - 1);
187
188    let mut map = HashMap::new();
189
190    for i in 1..series.len() {
191        let p_prev = series[i - 1].1;
192        let p_curr = series[i].1;
193        let log_ret = if p_prev > 0.0 && p_curr > 0.0 {
194            (p_curr / p_prev).ln()
195        } else {
196            0.0
197        };
198        // Day offset: how many steps from event_idx this return index falls
199        // Return at index i corresponds to day (i as i32 - event_idx as i32)
200        let day = i as i32 - event_idx as i32;
201        map.insert(day, log_ret);
202    }
203    map
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    /// Build a synthetic price series around an event date.
211    /// `event_ts` is index N in the series; series covers indices 0..=2N.
212    fn synthetic_prices(n: usize, event_idx: usize, drift: f64, vol: f64) -> Vec<(u64, f64)> {
213        let mut prices = Vec::with_capacity(n);
214        let mut p = 100.0f64;
215        let base_ts: u64 = 1_000_000;
216        let day_secs: u64 = 86_400;
217        // Use a simple deterministic pseudo-price series
218        for i in 0..n {
219            if i > 0 {
220                // Alternate up/down with drift
221                let sign = if i % 2 == 0 { 1.0 } else { -1.0 };
222                p *= (drift + sign * vol).exp();
223            }
224            prices.push((base_ts + i as u64 * day_secs, p));
225        }
226        let _ = event_idx; // event_ts will be set by caller
227        prices
228    }
229
230    fn make_event(date: u64) -> MarketEvent {
231        MarketEvent {
232            event_id: "EVT001".into(),
233            event_date: date,
234            event_type: "earnings".into(),
235            description: "Q3 earnings release".into(),
236        }
237    }
238
239    #[test]
240    fn test_compute_returns_correct_window_length() {
241        let prices = synthetic_prices(30, 15, 0.001, 0.005);
242        let bench = synthetic_prices(30, 15, 0.0005, 0.003);
243        let event_ts = prices[15].0;
244        let event = make_event(event_ts);
245        let window = EventWindow { pre_days: -5, post_days: 5 };
246        let result = EventStudy::compute(&event, &prices, &bench, window);
247        assert_eq!(result.abnormal_returns.len(), 11); // -5..=+5 inclusive
248    }
249
250    #[test]
251    fn test_car_accumulates_correctly() {
252        let prices = synthetic_prices(20, 10, 0.001, 0.003);
253        let bench = synthetic_prices(20, 10, 0.001, 0.003); // same → zero AR
254        let event_ts = prices[10].0;
255        let event = make_event(event_ts);
256        let window = EventWindow { pre_days: -3, post_days: 3 };
257        let result = EventStudy::compute(&event, &prices, &bench, window);
258        // When security == benchmark, all ARs ≈ 0, CARs ≈ 0
259        for ar in &result.abnormal_returns {
260            assert!(ar.car.abs() < 1e-10, "CAR should be ~0 when security==benchmark");
261        }
262    }
263
264    #[test]
265    fn test_abnormal_return_equals_raw_minus_expected() {
266        let prices: Vec<(u64, f64)> = (0..20u64).map(|i| (1_000_000 + i * 86_400, 100.0 + i as f64)).collect();
267        let bench: Vec<(u64, f64)>  = (0..20u64).map(|i| (1_000_000 + i * 86_400, 100.0 + i as f64 * 0.5)).collect();
268        let event_ts = 1_000_000 + 10 * 86_400;
269        let event = make_event(event_ts);
270        let window = EventWindow { pre_days: -2, post_days: 2 };
271        let result = EventStudy::compute(&event, &prices, &bench, window);
272        for ar in &result.abnormal_returns {
273            let diff = (ar.raw_return - ar.expected_return - ar.abnormal_return).abs();
274            assert!(diff < 1e-12, "AR identity failed on day {}", ar.day);
275        }
276    }
277
278    #[test]
279    fn test_car_monotone_with_window_start() {
280        let prices = synthetic_prices(25, 12, 0.002, 0.004);
281        let bench = synthetic_prices(25, 12, 0.001, 0.002);
282        let event_ts = prices[12].0;
283        let event = make_event(event_ts);
284        let window = EventWindow { pre_days: -5, post_days: 5 };
285        let result = EventStudy::compute(&event, &prices, &bench, window);
286        // CAR should equal sum of prior ARs
287        let mut running = 0.0f64;
288        for ar in &result.abnormal_returns {
289            running += ar.abnormal_return;
290            assert!((ar.car - running).abs() < 1e-12, "CAR mismatch at day {}", ar.day);
291        }
292    }
293
294    #[test]
295    fn test_car_pre_and_post_split() {
296        let prices = synthetic_prices(25, 12, 0.001, 0.002);
297        let bench = synthetic_prices(25, 12, 0.0005, 0.001);
298        let event_ts = prices[12].0;
299        let event = make_event(event_ts);
300        let window = EventWindow { pre_days: -5, post_days: 5 };
301        let result = EventStudy::compute(&event, &prices, &bench, window);
302        let manual_pre: f64 = result.abnormal_returns.iter()
303            .filter(|ar| ar.day >= -5 && ar.day < 0)
304            .map(|ar| ar.abnormal_return)
305            .sum();
306        let manual_post: f64 = result.abnormal_returns.iter()
307            .filter(|ar| ar.day >= 1 && ar.day <= 5)
308            .map(|ar| ar.abnormal_return)
309            .sum();
310        assert!((result.car_pre - manual_pre).abs() < 1e-12);
311        assert!((result.car_post - manual_post).abs() < 1e-12);
312    }
313
314    #[test]
315    fn test_peak_day_is_highest_car() {
316        let prices = synthetic_prices(25, 12, 0.003, 0.001);
317        let bench = synthetic_prices(25, 12, 0.001, 0.001);
318        let event_ts = prices[12].0;
319        let event = make_event(event_ts);
320        let window = EventWindow { pre_days: -5, post_days: 5 };
321        let result = EventStudy::compute(&event, &prices, &bench, window);
322        let max_car = result.abnormal_returns.iter().map(|ar| ar.car).fold(f64::NEG_INFINITY, f64::max);
323        let peak_car = result.abnormal_returns.iter().find(|ar| ar.day == result.peak_day).map(|ar| ar.car).unwrap_or(0.0);
324        assert!((peak_car - max_car).abs() < 1e-12);
325    }
326
327    #[test]
328    fn test_trough_day_is_lowest_car() {
329        let prices = synthetic_prices(25, 12, -0.001, 0.003);
330        let bench = synthetic_prices(25, 12, 0.001, 0.001);
331        let event_ts = prices[12].0;
332        let event = make_event(event_ts);
333        let window = EventWindow { pre_days: -5, post_days: 5 };
334        let result = EventStudy::compute(&event, &prices, &bench, window);
335        let min_car = result.abnormal_returns.iter().map(|ar| ar.car).fold(f64::INFINITY, f64::min);
336        let trough_car = result.abnormal_returns.iter().find(|ar| ar.day == result.trough_day).map(|ar| ar.car).unwrap_or(0.0);
337        assert!((trough_car - min_car).abs() < 1e-12);
338    }
339
340    #[test]
341    fn test_significance_zero_for_less_than_two() {
342        let event = make_event(1_000_000);
343        let prices = synthetic_prices(20, 10, 0.001, 0.002);
344        let bench = synthetic_prices(20, 10, 0.001, 0.002);
345        let window = EventWindow { pre_days: -3, post_days: 3 };
346        let result = EventStudy::compute(&event, &prices, &bench, window);
347        assert_eq!(EventStudy::significance(&[result]), 0.0);
348        assert_eq!(EventStudy::significance(&[]), 0.0);
349    }
350
351    #[test]
352    fn test_significance_positive_when_cars_positive() {
353        let mut results = Vec::new();
354        for i in 0..5 {
355            // Security consistently outperforms benchmark
356            let prices: Vec<(u64, f64)> = (0..20u64)
357                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (1.01f64).powi(j as i32)))
358                .collect();
359            let bench: Vec<(u64, f64)> = (0..20u64)
360                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (1.005f64).powi(j as i32)))
361                .collect();
362            let event_ts = 1_000_000 + i * 1_000_000 + 10 * 86_400;
363            let event = make_event(event_ts);
364            let window = EventWindow { pre_days: -3, post_days: 3 };
365            results.push(EventStudy::compute(&event, &prices, &bench, window));
366        }
367        let t = EventStudy::significance(&results);
368        assert!(t > 0.0, "t-statistic should be positive when CAR is consistently positive");
369    }
370
371    #[test]
372    fn test_significance_negative_when_cars_negative() {
373        let mut results = Vec::new();
374        for i in 0..5 {
375            // Security consistently underperforms benchmark
376            let prices: Vec<(u64, f64)> = (0..20u64)
377                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (0.99f64).powi(j as i32)))
378                .collect();
379            let bench: Vec<(u64, f64)> = (0..20u64)
380                .map(|j| (1_000_000 + i * 1_000_000 + j * 86_400, 100.0 * (1.005f64).powi(j as i32)))
381                .collect();
382            let event_ts = 1_000_000 + i * 1_000_000 + 10 * 86_400;
383            let event = make_event(event_ts);
384            let window = EventWindow { pre_days: -3, post_days: 3 };
385            results.push(EventStudy::compute(&event, &prices, &bench, window));
386        }
387        let t = EventStudy::significance(&results);
388        assert!(t < 0.0, "t-statistic should be negative when CAR is consistently negative");
389    }
390
391    #[test]
392    fn test_day_range_in_window() {
393        let prices = synthetic_prices(30, 15, 0.001, 0.002);
394        let bench = synthetic_prices(30, 15, 0.001, 0.002);
395        let event_ts = prices[15].0;
396        let event = make_event(event_ts);
397        let window = EventWindow { pre_days: -10, post_days: 10 };
398        let result = EventStudy::compute(&event, &prices, &bench, window);
399        let days: Vec<i32> = result.abnormal_returns.iter().map(|ar| ar.day).collect();
400        assert!(days.contains(&-10));
401        assert!(days.contains(&0));
402        assert!(days.contains(&10));
403    }
404
405    #[test]
406    fn test_event_fields_preserved() {
407        let prices = synthetic_prices(20, 10, 0.001, 0.002);
408        let bench = synthetic_prices(20, 10, 0.001, 0.002);
409        let event_ts = prices[10].0;
410        let event = make_event(event_ts);
411        let window = EventWindow { pre_days: -2, post_days: 2 };
412        let result = EventStudy::compute(&event, &prices, &bench, window);
413        assert_eq!(result.event.event_id, "EVT001");
414        assert_eq!(result.event.event_type, "earnings");
415    }
416
417    #[test]
418    fn test_significance_t_stat_formula() {
419        // Manually construct results with known CARs
420        fn dummy_result(car: f64) -> EventResult {
421            EventResult {
422                event: make_event(1_000_000),
423                car_pre: car / 2.0,
424                car_post: car / 2.0,
425                peak_day: 1,
426                trough_day: -1,
427                abnormal_returns: vec![],
428            }
429        }
430        let cars = [0.02, 0.03, 0.025, 0.018, 0.022];
431        let results: Vec<EventResult> = cars.iter().map(|&c| dummy_result(c)).collect();
432        let t = EventStudy::significance(&results);
433        // Mean ≈ 0.023, should yield a significant positive t
434        assert!(t > 1.0, "t-stat = {t}, expected > 1");
435    }
436
437    #[test]
438    fn test_zero_price_series_gives_zero_returns() {
439        // prices of 0 should not panic, returns default to 0
440        let prices: Vec<(u64, f64)> = vec![(1_000_000, 0.0), (1_086_400, 0.0)];
441        let bench: Vec<(u64, f64)>  = vec![(1_000_000, 100.0), (1_086_400, 101.0)];
442        let event = make_event(1_000_000);
443        let window = EventWindow { pre_days: -1, post_days: 1 };
444        // Should not panic
445        let _ = EventStudy::compute(&event, &prices, &bench, window);
446    }
447
448    #[test]
449    fn test_asymmetric_window() {
450        let prices = synthetic_prices(30, 15, 0.001, 0.002);
451        let bench = synthetic_prices(30, 15, 0.001, 0.002);
452        let event_ts = prices[15].0;
453        let event = make_event(event_ts);
454        let window = EventWindow { pre_days: -2, post_days: 8 };
455        let result = EventStudy::compute(&event, &prices, &bench, window);
456        assert_eq!(result.abnormal_returns.len(), 11); // -2..=8 = 11 days
457    }
458
459    #[test]
460    fn test_significance_all_identical_cars_returns_zero() {
461        fn dummy_result(car: f64) -> EventResult {
462            EventResult {
463                event: make_event(1_000_000),
464                car_pre: car,
465                car_post: 0.0,
466                peak_day: 0,
467                trough_day: 0,
468                abnormal_returns: vec![],
469            }
470        }
471        // All identical → std_dev = 0 → return 0
472        let results: Vec<EventResult> = [0.01, 0.01, 0.01].iter().map(|&c| dummy_result(c)).collect();
473        assert_eq!(EventStudy::significance(&results), 0.0);
474    }
475}