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"
## delreasontype n
## 1 BKPY 3087
## 2 CORQ 12495
## 3 DEEX 19
## 4 DELQ 1253
## 5 DERE 291
## 6 EQRQ 470
## 7 FING 76519
## 8 INSC 2561
## 9 LP 3276
## 10 N/A 1580
## 11 NACT 4598133
## 12 PUBI 101
## 13 SERQ 675
## 14 SHLD 1057
## 15 UNAV 244632
## 16 VIO 410
crsp_merged %>%
group_by(primaryexch) %>%
summarise(n_unique_permno = n_distinct(permno), .groups = "drop") %>%
arrange(desc(n_unique_permno))## # A tibble: 6 × 2
## primaryexch n_unique_permno
## <chr> <int>
## 1 Q 5286
## 2 R 2804
## 3 N 2586
## 4 X 1503
## 5 B 1326
## 6 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))## # A tibble: 15 × 2
## delreasontype n_unique_permno
## <chr> <int>
## 1 NACT 7043
## 2 UNAV 618
## 3 FING 362
## 4 CORQ 53
## 5 BKPY 18
## 6 LP 13
## 7 INSC 10
## 8 DELQ 4
## 9 SHLD 4
## 10 DERE 2
## 11 EQRQ 2
## 12 SERQ 2
## 13 DEEX 1
## 14 PUBI 1
## 15 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])
)## # A tibble: 1 × 3
## total_unique_permno penny_stock_permno less_than_five_permno
## <int> <int> <int>
## 1 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)
)crsp_full %>%
filter(ticker == "AAPL") %>%
select(ticker, date, dlyprc, earnings_day) %>%
arrange(date)## # A tibble: 502 × 4
## ticker date dlyprc earnings_day
## <chr> <date> <dbl> <int>
## 1 AAPL 2024-01-02 186. 0
## 2 AAPL 2024-01-03 184. 0
## 3 AAPL 2024-01-04 182. 0
## 4 AAPL 2024-01-05 181. 0
## 5 AAPL 2024-01-08 186. 0
## 6 AAPL 2024-01-09 185. 0
## 7 AAPL 2024-01-10 186. 0
## 8 AAPL 2024-01-11 186. 0
## 9 AAPL 2024-01-12 186. 0
## 10 AAPL 2024-01-16 184. 0
## # ℹ 492 more rows
## tic rdq earnings_day
## 1 AAPL 2024-08-01 1
## 2 AAPL 2024-10-31 1
## 3 AAPL 2025-01-30 1
## 4 AAPL 2025-05-01 1
## 5 AAPL 2025-07-31 1
## 6 AAPL 2025-10-30 1
## 7 AAPL 2026-01-29 1
## 8 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(dt_events$beta)## Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
## -2.3227 0.5922 0.9312 0.9937 1.3189 4.9880 473
## [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).
## 1% 99%
## -0.1461112 2.8800285
dt_events[, beta_winsorized := pmin(pmax(beta, beta_bounds[1]), beta_bounds[2])]
summary(dt_events$beta_winsorized)## Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
## -0.1461 0.5922 0.9312 0.9905 1.3189 2.8800 473
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
## Min. 1st Qu. Median Mean 3rd Qu. Max. NA's
## -0.1703 -0.0030 0.0007 0.0006 0.0053 0.2781 18965
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_m30_p10 car_m30_p5 car_m30_p1 car_m30_0 car_m30_m1
## Min. -1.326896228 -1.30988828 -1.40136815 -1.40111711 -0.97162508
## 1st Qu. -0.109000987 -0.10823307 -0.10456340 -0.09366078 -0.08468670
## Median -0.015251021 -0.01639583 -0.01803726 -0.01654803 -0.01564227
## Mean -0.007330889 -0.01010472 -0.01191419 -0.01095387 -0.01060181
## 3rd Qu. 0.081270110 0.07608144 0.07060155 0.05907018 0.05212115
## Max. 2.930553560 3.25933562 2.48127384 2.62481195 2.64498600
## NA's 474.000000000 474.00000000 474.00000000 474.00000000 474.00000000
## car_m30_m10 car_m30_m20
## Min. -1.018295376 -0.920343500
## 1st Qu. -0.068111984 -0.047869644
## Median -0.009431167 -0.007355011
## Mean -0.003132475 -0.003108649
## 3rd Qu. 0.049215275 0.034240266
## Max. 2.152064427 1.486948773
## NA's 474.000000000 473.000000000
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")Treatment = 1 if a ticker is covered by a polymarket prediction
market, based on event_ticker_identified.csv.
polymarket has 143 rows but only 130 distinct
ticker_symbol values; the duplicates are the same ticker
listed under different company-name spellings (e.g. “Apple Inc.”
vs. “Apple”, both AAPL). Collapse to one row per ticker before merging
so treatment stays a clean 0/1 flag.
polymarket_tickers <- polymarket %>%
distinct(ticker_symbol) %>%
mutate(treatment = 1L)
nrow(polymarket_tickers)## [1] 130
car_wide <- car_wide %>%
as_tibble() %>%
left_join(polymarket_tickers, by = c("ticker" = "ticker_symbol")) %>%
mutate(treatment = coalesce(treatment, 0L)) %>%
as.data.table()
car_wide %>% count(treatment)## treatment n
## <int> <int>
## 1: 0 14158
## 2: 1 510
## [1] 127
Three polymarket tickers (TWTR, CBOE, BK) never matched. TWTR and
CBOE never appear in the raw CRSP pull at all (TWTR was delisted/taken
private in 2022, before this sample begins). BK does appear in
crsp_merged and passes the exchange/price filters, but
never has a matched earnings date in earnings_dates, so it
was dropped in the “Remove tickers with no earnings coverage” step —
likely a ticker-linkage gap between the CRSP and earnings data sources
rather than a problem with BK itself.
for (tk in c("TWTR", "CBOE", "BK")) {
cat(tk, ": in crsp_merged =", tk %in% crsp_merged$ticker,
"| in crsp_full =", tk %in% crsp_full$ticker,
"| in dt_events (2025) =", tk %in% dt_events$ticker, "\n")
}## TWTR : in crsp_merged = FALSE | in crsp_full = FALSE | in dt_events (2025) = FALSE
## CBOE : in crsp_merged = FALSE | in crsp_full = FALSE | in dt_events (2025) = FALSE
## BK : in crsp_merged = TRUE | in crsp_full = FALSE | in dt_events (2025) = FALSE
Restrict to earnings events in the first two quarters of 2025
(Jan-Jun), and compare CAR between treated (polymarket-covered) and
untreated firms across all seven windows above. Penny stock tickers have
already been excluded upstream, before beta estimation, so
dt_events / event_window /
car_wide here all reflect that exclusion.
car_h1 <- car_wide %>%
filter(event_date >= as.Date("2025-01-01"), event_date <= as.Date("2025-06-30"))
nrow(car_h1)## [1] 7193
## Index: <treatment>
## treatment n
## <int> <int>
## 1: 0 6937
## 2: 1 256
ggplot(car_h1 %>% filter(!is.na(car_m30_p1)) %>%
mutate(treatment = factor(treatment, labels = c("untreated", "treated"))),
aes(x = treatment, y = car_m30_p1)) +
geom_boxplot() +
labs(x = "Treatment", y = "CAR [-30,+1]")Group sizes and variances differ substantially by treatment for every window (untreated firms have much larger SD/outliers even after excluding penny stocks), so Welch’s t-test (unequal variances) is used for each comparison, along with a Wilcoxon rank-sum test as a robustness check against remaining skew.
windows_all <- c("car_m30_m20", "car_m30_m10", "car_m30_0", "car_m30_p1", "car_m30_p5", "car_m30_p10")
run_h1_test <- function(win) {
dat <- car_h1 %>%
filter(!is.na(.data[[win]])) %>%
mutate(treatment = factor(treatment, levels = c(0, 1), labels = c("untreated", "treated")))
summ <- dat %>%
group_by(treatment) %>%
summarise(n = n(), mean = mean(.data[[win]]), sd = sd(.data[[win]]),
median = median(.data[[win]]), min = min(.data[[win]]), max = max(.data[[win]]),
.groups = "drop")
tt <- t_test(dat, as.formula(paste(win, "~ treatment")), order = c("treated", "untreated"))
wt <- wilcox.test(as.formula(paste(win, "~ treatment")), data = dat)
list(window = win, summary = summ, t_test = tt, wilcox_p = wt$p.value)
}
results <- lapply(windows_all, run_h1_test)
names(results) <- windows_allsummary_table <- do.call(rbind, lapply(windows_all, function(w) {
r <- results[[w]]
tt <- r$t_test
data.frame(
window = w,
welch_t = round(tt$statistic, 2),
df = round(tt$t_df, 0),
p_t = round(tt$p_value, 3),
p_wilcox = round(r$wilcox_p, 3),
mean_diff = round(tt$estimate, 4)
)
}))
knitr::kable(summary_table, row.names = FALSE,
col.names = c("Window", "Welch t", "df", "p (t-test)", "p (Wilcoxon)", "Mean diff (treated - untreated)"))| Window | Welch t | df | p (t-test) | p (Wilcoxon) | Mean diff (treated - untreated) |
|---|---|---|---|---|---|
| car_m30_m20 | -0.38 | 321 | 0.701 | 0.530 | -0.0014 |
| car_m30_m10 | -1.37 | 308 | 0.173 | 0.173 | -0.0069 |
| car_m30_0 | 1.10 | 313 | 0.273 | 0.353 | 0.0067 |
| car_m30_p1 | 1.10 | 321 | 0.272 | 0.339 | 0.0072 |
| car_m30_p5 | 1.15 | 330 | 0.249 | 0.353 | 0.0076 |
| car_m30_p10 | 0.87 | 326 | 0.387 | 0.373 | 0.0060 |
After excluding penny stocks, none of the six windows show a statistically significant treated/untreated difference, and the effects are noticeably weaker than they were before that exclusion — e.g. CAR[-30,+1]’s mean difference shrinks from +0.0115 (Welch p = 0.097, before excluding penny stocks) to +0.0072 (Welch p = 0.271, after). This suggests some of the earlier marginal signal was driven by the more extreme volatility of penny stocks in the untreated group rather than a genuine treatment effect. The two pre-announcement placebo windows ([-30,-20], [-30,-10]) show essentially no difference, while the windows that include or follow the earnings date show a small, consistently positive (but not statistically significant) gap favoring treated firms. With six comparisons run here and none individually clearing p < 0.05 even before considering multiple-comparison correction, this pattern should be read as suggestive at most, not evidence of a treatment effect.