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.

Later sections (see “Treated vs. Non-Treated: Polymarket Coverage” and “Does Polymarket’s Resolved Outcome Predict the Actual Reaction?”) link this CRSP sample to Polymarket earnings-related prediction markets (classified in a companion script, poly_market_cleaning.Rmd) to ask whether Polymarket coverage, or the market’s own resolved outcome, is associated with a firm’s actual earnings-day stock reaction.

Key Results

  1. Naive Polymarket-coverage comparison is confounded, not causal. Firms with a Polymarket earnings market (“treated”) show a lower CAR[-30,+1] than nearest-neighbor-matched controls (matched on market cap, then market cap + pre-event beta). But a placebo test on CAR[-30,-1] — a window entirely before the earnings date — shows this gap already exists pre-event (paired t-test / Wilcoxon, p < 0.001). A difference-in-differences test (post-window CAR minus placebo-window CAR) confirms the netted-out earnings-day effect is not statistically distinguishable from zero (p = 0.48 / 0.89). Polymarket coverage itself has no detectable causal effect on the earnings-day reaction — the raw comparison was confounded by pre-existing differences between which firms get a Polymarket market in the first place.

  2. Polymarket’s resolved outcome does predict the actual reaction. Sidestepping the selection problem by comparing within covered firms only: those whose market resolved “beat estimate” had a mean day[0,+1] abnormal return of +0.93%, versus -2.07% for “missed estimate” (Welch t-test p = 0.033, Wilcoxon p = 0.035) — a sensible earnings-surprise effect, and not subject to the selection confound in (1) since both groups are drawn from covered firms. A parallel “up/down after earnings” signal shows an even larger gap, but that question is nearly tautological with the outcome variable itself, so it’s better read as a consistency check than new information.

  3. The beat/miss effect is asymmetric over time. Extending the window to [0,+5] and [0,+10]: “missed estimate” firms drop sharply after earnings and stay down through day +10 (a persistent negative reaction), while “beat estimate” firms get only a modest initial bump that decays back toward zero within about a week. The beat-vs-miss gap is strongest at [0,+5] (p = 0.011 / 0.033) and only marginal by [0,+10] (p = 0.051 / 0.088) — driven by the beat premium fading, not the miss reaction reversing. With only 48 “missed” events, this asymmetry is suggestive rather than conclusive.

Caveats: Polymarket’s earnings_number market coverage in this sample is concentrated in 2025 Q2-Q4 (mostly Q4), not a representative cross-section of all 2025 earnings events; the 0.95 confidence assigned to keyword-prefiltered-out, non-LLM-checked questions is a blanket value from a 300-question recall audit (0 missed positives), not a per-question judgment; and several event/CAR windows here rely on complete-case estimation windows, so events near a ticker’s IPO or with gaps in trading history are dropped rather than imputed.

Data Preparation

Load and merge CRSP and Fama-French daily data

crsp_raw <- read.csv("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("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("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
NAs 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
NAs 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
NAs 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
NAs 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.

Treated vs. Non-Treated: Polymarket Coverage

match_window <- 3

Define “treated” earnings events

A CRSP earnings event is treated if a Polymarket earnings_number market (beat/miss EPS, up-or-down after earnings — see poly_market_cleaning.Rmd) exists for the same ticker with a resolution date (market_enddate) within 3 calendar days of that event’s earnings date. This is an event-level match rather than a ticker-level match: a ticker with a Polymarket market for its Q4 earnings is only “treated” for that specific earnings event, not for its earlier-2025 events that had no corresponding market.

Polymarket’s earnings_number markets in this sample only cover 2025 Q2-Q4 (mostly Q4 — see poly_market_cleaning.Rmd), so events from earlier in 2025 are essentially guaranteed to be non-treated regardless of ticker; keep that in mind when interpreting differences below, since “treated” is confounded with “reports earnings later in the year.”

polymarket_numeric_events <- read.csv("polymarket_numeric_events.csv", check.names = TRUE) %>%
  mutate(market_enddate = as.Date(market_enddate))

pm_num <- as.data.table(polymarket_numeric_events)

car_wide[, `:=`(
  event_date_lo = event_date - match_window,
  event_date_hi = event_date + match_window
)]

treated_matches <- pm_num[car_wide,
  on = .(ticker, market_enddate >= event_date_lo, market_enddate <= event_date_hi),
  .(event_id = i.event_id),
  nomatch = NULL,
  allow.cartesian = TRUE]

treated_ids <- unique(treated_matches$event_id)
car_wide[, treated := event_id %in% treated_ids]
car_wide[, c("event_date_lo", "event_date_hi") := NULL]

car_wide %>%
  count(treated, mcap_tier) %>%
  nice_table("Table 17. Earnings events by treatment status and market cap tier",
             col.names = c("Treated (Polymarket-covered)", "Market cap tier", "N earnings events"))
Table 17. Earnings events by treatment status and market cap tier
Treated (Polymarket-covered) Market cap tier N earnings events
FALSE Small 4885
FALSE Mid 4841
FALSE Large 4679
TRUE Small 5
TRUE Mid 48
TRUE Large 210

CAR[-30,+1]: treated vs. non-treated, by market cap tier

treated_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, treated)][order(mcap_tier, treated)]

treated_car %>%
  nice_table("Table 18. CAR[-30,+1] summary statistics, by market cap tier and treatment status",
             digits = 4,
             col.names = c("Market cap tier", "Treated", "N", "Mean", "SD", "Q1", "Median", "Q3", "Min", "Max"))
Table 18. CAR[-30,+1] summary statistics, by market cap tier and treatment status
Market cap tier Treated N Mean SD Q1 Median Q3 Min Max
Small FALSE 4611 -0.0189 0.2197 -0.1318 -0.0276 0.0755 -1.4014 2.3734
Small TRUE 5 0.2051 0.4314 -0.0651 0.1073 0.1864 -0.1430 0.9402
Mid FALSE 4720 -0.0151 0.1778 -0.1138 -0.0234 0.0726 -1.3029 2.0819
Mid TRUE 46 -0.0381 0.2186 -0.1681 -0.0695 0.0815 -0.4599 0.4674
Large FALSE 4607 -0.0012 0.1388 -0.0776 -0.0068 0.0654 -0.6381 2.4813
Large TRUE 205 -0.0208 0.1274 -0.0981 -0.0228 0.0399 -0.3583 0.7547
ggplot(car_wide[!is.na(car_m30_p1)], aes(x = mcap_tier, y = car_m30_p1, fill = treated)) +
  geom_boxplot() +
  labs(x = "Market cap tier", y = "CAR [-30,+1]", fill = "Polymarket-covered",
       title = "CAR[-30,+1] by market cap tier and treatment status")

Cell counts for treated == TRUE are small relative to treated == FALSE in every market cap tier (consistent with Polymarket’s narrow 2025 Q4-heavy coverage above), so the summary statistics for the treated group — especially SD, quantiles, and any tier with very few treated events — should be read as descriptive only. No test of significance is run here given the small, non-random treated sample and the timing confound noted above; a formal comparison would need to account for the fact that treatment is concentrated in Q4 events.

Matched CAR comparison (nearest-neighbor on market cap)

Market cap terciles are coarse, and treated events skew heavily toward Large (210 of 263). To compare treated and control events at similar firm size, each treated event is 1:1 nearest-neighbor matched (via MatchIt, logit distance, caliper = 0.2 SD) to an untreated event with the closest log(event_market_cap), without replacement. 260 of 263 treated events find an acceptable match (3 discarded — no untreated event close enough in size within the caliper); post-match balance on log_mcap is near-exact (standardized mean difference ≈ 0.0001).

match_df <- car_wide[, .(event_id, ticker, event_date, treated,
                          log_mcap = log(event_market_cap))]

m <- matchit(
  treated ~ log_mcap,
  data = match_df,
  method = "nearest",
  distance = "logit",
  caliper = 0.2,
  ratio = 1,
  replace = FALSE
)

matched_event_ids <- match.data(m)$event_id
car_wide[, matched := event_id %in% matched_event_ids]
event_window[car_wide, matched := i.matched, on = "event_id"]
event_window[car_wide, treated := i.treated, on = "event_id"]
ggplot(car_wide[matched == TRUE & !is.na(car_m30_p1)], aes(x = treated, y = car_m30_p1)) +
  geom_boxplot() +
  labs(x = "Polymarket-covered (treated)", y = "CAR [-30,+1]",
       title = "CAR[-30,+1]: matched treated vs. control (matched on market cap)")

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

ggplot(car_path_matched, aes(x = event_offset, y = mean_car, color = treated)) +
  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 = "Polymarket-covered", title = "Mean CAR path: matched treated vs. control (matched on market cap)")

In the matched sample, treated firms’ mean CAR path drifts to roughly -0.02 to -0.03 over the window and stays below the (roughly flat, near-zero) control path throughout, including before the earnings date — so this looks more like a persistent level difference between the two groups than a distinct earnings-day reaction. Since matching only balances market cap, this gap could still reflect other differences correlated with being selected for a Polymarket earnings market (e.g. sector, volatility, retail attention) rather than a causal effect of Polymarket coverage itself. The boxplot of CAR[-30,+1] shows heavily overlapping distributions with similar spread, consistent with a small average difference relative to within-group variability.

Finer matching: market cap + pre-event beta

Adding pre-event beta as a second matching covariate checks whether the pre-existing gap above is just a market-cap artifact. 7 treated events have no estimated beta (incomplete estimation window) and are dropped before matching. Calipers are applied in standard-deviation units on each covariate.

match_df2 <- car_wide[!is.na(beta), .(event_id, ticker, event_date, treated,
                                       log_mcap = log(event_market_cap), beta)]

m2 <- matchit(
  treated ~ log_mcap + beta,
  data = match_df2,
  method = "nearest",
  distance = "logit",
  caliper = c(log_mcap = 0.2, beta = 0.2),
  std.caliper = TRUE,
  ratio = 1,
  replace = FALSE
)

summary(m2, un = FALSE)
## 
## Call:
## matchit(formula = treated ~ log_mcap + beta, data = match_df2, 
##     method = "nearest", distance = "logit", replace = FALSE, 
##     caliper = c(log_mcap = 0.2, beta = 0.2), std.caliper = TRUE, 
##     ratio = 1)
## 
## Summary of Balance for Matched Data:
##          Means Treated Means Control Std. Mean Diff. Var. Ratio eCDF Mean
## distance        0.0586        0.0582          0.0049     1.0491    0.0006
## log_mcap       16.8572       16.8541          0.0016     1.0106    0.0025
## beta            1.0899        1.0892          0.0011     1.0091    0.0092
##          eCDF Max Std. Pair Dist.
## distance   0.0119          0.0152
## log_mcap   0.0198          0.0277
## beta       0.0278          0.0923
## 
## Sample Sizes:
##           Control Treated
## All         13939     256
## Matched       252     252
## Unmatched   13687       4
## Discarded       0       0
matched_event_ids2 <- match.data(m2)$event_id
car_wide[, matched_fine := event_id %in% matched_event_ids2]
event_window[car_wide, matched_fine := i.matched_fine, on = "event_id"]

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

ggplot(car_path_fine, aes(x = event_offset, y = mean_car, color = treated)) +
  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 = "Polymarket-covered", title = "Mean CAR path: matched on market cap + beta")

Matching on market cap and beta jointly gives near-exact covariate balance (standardized mean differences < 0.005 for both), yet the gap between treated and control does not narrow — if anything the paths look similar to the market-cap-only match. This rules out beta and market cap as the explanation for the pre-existing divergence.

Formal test: is the pre-earnings placebo-window gap real?

The CAR[-30,-1] window is entirely before the earnings date, so a non-zero difference here isolates the “pre-existing gap” from any earnings-day reaction. Using the market-cap-and-beta-matched pairs (one treated, one control per subclass), a paired test is used since matching creates dependent pairs. CAR[-30,-1] is right-skewed with heavy tails (Shapiro-Wilk rejects normality of the paired differences, p < 0.001), so both a paired t-test and its nonparametric counterpart (Wilcoxon signed-rank) are reported.

placebo_pairs <- match.data(m2) %>%
  select(event_id, treated, subclass) %>%
  left_join(car_wide[, .(event_id, car_m30_m1)], by = "event_id") %>%
  filter(!is.na(car_m30_m1)) %>%
  select(subclass, treated, car_m30_m1) %>%
  pivot_wider(names_from = treated, values_from = car_m30_m1, names_prefix = "car_") %>%
  filter(!is.na(car_TRUE), !is.na(car_FALSE))

nrow(placebo_pairs)
## [1] 252
t_test_result <- t.test(placebo_pairs$car_TRUE, placebo_pairs$car_FALSE, paired = TRUE)
wilcox_result <- wilcox.test(placebo_pairs$car_TRUE, placebo_pairs$car_FALSE,
                              paired = TRUE, conf.int = TRUE)

data.frame(
  Test = c("Paired t-test", "Wilcoxon signed-rank"),
  Estimate = c(unname(t_test_result$estimate), unname(wilcox_result$estimate)),
  CI_low = c(t_test_result$conf.int[1], wilcox_result$conf.int[1]),
  CI_high = c(t_test_result$conf.int[2], wilcox_result$conf.int[2]),
  p_value = c(t_test_result$p.value, wilcox_result$p.value)
) %>%
  nice_table("Table 19. Treated vs. control difference in CAR[-30,-1] (matched pairs)", digits = 4)
Table 19. Treated vs. control difference in CAR[-30,-1] (matched pairs)
Test Estimate CI_low CI_high p_value
Paired t-test -0.0332 -0.0517 -0.0146 5e-04
Wilcoxon signed-rank -0.0352 -0.0517 -0.0178 1e-04

Both tests agree: treated firms’ pre-earnings CAR[-30,-1] is on average about 0.03 lower than their matched controls (paired t-test: mean difference = -0.033, 95% CI [-0.052, -0.015], p < 0.001; Wilcoxon: pseudo-median = -0.035, 95% CI [-0.052, -0.018], p < 0.001), even after matching on market cap and beta. So the divergence identified in the CAR path plots above is not an artifact of the earnings event itself — it is present, and statistically distinguishable from zero, in the placebo window entirely before the earnings date. This points to treated and control firms differing on some dimension not captured by market cap or beta (e.g. sector composition, momentum, or whatever drives Polymarket’s selection of which earnings to list), rather than Polymarket coverage causing a specific earnings-day abnormal return.

Difference-in-differences: netting out the pre-existing gap

CAR[-30,+1] and CAR[-30,-1] share the same start date (day -30), so car_m30_p1 - car_m30_m1 isolates the incremental abnormal return from day 0 to day +1 — i.e. the earnings-day-specific reaction, with the pre-existing (placebo-period) level difference between treated and control subtracted out. Comparing this differenced quantity between matched pairs (market cap + beta match) is exactly a difference-in-differences estimator of the earnings-day effect.

did_pairs <- match.data(m2) %>%
  select(event_id, treated, subclass) %>%
  left_join(car_wide[, .(event_id, car_m30_m1, car_m30_p1)], by = "event_id") %>%
  mutate(car_diff = car_m30_p1 - car_m30_m1)

did_wide <- did_pairs %>%
  filter(!is.na(car_diff)) %>%
  select(subclass, treated, car_diff, car_m30_m1, car_m30_p1) %>%
  pivot_wider(names_from = treated, values_from = c(car_diff, car_m30_m1, car_m30_p1)) %>%
  filter(!is.na(car_diff_TRUE), !is.na(car_diff_FALSE))

nrow(did_wide)
## [1] 252
t_did <- t.test(did_wide$car_diff_TRUE, did_wide$car_diff_FALSE, paired = TRUE)
wilcox_did <- wilcox.test(did_wide$car_diff_TRUE, did_wide$car_diff_FALSE,
                           paired = TRUE, conf.int = TRUE)

data.frame(
  Test = c("Paired t-test", "Wilcoxon signed-rank"),
  Estimate = c(unname(t_did$estimate), unname(wilcox_did$estimate)),
  CI_low = c(t_did$conf.int[1], wilcox_did$conf.int[1]),
  CI_high = c(t_did$conf.int[2], wilcox_did$conf.int[2]),
  p_value = c(t_did$p.value, wilcox_did$p.value)
) %>%
  nice_table("Table 20. DiD estimate: (post - placebo) CAR, treated vs. control (matched pairs)",
             digits = 4)
Table 20. DiD estimate: (post - placebo) CAR, treated vs. control (matched pairs)
Test Estimate CI_low CI_high p_value
Paired t-test 0.0065 -0.0116 0.0246 0.4828
Wilcoxon signed-rank -0.0012 -0.0162 0.0137 0.8850
did_long <- did_pairs %>%
  filter(subclass %in% did_wide$subclass) %>%
  select(treated, car_m30_m1, car_m30_p1) %>%
  pivot_longer(cols = c(car_m30_m1, car_m30_p1), names_to = "window", values_to = "car") %>%
  mutate(window = factor(window, levels = c("car_m30_m1", "car_m30_p1"),
                          labels = c("Placebo [-30,-1]", "Post [-30,+1]"))) %>%
  group_by(treated, window) %>%
  summarise(mean_car = mean(car, na.rm = TRUE),
            se = sd(car, na.rm = TRUE) / sqrt(sum(!is.na(car))), .groups = "drop")

ggplot(did_long, aes(x = window, y = mean_car, color = treated, group = treated)) +
  geom_line() +
  geom_point(size = 2) +
  geom_errorbar(aes(ymin = mean_car - 1.96 * se, ymax = mean_car + 1.96 * se), width = 0.1) +
  geom_hline(yintercept = 0, linetype = "dashed") +
  labs(x = NULL, y = "Mean CAR", color = "Polymarket-covered",
       title = "Difference-in-differences: placebo vs. post-event CAR (matched pairs)")

The two lines are nearly parallel: control firms move from a mean CAR of 0.0056 (placebo) to 0.0052 (post), while treated firms move from -0.0275 to -0.0215 — both groups shift by roughly the same small positive amount. The DiD estimate (mean of within-pair post - placebo differences, treated minus control) is 0.0065 (paired t-test 95% CI [-0.012, 0.025], p = 0.48; Wilcoxon pseudo-median -0.0012, 95% CI [-0.016, 0.014], p = 0.89) — not distinguishable from zero by either test.

Once the pre-existing level difference is netted out, there is no evidence of an earnings-day-specific effect associated with Polymarket coverage: the entire CAR[-30,+1] gap identified earlier is explained by a divergence that was already present before the earnings date, not by anything happening on or after day 0.

Does Polymarket’s Resolved Outcome Predict the Actual Reaction?

Mere coverage (does a market exist for this ticker?) turned out to be confounded with a pre-existing difference between covered and non-covered firms. A different, more direct question avoids that selection problem entirely by staying within the covered firms: does the market’s own resolved outcome — “beat” vs “missed” the EPS estimate, or “up” vs “down” after earnings — line up with the actual CRSP-based abnormal return on the earnings date? This sidesteps the selection confound above, since it compares covered firms to other covered firms rather than covered to non-covered.

Each Polymarket earnings_number market resolves to one of two outcomes (market_outcomes_summary, e.g. "Yes:1|No:0"); the outcome with value 1 is the resolved result. These are grouped into two signal types with different interpretations:

  • beat/miss EPS estimate: an independent economic signal (a distinct earnings-surprise indicator) not mechanically tied to the CRSP return calculation — the more informative test.
  • up/down after earnings: close to tautological with the sign of the actual price reaction (both are measuring “did the stock go up after earnings,” just from different data sources), so a strong relationship here is more of a validity check than a novel finding.

The outcome window used is the day 0-to-+1 abnormal return (event_offset %in% c(0, 1)), matching the incremental-reaction construct used in the DiD analysis above.

pm_num2 <- as.data.table(polymarket_numeric_events)
pm_num2[, market_enddate := as.Date(market_enddate)]

car_dt <- car_wide[, .(event_id, ticker, event_date)]
car_dt[, `:=`(event_date_lo = event_date - match_window, event_date_hi = event_date + match_window)]

outcome_matches <- pm_num2[car_dt,
  on = .(ticker, market_enddate >= event_date_lo, market_enddate <= event_date_hi),
  .(event_id = i.event_id, market_id, market_question, market_outcomes_summary),
  nomatch = NULL,
  allow.cartesian = TRUE]

outcome_matches[, favorable_label := str_extract(market_outcomes_summary, "^[^:]+(?=:1)")]
outcome_matches[, favorable := favorable_label %in% c("Yes", "Up")]
outcome_matches[, signal_type := if_else(str_detect(market_outcomes_summary, "Yes|No"),
                                          "beat_eps_estimate", "price_direction")]

reaction <- event_window[event_offset %in% c(0, 1),
  .(ar_0_1 = sum(abnormal_return, na.rm = TRUE), n_na = sum(is.na(abnormal_return))),
  by = event_id]
reaction[n_na > 0, ar_0_1 := NA_real_]

outcome_reaction_cc <- outcome_matches[reaction, on = "event_id", nomatch = NULL][!is.na(ar_0_1)]

outcome_reaction_cc[, .(
  n = .N, mean_ar = mean(ar_0_1), sd_ar = sd(ar_0_1), median_ar = median(ar_0_1)
), by = .(signal_type, favorable)][order(signal_type, favorable)] %>%
  nice_table("Table 21. Day[0,+1] abnormal return by signal type and resolved outcome", digits = 4)
Table 21. Day[0,+1] abnormal return by signal type and resolved outcome
signal_type favorable n mean_ar sd_ar median_ar
beat_eps_estimate FALSE 48 -0.0207 0.0770 -0.0184
beat_eps_estimate TRUE 164 0.0093 0.1067 0.0007
price_direction FALSE 33 -0.0874 0.0754 -0.0659
price_direction TRUE 47 0.0965 0.1260 0.0580
beat_test <- t.test(ar_0_1 ~ favorable, data = outcome_reaction_cc[signal_type == "beat_eps_estimate"])
beat_wilcox <- wilcox.test(ar_0_1 ~ favorable, data = outcome_reaction_cc[signal_type == "beat_eps_estimate"], conf.int = TRUE)
dir_test <- t.test(ar_0_1 ~ favorable, data = outcome_reaction_cc[signal_type == "price_direction"])
dir_wilcox <- wilcox.test(ar_0_1 ~ favorable, data = outcome_reaction_cc[signal_type == "price_direction"], conf.int = TRUE)

data.frame(
  Signal = c("Beat/miss EPS estimate", "Beat/miss EPS estimate", "Up/down after earnings", "Up/down after earnings"),
  Test = c("Welch t-test", "Wilcoxon rank-sum", "Welch t-test", "Wilcoxon rank-sum"),
  Estimate = c(diff(beat_test$estimate), unname(beat_wilcox$estimate),
               diff(dir_test$estimate), unname(dir_wilcox$estimate)),
  CI_low = c(beat_test$conf.int[1], beat_wilcox$conf.int[1], dir_test$conf.int[1], dir_wilcox$conf.int[1]),
  CI_high = c(beat_test$conf.int[2], beat_wilcox$conf.int[2], dir_test$conf.int[2], dir_wilcox$conf.int[2]),
  p_value = c(beat_test$p.value, beat_wilcox$p.value, dir_test$p.value, dir_wilcox$p.value)
) %>%
  nice_table("Table 22. Difference in day[0,+1] abnormal return, favorable vs. unfavorable resolved outcome",
             digits = 4)
Table 22. Difference in day[0,+1] abnormal return, favorable vs. unfavorable resolved outcome
Signal Test Estimate CI_low CI_high p_value
Beat/miss EPS estimate Welch t-test 0.0300 -0.0575 -0.0024 0.0332
Beat/miss EPS estimate Wilcoxon rank-sum -0.0265 -0.0508 -0.0020 0.0354
Up/down after earnings Welch t-test 0.1839 -0.2289 -0.1389 0.0000
Up/down after earnings Wilcoxon rank-sum -0.1445 -0.1771 -0.1162 0.0000
outcome_reaction_cc[, favorable_label2 := fifelse(
  signal_type == "beat_eps_estimate",
  fifelse(favorable, "Beat estimate", "Missed estimate"),
  fifelse(favorable, "Resolved Up", "Resolved Down")
)]
outcome_reaction_cc[, signal_label := fifelse(signal_type == "beat_eps_estimate",
                                               "Polymarket: beat/miss EPS estimate",
                                               "Polymarket: up/down after earnings")]

ggplot(outcome_reaction_cc, aes(x = favorable_label2, y = ar_0_1)) +
  geom_boxplot() +
  geom_hline(yintercept = 0, linetype = "dashed") +
  facet_wrap(~ signal_label, scales = "free_x") +
  labs(x = NULL, y = "Abnormal return, day 0 to +1",
       title = "Actual earnings-day stock reaction by Polymarket's resolved outcome")

Firms whose Polymarket market resolved “beat estimate” had a mean day[0,+1] abnormal return of +0.93%, versus -2.07% for “missed estimate” (Welch t-test, p = 0.033; Wilcoxon, p = 0.035) — a statistically distinguishable, economically sensible gap consistent with a standard earnings-surprise / post-earnings-announcement-drift pattern, and not subject to the selection confound that undermined the plain coverage comparison, since both groups are drawn from covered firms. The up/down signal shows an even larger gap (+9.6% vs -8.7%, p < 0.001 on both tests) — expected, since this Polymarket question is nearly measuring the same thing (did the stock go up right after earnings) as the dependent variable, so it is better read as a consistency check between Polymarket’s resolution and CRSP-based returns than as new information.

Does the beat/miss effect persist or fade? (drift check)

Extending the window from day[0,+1] to day[0,+5] and day[0,+10] checks whether the beat/miss gap identified above is a short-lived reaction or a more sustained drift.

horizons <- list(h1 = c(0, 1), h5 = c(0, 5), h10 = c(0, 10))

reaction_multi <- rbindlist(lapply(names(horizons), function(h) {
  bounds <- horizons[[h]]
  event_window[event_offset >= bounds[1] & event_offset <= bounds[2],
    .(ar = sum(abnormal_return, na.rm = TRUE), n_na = sum(is.na(abnormal_return))),
    by = event_id][, horizon := h]
}))
reaction_multi[n_na > 0, ar := NA_real_]

beat_matches <- outcome_matches[signal_type == "beat_eps_estimate"]
beat_reaction <- beat_matches[reaction_multi, on = "event_id", nomatch = NULL][!is.na(ar)]
beat_reaction[, horizon := factor(horizon, levels = c("h1", "h5", "h10"),
                                   labels = c("[0,+1]", "[0,+5]", "[0,+10]"))]

beat_reaction[, .(n = .N, mean_ar = mean(ar), sd_ar = sd(ar)), by = .(horizon, favorable)][order(horizon, favorable)] %>%
  nice_table("Table 23. Mean abnormal return by horizon and beat/miss outcome", digits = 4)
Table 23. Mean abnormal return by horizon and beat/miss outcome
horizon favorable n mean_ar sd_ar
[0,+1] FALSE 48 -0.0207 0.0770
[0,+1] TRUE 164 0.0093 0.1067
[0,+5] FALSE 48 -0.0363 0.0806
[0,+5] TRUE 164 0.0024 0.1222
[0,+10] FALSE 48 -0.0371 0.1029
[0,+10] TRUE 164 -0.0027 0.1165
horizon_tests <- rbindlist(lapply(c("[0,+1]", "[0,+5]", "[0,+10]"), function(h) {
  sub <- beat_reaction[horizon == h]
  tt <- t.test(ar ~ favorable, data = sub)
  wt <- wilcox.test(ar ~ favorable, data = sub, conf.int = TRUE)
  data.table(
    horizon = h,
    mean_diff_t = diff(tt$estimate), ci_low_t = tt$conf.int[1], ci_high_t = tt$conf.int[2], p_t = tt$p.value,
    est_wilcox = unname(wt$estimate), ci_low_w = wt$conf.int[1], ci_high_w = wt$conf.int[2], p_w = wt$p.value
  )
}))

horizon_tests %>%
  nice_table("Table 24. Beat-vs-miss abnormal return gap, by horizon (Welch t-test and Wilcoxon)", digits = 4)
Table 24. Beat-vs-miss abnormal return gap, by horizon (Welch t-test and Wilcoxon)
horizon mean_diff_t ci_low_t ci_high_t p_t est_wilcox ci_low_w ci_high_w p_w
[0,+1] 0.0300 -0.0575 -0.0024 0.0332 -0.0265 -0.0508 -0.0020 0.0354
[0,+5] 0.0388 -0.0686 -0.0089 0.0113 -0.0324 -0.0585 -0.0032 0.0329
[0,+10] 0.0344 -0.0690 0.0002 0.0514 -0.0292 -0.0608 0.0041 0.0876
car_path_beat <- event_window[event_id %in% beat_matches$event_id & event_offset >= 0,
  .(event_id, event_offset, abnormal_return)] %>%
  inner_join(beat_matches[, .(event_id, favorable)], by = "event_id") %>%
  group_by(event_offset, favorable) %>%
  summarise(mean_ar = mean(abnormal_return, na.rm = TRUE), .groups = "drop") %>%
  arrange(favorable, event_offset) %>%
  group_by(favorable) %>%
  mutate(mean_car = cumsum(mean_ar)) %>%
  ungroup()

ggplot(car_path_beat, aes(x = event_offset, y = mean_car, color = favorable)) +
  geom_line() +
  geom_hline(yintercept = 0, linetype = "dashed") +
  labs(x = "Trading days after earnings date", y = "Mean cumulative abnormal return",
       color = "Beat estimate", title = "Post-earnings drift: beat vs. miss (EPS estimate signal)")

The gap is asymmetric rather than a clean, persistent drift in both directions. “Missed estimate” firms drop sharply in the first couple of days after earnings and stay down (-2.1% by day 0, roughly -3.7% by day +10) — a negative reaction that holds up over the full 10-day window. “Beat estimate” firms show only a modest initial bump (+0.9% at day +1) that decays back toward zero by day +7-10, ending essentially flat. Correspondingly, the significance of the beat-vs-miss gap is strongest at day[0,+5] (p = 0.011 t-test / 0.033 Wilcoxon) and weakens by day[0,+10] (p = 0.051 t-test / 0.088 Wilcoxon, no longer significant at the 5% level by the Wilcoxon test) — driven by the “beat” group’s premium fading, not by the “miss” group’s decline reversing. With n = 48 in the “missed estimate” group, this asymmetry should be treated as suggestive rather than conclusive.