This document builds a CRSP earnings-announcement event study for 2024-2025 daily stock data. It:
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.
# 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"
## [1] "date" "mktrf" "smb" "hml" "rf" "umd"
crsp_merged %>%
count(delreasontype) %>%
nice_table("Table 1. Delisting reason codes and record counts",
digits = 0)| 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)| 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.
# 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)| 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 |
## [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 %>%
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)| total_unique_permno | penny_stock_permno | less_than_five_permno |
|---|---|---|
| 8074 | 1503 | 2811 |
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)))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)
)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)| 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)| 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.
# 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
## [1] 4837
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
## [1] 14668
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)| 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 |
## [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)| 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)| 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 |
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)| 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 |
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)| 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 |
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")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"))| 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.
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"))| 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.
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"))| 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 |
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"))| 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"))| 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.
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.