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.
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.
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.
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.
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.
# 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("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 |
| NAs | 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 |
| NAs | 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 |
| NAs | 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 |
| NAs | 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.
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"))| 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 |
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"))| 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.
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.
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.
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)| 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.
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)| 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.
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:
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)| 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)| 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.
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)| 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)| 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.