Overview

This document builds a CRSP earnings-announcement event study for 2024-2025 daily stock data. It:

  1. Cleans and merges raw CRSP daily prices, Fama-French factors, and earnings announcement dates.
  2. Estimates a pre-earnings market-model beta for each earnings event and uses it to compute CAPM expected returns and cumulative abnormal returns (CAR) around the earnings date.
  3. Summarizes the resulting sample (event counts, quarterly breakdown) and compares CAR across market cap categories.

Data covers 01/01/2024 to 12/31/2025. All file paths are relative to this document’s own directory, so no setwd() is required to knit or deploy this report.

Data Preparation

Load and merge CRSP and Fama-French daily data

crsp_raw <- read.csv("data_polymarket/crsp_raw.csv", check.names = TRUE)
# Standardize variable names
colnames(crsp_raw) <- make.names(tolower(colnames(crsp_raw)))
names(crsp_raw)
##  [1] "permno"        "hdrcusip"      "cusip"         "primaryexch"  
##  [5] "securitynm"    "delreasontype" "ticker"        "permco"       
##  [9] "siccd"         "dlycaldt"      "dlydelflg"     "dlyprc"       
## [13] "dlyret"        "dlyvol"        "dlybid"        "dlyask"       
## [17] "dlynumtrd"     "shrout"        "disexdt"       "disdeclaredt" 
## [21] "disrecorddt"   "dispaydt"      "vwretx"        "ewretx"
crsp_raw <- crsp_raw |>
  rename(date = dlycaldt)
fama_raw <- read.csv("data_polymarket/fama_raw.csv", check.names = TRUE)
names(fama_raw)
## [1] "date"  "mktrf" "smb"   "hml"   "rf"    "umd"
crsp_merged <- crsp_raw |>
  left_join(fama_raw, by = "date")
# Market return = risk-free rate + excess market return
crsp_merged <- crsp_merged %>%
  mutate(market_return = mktrf + rf)

Filter to major exchanges and clean prices

crsp_merged %>%
  count(delreasontype) %>%
  nice_table("Table 1. Delisting reason codes and record counts",
             digits = 0)
Table 1. Delisting reason codes and record counts
delreasontype n
BKPY 3087
CORQ 12495
DEEX 19
DELQ 1253
DERE 291
EQRQ 470
FING 76519
INSC 2561
LP 3276
N/A 1580
NACT 4598133
PUBI 101
SERQ 675
SHLD 1057
UNAV 244632
VIO 410
crsp_merged %>%
  group_by(primaryexch) %>%
  summarise(n_unique_permno = n_distinct(permno), .groups = "drop") %>%
  arrange(desc(n_unique_permno)) %>%
  nice_table("Table 2. Unique firms by primary exchange (before exchange filter)",
             digits = 0)
Table 2. Unique firms by primary exchange (before exchange filter)
primaryexch n_unique_permno
Q 5286
R 2804
N 2586
X 1503
B 1326
A 338

Exchange codes: N = NYSE, A = NYSE American (AMEX), Q = NASDAQ. Restrict the sample to these three major exchanges.

crsp_merged <- crsp_merged %>%
  filter(primaryexch %in% c("N", "A", "Q"))
# Confirm the exchange filter didn't materially change the mix of
# delisting reasons remaining in the sample
crsp_merged %>%
  group_by(delreasontype) %>%
  summarise(n_unique_permno = n_distinct(permno), .groups = "drop") %>%
  arrange(desc(n_unique_permno)) %>%
  nice_table("Table 3. Unique firms by delisting reason, after exchange filter",
             digits = 0)
Table 3. Unique firms by delisting reason, after exchange filter
delreasontype n_unique_permno
NACT 7043
UNAV 618
FING 362
CORQ 53
BKPY 18
LP 13
INSC 10
DELQ 4
SHLD 4
DERE 2
EQRQ 2
SERQ 2
DEEX 1
PUBI 1
VIO 1
negative_price <- crsp_merged %>%
  filter(dlyprc < 0)

nrow(negative_price)
## [1] 0

No observations have negative prices. Permnos with any missing daily price are dropped, since a gap in the price series would break the trading-day indexing used later in the event study.

crsp_merged <- crsp_merged %>%
  group_by(permno) %>%
  filter(!any(is.na(dlyprc))) %>%
  ungroup()
crsp_merged <- crsp_merged %>%
  mutate(
    penny_stock = if_else(abs(dlyprc) < 1, 1L, 0L),
    less_than_five_stock = if_else(abs(dlyprc) < 5, 1L, 0L)
  )

crsp_merged %>%
  summarise(
    total_unique_permno = n_distinct(permno),
    penny_stock_permno = n_distinct(permno[penny_stock == 1]),
    less_than_five_permno = n_distinct(permno[less_than_five_stock == 1])
  ) %>%
  nice_table("Table 4. Firm counts by price category",
             digits = 0)
Table 4. Firm counts by price category
total_unique_permno penny_stock_permno less_than_five_permno
8074 1503 2811
crsp_merged <- crsp_merged %>% 
  mutate(market_cap = shrout * dlyprc)

Merge earnings announcement dates

Earnings announcement dates come from earning_date.csv.

crsp_q <- read.csv("data_polymarket/earning_date.csv", check.names = TRUE)

colnames(crsp_q) <- make.names(tolower(colnames(crsp_q)))
crsp_q <- crsp_q %>%
  mutate(rdq = as.Date(rdq))
earnings_dates <- crsp_q %>%
  select(tic, rdq) %>%
  filter(!is.na(rdq)) %>%
  distinct(tic, rdq) %>%
  mutate(earnings_day = 1L)
crsp_merged <- crsp_merged %>%
  mutate(date = as.Date(date))

crsp_full <- crsp_merged %>%
  left_join(
    earnings_dates,
    by = c("ticker" = "tic",
           "date" = "rdq")
  ) %>%
  mutate(
    earnings_day = coalesce(earnings_day, 0L)
  )

Manual verification: AAPL merge

aapl_check <- crsp_full %>%
  filter(ticker == "AAPL") %>%
  select(ticker, date, dlyprc, earnings_day) %>%
  arrange(date)

# Full daily series is long (~500 rows); show the earnings-flagged days
# plus a handful of surrounding rows as a representative sample
aapl_check %>%
  filter(earnings_day == 1 | date %in% (aapl_check %>% slice_head(n = 5) %>% pull(date))) %>%
  nice_table("Table 5. AAPL daily prices and earnings-day flags (sample rows: first 5 days plus every flagged earnings date)",
             digits = 2)
Table 5. AAPL daily prices and earnings-day flags (sample rows: first 5 days plus every flagged earnings date)
ticker date dlyprc earnings_day
AAPL 2024-01-02 185.64 0
AAPL 2024-01-03 184.25 0
AAPL 2024-01-04 181.91 0
AAPL 2024-01-05 181.18 0
AAPL 2024-01-08 185.56 0
AAPL 2024-08-01 218.36 1
AAPL 2024-10-31 225.91 1
AAPL 2025-01-30 237.59 1
AAPL 2025-05-01 213.32 1
AAPL 2025-07-31 207.57 1
AAPL 2025-10-30 271.40 1
earnings_dates %>%
  filter(tic == "AAPL") %>%
  nice_table("Table 6. AAPL earnings announcement dates", digits = 0)
Table 6. AAPL earnings announcement dates
tic rdq earnings_day
AAPL 2024-08-01 1
AAPL 2024-10-31 1
AAPL 2025-01-30 1
AAPL 2025-05-01 1
AAPL 2025-07-31 1
AAPL 2025-10-30 1
AAPL 2026-01-29 1
AAPL 2026-04-30 1

AAPL’s flagged earnings dates in crsp_full line up exactly with the dates in earnings_dates, confirming the merge is correct.

Remove tickers with no earnings coverage

# Tickers with no earnings_day flag anywhere in the sample (e.g. ETFs,
# SPACs, or tickers absent from the earnings dataset)
no_earnings_tickers <- crsp_full %>%
  group_by(ticker) %>%
  summarise(any_earnings = any(earnings_day == 1), .groups = "drop") %>%
  filter(!any_earnings) %>%
  pull(ticker)

length(no_earnings_tickers)
## [1] 3505
crsp_full <- crsp_full %>%
  filter(!ticker %in% no_earnings_tickers)

n_distinct(crsp_full$ticker)
## [1] 4837

Event Study Construction

Sample restrictions

A per-ticker trading-day index is built first, since [-150,-30] and [-30,+10] windows are defined in trading days, not calendar days.

# One "ticker" trades under the literal ticker symbol "NA" (Nano Labs
# Ltd), which read.csv() silently parsed as a missing value. Dropped
# from analysis.
dt_full <- as.data.table(crsp_full)
dt_full <- dt_full[!is.na(ticker)]
setorder(dt_full, ticker, date)
dt_full[, trading_day := seq_len(.N), by = ticker]
# One row per earnings event, with estimation-window (est) and
# event-window (evt) trading-day boundaries
dt_events <- dt_full[earnings_day == 1, .(ticker, event_date = date, trading_day)]
dt_events[, event_id := .I]
dt_events[, `:=`(
  est_start = trading_day - 150,
  est_end   = trading_day - 30,
  evt_start = trading_day - 30,
  evt_end   = trading_day + 10
)]

nrow(dt_events)
## [1] 26404
# Drop the NXTT event (2025-05-09): dlyret == 6.61 on the earnings date
# itself, a +661% one-day return that dominates every CAR window it
# enters regardless of beta. Treated as an extreme outlier and excluded.
dt_events <- dt_events[!(ticker == "NXTT" & event_date == as.Date("2025-05-09"))]

nrow(dt_events)
## [1] 26403
# Restrict to earnings events occurring in 2025. dt_full itself still
# spans 2024-2025 so estimation windows for early-2025 events can still
# reach back into 2024 trading days.
dt_events <- dt_events[lubridate::year(event_date) == 2025]

nrow(dt_events)
## [1] 17955
# Exclude penny stock firms: any ticker with at least one day where
# abs(dlyprc) < $1 anywhere in the sample, using the `penny_stock` flag
# on crsp_full. ~18% of 2025 events belong to a penny stock ticker.
penny_tickers <- crsp_full %>%
  filter(penny_stock == 1) %>%
  distinct(ticker) %>%
  pull(ticker)

length(penny_tickers)
## [1] 996
dt_events <- dt_events[!(ticker %in% penny_tickers)]

nrow(dt_events)
## [1] 14668

Estimate pre-earnings beta

Beta is estimated per earnings event as Cov(dlyret, market_return) / Var(market_return) over a [-150,-30] trading-day window before the earnings date. That beta is later used to compute expected returns over the [-30,+10] event window.

setkey(dt_full, ticker, trading_day)

# Non-equi join: all trading days in each event's estimation window
est_window <- dt_full[dt_events,
  on = .(ticker, trading_day >= est_start, trading_day <= est_end),
  .(event_id = i.event_id, dlyret = x.dlyret, market_return = x.market_return),
  allow.cartesian = TRUE]

betas <- est_window[, {
  ok <- complete.cases(dlyret, market_return)
  if (sum(ok) < 2) {
    list(beta = NA_real_, n_obs = sum(ok))
  } else {
    list(beta = cov(dlyret[ok], market_return[ok]) / var(market_return[ok]), n_obs = sum(ok))
  }
}, by = event_id]

dt_events <- betas[dt_events, on = "event_id"]
# A full window has 121 trading days (-150 to -30 inclusive). Events
# with fewer observations (mostly stocks without 150 days of trading
# history before their earnings date, e.g. recent IPOs) produce unstable
# beta estimates and are set to NA rather than dropped outright.
dt_events[n_obs < 121, beta := NA_real_]

summary_df(dt_events$beta) %>%
  nice_table("Table 7. Distribution of estimated pre-earnings beta", digits = 4)
Table 7. Distribution of estimated pre-earnings beta
Statistic Value
Min. -2.3227
1st Qu. 0.5922
Median 0.9312
Mean 0.9937
3rd Qu. 1.3189
Max. 4.9880
NA’s 473.0000
sum(is.na(dt_events$beta))
## [1] 473

Beta is additionally winsorized at the 1st/99th percentile to limit the influence of unstable estimates on the tails (e.g. thinly traded stocks with near-zero market-return variance in the estimation window).

beta_bounds <- quantile(dt_events$beta, probs = c(0.01, 0.99), na.rm = TRUE)

data.frame(Percentile = names(beta_bounds), Beta = as.numeric(beta_bounds)) %>%
  nice_table("Table 8. Beta winsorization bounds (1st/99th percentile)", digits = 4)
Table 8. Beta winsorization bounds (1st/99th percentile)
Percentile Beta
1% -0.1461
99% 2.8800
dt_events[, beta_winsorized := pmin(pmax(beta, beta_bounds[1]), beta_bounds[2])]

summary_df(dt_events$beta_winsorized) %>%
  nice_table("Table 9. Distribution of winsorized beta", digits = 4)
Table 9. Distribution of winsorized beta
Statistic Value
Min. -0.1461
1st Qu. 0.5922
Median 0.9312
Mean 0.9905
3rd Qu. 1.3189
Max. 2.8800
NA’s 473.0000

CAPM expected returns over the event window

For each day in the [-30,+10] event window, expected return is computed via the market model / CAPM, E[R] = rf + beta * mktrf, using each event’s (winsorized) pre-earnings beta and that day’s risk-free rate and excess market return. Events with NA beta (incomplete estimation window) carry through as NA.

event_window <- dt_full[dt_events,
  on = .(ticker, trading_day >= evt_start, trading_day <= evt_end),
  .(event_id = i.event_id, ticker, event_date = i.event_date,
    date = x.date, trading_day = x.trading_day,
    dlyret = x.dlyret, rf = x.rf, mktrf = x.mktrf, market_return = x.market_return),
  allow.cartesian = TRUE]

# Trading-day offset relative to the earnings date, for alignment/plots
event_window[dt_events, event_offset := trading_day - i.trading_day, on = "event_id"]

# Use dt_events' winsorized, NA-cleaned beta
event_window <- dt_events[, .(event_id, beta = beta_winsorized)][event_window, on = "event_id"]

event_window[, capm_return := rf + beta * mktrf]

nrow(event_window)
## [1] 600738
summary_df(event_window$capm_return) %>%
  nice_table("Table 10. Distribution of CAPM-expected daily returns", digits = 4)
Table 10. Distribution of CAPM-expected daily returns
Statistic Value
Min. -0.1703
1st Qu. -0.0030
Median 0.0007
Mean 0.0006
3rd Qu. 0.0053
Max. 0.2781
NA’s 18965.0000

Cumulative abnormal returns (CAR)

Daily abnormal return is dlyret - capm_return. CAR is the sum of daily abnormal returns within a window, computed for seven windows relative to the earnings date: two pre-announcement placebo windows ([-30,-20], [-30,-10]) and five windows spanning or following the announcement ([-30,-1], [-30,0], [-30,+1], [-30,+5], [-30,+10]). If any day within a window is missing (e.g. NA beta, or a gap at the edge of the ticker’s trading history), that window’s CAR is set to NA rather than computed on a partial window.

The most extreme realized CAR values are driven by a handful of stocks with very large single-day returns around the earnings date (e.g. NXTT’s +661% one-day return, already excluded above), not by unstable beta — winsorizing beta had negligible effect on the width of the CAR distribution, so these realized returns are left as-is.

event_window[, abnormal_return := dlyret - capm_return]

car_windows <- list(
  car_m30_p10 = c(-30, 10),
  car_m30_p5  = c(-30, 5),
  car_m30_p1  = c(-30, 1),
  car_m30_0   = c(-30, 0),
  car_m30_m1  = c(-30, -1),
  car_m30_m10 = c(-30, -10),
  car_m30_m20 = c(-30, -20)
)

car_list <- lapply(names(car_windows), function(nm) {
  bounds <- car_windows[[nm]]
  event_window[event_offset >= bounds[1] & event_offset <= bounds[2],
    .(car = sum(abnormal_return), n_days = .N, n_missing = sum(is.na(abnormal_return))),
    by = event_id
  ][, window := nm]
})

car_long <- rbindlist(car_list)
car_long[n_missing > 0, car := NA_real_]

car_wide <- dcast(car_long, event_id ~ window, value.var = "car")
car_wide <- dt_events[, .(event_id, ticker, event_date, beta = beta_winsorized)][car_wide, on = "event_id"]

nrow(car_wide)
## [1] 14668
car_wide[, sapply(.SD, summary), .SDcols = names(car_windows)] %>%
  as.data.frame() %>%
  tibble::rownames_to_column("Statistic") %>%
  nice_table("Table 11. CAR summary statistics across the seven event windows", digits = 4)
Table 11. CAR summary statistics across the seven event windows
Statistic car_m30_p10 car_m30_p5 car_m30_p1 car_m30_0 car_m30_m1 car_m30_m10 car_m30_m20
Min. -1.3269 -1.3099 -1.4014 -1.4011 -0.9716 -1.0183 -0.9203
1st Qu. -0.1090 -0.1082 -0.1046 -0.0937 -0.0847 -0.0681 -0.0479
Median -0.0153 -0.0164 -0.0180 -0.0165 -0.0156 -0.0094 -0.0074
Mean -0.0073 -0.0101 -0.0119 -0.0110 -0.0106 -0.0031 -0.0031
3rd Qu. 0.0813 0.0761 0.0706 0.0591 0.0521 0.0492 0.0342
Max. 2.9306 3.2593 2.4813 2.6248 2.6450 2.1521 1.4869
NA’s 474.0000 474.0000 474.0000 474.0000 474.0000 474.0000 473.0000

Event-time CAR path

Average abnormal return across all 2025 events on each trading-day offset, cumulated from -30 to +10, showing the typical CAR trajectory around the earnings announcement (day 0, dashed line).

car_path <- event_window[, .(mean_ar = mean(abnormal_return, na.rm = TRUE)), by = event_offset]
setorder(car_path, event_offset)
car_path[, mean_car := cumsum(mean_ar)]

ggplot(car_path, aes(x = event_offset, y = mean_car)) +
  geom_line() +
  geom_vline(xintercept = 0, linetype = "dashed") +
  geom_hline(yintercept = 0) +
  labs(x = "Trading days relative to earnings date", y = "Mean cumulative abnormal return")

Summary Statistics

Sample size after each restriction

The working sample is built by applying the following restrictions to all earnings events with any coverage in earnings_dates, in order: dropping the single NXTT outlier event, restricting to earnings dates in 2025, and excluding penny stock tickers.

n_events_2024_2025 <- nrow(dt_full[earnings_day == 1])
n_events_no_nxtt <- n_events_2024_2025 - 1
n_events_2025_only <- sum(year(dt_full[earnings_day == 1]$date) == 2025) - 1  # minus NXTT
n_events_final <- nrow(dt_events)

funnel <- data.frame(
  stage = c(
    "Earnings events, 2024-2025 (tickers with any earnings coverage)",
    "After dropping NXTT outlier event (2025-05-09)",
    "After restricting to earnings dates in 2025",
    "After excluding penny stock tickers (final analysis sample)"
  ),
  n_events = c(n_events_2024_2025, n_events_no_nxtt, n_events_2025_only, n_events_final)
)

funnel %>%
  nice_table("Table 12. Sample size after each restriction",
             col.names = c("Restriction", "N earnings events"))
Table 12. Sample size after each restriction
Restriction N earnings events
Earnings events, 2024-2025 (tickers with any earnings coverage) 26404
After dropping NXTT outlier event (2025-05-09) 26403
After restricting to earnings dates in 2025 17955
After excluding penny stock tickers (final analysis sample) 14668

The final 2025 analysis sample contains 14668 earnings events across 3832 distinct companies.

Earnings events by quarter

dt_events[, quarter := paste0("Q", quarter(event_date), " 2025")]
car_wide[dt_events, quarter := i.quarter, on = "event_id"]

dt_events[, .(n_events = .N, n_companies = n_distinct(ticker)), by = quarter][order(quarter)] %>%
  nice_table("Table 13. Earnings events and companies reporting, by quarter",
             col.names = c("Quarter", "N earnings events", "N companies reporting"))
Table 13. Earnings events and companies reporting, by quarter
Quarter N earnings events N companies reporting
Q1 2025 3542 3508
Q2 2025 3651 3578
Q3 2025 3707 3676
Q4 2025 3768 3738

Event counts are fairly balanced across quarters, with a small number of companies reporting more than once in the same quarter (e.g. due to fiscal-year timing), so n_companies is slightly below n_events in every quarter.

CAR[-30,+1] by quarter

Summary statistics use CAR[-30,+1] as the primary window; the other six CAR windows in car_wide can be substituted the same way if a different window is of interest.

quarterly_car <- car_wide[!is.na(car_m30_p1), .(
  n = .N,
  mean = mean(car_m30_p1),
  sd = sd(car_m30_p1),
  q1 = quantile(car_m30_p1, 0.25),
  median = median(car_m30_p1),
  q3 = quantile(car_m30_p1, 0.75),
  min = min(car_m30_p1),
  max = max(car_m30_p1)
), by = quarter][order(quarter)]

quarterly_car %>%
  nice_table("Table 14. CAR[-30,+1] summary statistics, by quarter",
             digits = 4,
             col.names = c("Quarter", "N", "Mean", "SD", "Q1", "Median", "Q3", "Min", "Max"))
Table 14. CAR[-30,+1] summary statistics, by quarter
Quarter N Mean SD Q1 Median Q3 Min Max
Q1 2025 3451 -0.0080 0.1809 -0.1035 -0.0107 0.0761 -1.4014 2.3734
Q2 2025 3542 -0.0141 0.1687 -0.0979 -0.0166 0.0695 -0.8441 2.4813
Q3 2025 3585 0.0044 0.1807 -0.0905 -0.0066 0.0783 -0.8346 2.0819
Q4 2025 3616 -0.0298 0.1931 -0.1246 -0.0419 0.0547 -1.3029 2.0947

CAR[-30,+1] by market cap tier

Market cap is measured on the earnings date itself (market_cap = shrout * dlyprc from CRSP, so units are as provided by the shrout field). Firms are split into terciles (Small/Mid/Large) based on the 2025 event sample’s own market cap distribution.

event_mcap <- dt_full[dt_events, on = .(ticker, trading_day),
                       .(event_id = i.event_id, market_cap = x.market_cap)]

car_wide[event_mcap, event_market_cap := i.market_cap, on = "event_id"]

mcap_breaks <- quantile(car_wide$event_market_cap, probs = c(1/3, 2/3), na.rm = TRUE)

car_wide[, mcap_tier := cut(event_market_cap,
  breaks = c(-Inf, mcap_breaks, Inf),
  labels = c("Small", "Mid", "Large"))]

car_wide %>%
  count(mcap_tier) %>%
  nice_table("Table 15. Number of earnings events by market cap tier",
             col.names = c("Market cap tier", "N earnings events"))
Table 15. Number of earnings events by market cap tier
Market cap tier N earnings events
Small 4890
Mid 4889
Large 4889
mcap_car <- car_wide[!is.na(car_m30_p1), .(
  n = .N,
  mean = mean(car_m30_p1),
  sd = sd(car_m30_p1),
  q1 = quantile(car_m30_p1, 0.25),
  median = median(car_m30_p1),
  q3 = quantile(car_m30_p1, 0.75),
  min = min(car_m30_p1),
  max = max(car_m30_p1)
), by = mcap_tier][order(mcap_tier)]

mcap_car %>%
  nice_table("Table 16. CAR[-30,+1] summary statistics, by market cap tier",
             digits = 4,
             col.names = c("Market cap tier", "N", "Mean", "SD", "Q1", "Median", "Q3", "Min", "Max"))
Table 16. CAR[-30,+1] summary statistics, by market cap tier
Market cap tier N Mean SD Q1 Median Q3 Min Max
Small 4616 -0.0187 0.2200 -0.1318 -0.0276 0.0761 -1.4014 2.3734
Mid 4766 -0.0153 0.1782 -0.1143 -0.0237 0.0726 -1.3029 2.0819
Large 4812 -0.0021 0.1384 -0.0783 -0.0076 0.0649 -0.6381 2.4813

CAR volatility declines monotonically with firm size (SD ≈ 0.22 for Small, 0.18 for Mid, 0.14 for Large), and mean CAR is closer to zero for Large firms than for Small/Mid firms — consistent with larger, more liquid firms having less extreme earnings-day price reactions.

CAR across market cap categories

Three views of the same market-cap pattern: the distribution of CAR[-30,+1] by tier, mean CAR (with 95% CI) by tier across all seven windows, and the average cumulative abnormal return path in event time by tier.

ggplot(car_wide[!is.na(car_m30_p1)], aes(x = mcap_tier, y = car_m30_p1)) +
  geom_boxplot() +
  labs(x = "Market cap tier", y = "CAR [-30,+1]",
       title = "CAR[-30,+1] distribution by market cap tier")

mcap_by_window <- rbindlist(lapply(names(car_windows), function(w) {
  car_wide[!is.na(get(w)), .(
    window = w,
    mean_car = mean(get(w)),
    se = sd(get(w)) / sqrt(.N)
  ), by = mcap_tier]
}))

mcap_by_window[, window_label := factor(window,
  levels = rev(names(car_windows)),
  labels = c("[-30,-20]", "[-30,-10]", "[-30,-1]", "[-30,0]", "[-30,+1]", "[-30,+5]", "[-30,+10]"))]

ggplot(mcap_by_window, aes(x = window_label, y = mean_car, color = mcap_tier, group = mcap_tier)) +
  geom_point(position = position_dodge(width = 0.3)) +
  geom_errorbar(aes(ymin = mean_car - 1.96 * se, ymax = mean_car + 1.96 * se),
                width = 0.2, position = position_dodge(width = 0.3)) +
  geom_hline(yintercept = 0, linetype = "dashed") +
  labs(x = "CAR window", y = "Mean CAR (95% CI)", color = "Market cap tier",
       title = "Mean CAR by window and market cap tier")

event_window[car_wide, mcap_tier := i.mcap_tier, on = "event_id"]

car_path_by_tier <- event_window[!is.na(mcap_tier),
  .(mean_ar = mean(abnormal_return, na.rm = TRUE)), by = .(event_offset, mcap_tier)]
setorder(car_path_by_tier, mcap_tier, event_offset)
car_path_by_tier[, mean_car := cumsum(mean_ar), by = mcap_tier]

ggplot(car_path_by_tier, aes(x = event_offset, y = mean_car, color = mcap_tier)) +
  geom_line() +
  geom_vline(xintercept = 0, linetype = "dashed") +
  geom_hline(yintercept = 0) +
  labs(x = "Trading days relative to earnings date", y = "Mean cumulative abnormal return",
       color = "Market cap tier", title = "Mean CAR path by market cap tier")

Large-cap firms track close to zero throughout the window and show little reaction to the earnings date itself. Small- and mid-cap firms follow a similar downward drift into the earnings date (reaching roughly -0.015 to -0.017 by day 0) before partially recovering afterward — the two smaller tiers track each other closely, while Large is consistently different from both.