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. Merges in a treatment indicator (whether a ticker is covered by a polymarket prediction market) and tests whether CAR differs between treated and untreated firms in the first half of 2025.

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)
##    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.

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))
## # 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
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])
  )
## # A tibble: 1 × 3
##   total_unique_permno penny_stock_permno less_than_five_permno
##                 <int>              <int>                 <int>
## 1                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

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
earnings_dates %>%
  filter(tic == "AAPL")
##    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.

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(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
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)
beta_bounds
##         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

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(event_window$capm_return)
##    Min. 1st Qu.  Median    Mean 3rd Qu.    Max.    NA's 
## -0.1703 -0.0030  0.0007  0.0006  0.0053  0.2781   18965

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)]
##           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

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")

Treatment Analysis: Polymarket Coverage

Load and merge treatment identifier

Treatment = 1 if a ticker is covered by a polymarket prediction market, based on event_ticker_identified.csv.

polymarket <- read.csv("data_polymarket/event_ticker_identified.csv", check.names = TRUE)

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
n_distinct(car_wide[treatment == 1, ticker])
## [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

Treated vs. untreated CAR, H1 2025

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
car_h1 %>% count(treatment)
## 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_all
summary_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.