commit f31da659e995ca77a474320d1efc92cf1811bf93
parent 5d9906c5c6df32a4581032675430431bb7c130b6
Author: Erik Loualiche <[email protected]>
Date: Sun, 22 Mar 2026 15:57:07 -0500
Add event_study() for CARs and BHARs (experimental)
Supports market-adjusted, market model, and mean-adjusted abnormal
returns. Handles missing entities, insufficient data gracefully.
Marked experimental in docs/docstring. 18 test assertions.
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
Diffstat:
7 files changed, 333 insertions(+), 0 deletions(-)
diff --git a/NEWS.md b/NEWS.md
@@ -11,7 +11,10 @@
- `import_FF_momentum`: Import Fama-French momentum factor
- `calculate_portfolio_returns`: Value-weighted and equal-weighted portfolio return calculations
- `diagnose`: Data quality diagnostics for financial DataFrames
+- `event_study` (experimental): Event study CARs and BHARs with market-adjusted, market model, and mean-adjusted methods
- WRDS connection now warns about Duo 2FA and gives clear guidance on failure
+- `_validate_date_range`: Warns on reversed, ancient, or future date ranges in WRDS imports
+- Compustat variable validation now queries the actual schema at runtime (falls back to hardcoded list)
### Internal improvements
- Removed broken `@log_msg` macro, replaced with `@debug`
diff --git a/README.md b/README.md
@@ -14,6 +14,7 @@ The package provides functions to:
- Import GSW yield curves from the [Federal Reserve](https://www.federalreserve.gov/pubs/feds/2006/200628/200628abs.html) and compute bond returns
- Estimate rolling betas for stocks
- Calculate equal-weighted and value-weighted portfolio returns
+ - Run event studies (CARs, BHARs) with standard abnormal return models
- Run data quality diagnostics on financial DataFrames
## Installation
@@ -131,6 +132,22 @@ report[:duplicate_keys] # duplicate (permno, date) pairs
report[:suspicious_values] # extreme returns, negative prices
```
+### Event studies (experimental)
+
+> **Note:** `event_study` is experimental and has not been extensively validated against established implementations. Use with caution and verify results independently.
+
+```julia
+events = DataFrame(permno=[10001, 10002], event_date=[Date("2010-06-15"), Date("2011-03-20")])
+
+# Market-adjusted CARs and BHARs (default)
+results = event_study(events, df_msf)
+
+# Market model with custom windows
+results = event_study(events, df_msf;
+ event_window=(-5, 5), estimation_window=(-252, -21),
+ model=:market_model)
+```
+
### Common operations in asset pricing
Look in the documentation for a guide on how to estimate betas: over the whole sample and using rolling regressions.
diff --git a/docs/src/index.md b/docs/src/index.md
@@ -45,6 +45,7 @@ Pkg.add("https://github.com/louloulibs/FinanceRoutines.jl")
- Portfolio analytics
+ `calculate_portfolio_returns` — equal/value-weighted returns with optional grouping
+ `calculate_rolling_betas` — rolling window factor regressions
+ + `event_study` — CARs and BHARs with market-adjusted, market model, or mean-adjusted methods (experimental)
+ `diagnose` — missing rates, duplicates, suspicious values
- Demos
diff --git a/src/EventStudy.jl b/src/EventStudy.jl
@@ -0,0 +1,224 @@
+# --------------------------------------------------------------------------------------------------
+# EventStudy.jl
+
+# Event study utilities for computing abnormal returns around events
+# --------------------------------------------------------------------------------------------------
+
+
+# --------------------------------------------------------------------------------------------------
+"""
+ event_study(events, returns;
+ event_window=(-10, 10), estimation_window=(-260, -11),
+ model=:market_adjusted,
+ id_col=:permno, date_col=:date, ret_col=:ret,
+ event_date_col=:event_date, market_col=:mktrf)
+
+Compute cumulative abnormal returns (CAR) and buy-and-hold abnormal returns (BHAR)
+around events using standard event study methodology.
+
+# Arguments
+- `events::AbstractDataFrame`: One row per event with entity ID and event date
+- `returns::AbstractDataFrame`: Panel of returns with entity ID, date, and return
+
+# Keywords
+- `event_window::Tuple{Int,Int}=(-10, 10)`: Trading days around event (inclusive)
+- `estimation_window::Tuple{Int,Int}=(-260, -11)`: Trading days for estimating normal returns
+- `model::Symbol=:market_adjusted`: Normal return model:
+ - `:market_adjusted` — abnormal = ret - mktrf
+ - `:market_model` — OLS α+β on market in estimation window
+ - `:mean_adjusted` — abnormal = ret - mean(ret in estimation window)
+- `id_col::Symbol=:permno`: Entity identifier column (must exist in both DataFrames)
+- `date_col::Symbol=:date`: Date column in returns
+- `ret_col::Symbol=:ret`: Return column in returns
+- `event_date_col::Symbol=:event_date`: Event date column in events
+- `market_col::Symbol=:mktrf`: Market return column in returns (for `:market_adjusted` and `:market_model`)
+
+# Returns
+- `DataFrame` with columns:
+ - All columns from `events`
+ - `:car` — Cumulative Abnormal Return over the event window
+ - `:bhar` — Buy-and-Hold Abnormal Return over the event window
+ - `:n_obs` — Number of non-missing return observations in the event window
+
+# Examples
+```julia
+events = DataFrame(permno=[10001, 10002], event_date=[Date("2010-06-15"), Date("2011-03-20")])
+
+# Market-adjusted (simplest)
+results = event_study(events, df_msf)
+
+# Market model with custom windows
+results = event_study(events, df_msf;
+ event_window=(-5, 5), estimation_window=(-252, -21),
+ model=:market_model)
+
+# Mean-adjusted (no market return needed)
+results = event_study(events, df_msf; model=:mean_adjusted)
+```
+
+# Notes
+- **Experimental:** this function has not been extensively validated against established
+ event study implementations. Verify results independently before relying on them.
+- Returns must be sorted by (id, date) and contain trading days only
+- Events with insufficient estimation window data are included with `missing` CAR/BHAR
+- The function uses relative trading-day indexing (not calendar days)
+"""
+function event_study(events::AbstractDataFrame, returns::AbstractDataFrame;
+ event_window::Tuple{Int,Int}=(-10, 10),
+ estimation_window::Tuple{Int,Int}=(-260, -11),
+ model::Symbol=:market_adjusted,
+ id_col::Symbol=:permno,
+ date_col::Symbol=:date,
+ ret_col::Symbol=:ret,
+ event_date_col::Symbol=:event_date,
+ market_col::Symbol=:mktrf)
+
+ if model ∉ (:market_adjusted, :market_model, :mean_adjusted)
+ throw(ArgumentError("model must be :market_adjusted, :market_model, or :mean_adjusted, got :$model"))
+ end
+ if event_window[1] > event_window[2]
+ throw(ArgumentError("event_window start must be ≤ end"))
+ end
+ if estimation_window[1] > estimation_window[2]
+ throw(ArgumentError("estimation_window start must be ≤ end"))
+ end
+ if model != :mean_adjusted && market_col ∉ propertynames(returns)
+ throw(ArgumentError("returns must contain market column :$market_col for model :$model"))
+ end
+
+ # Sort returns by entity and date
+ returns_sorted = sort(returns, [id_col, date_col])
+
+ # Group returns by entity for fast lookup
+ returns_by_id = groupby(returns_sorted, id_col)
+
+ # Process each event
+ car_vec = Union{Missing, Float64}[]
+ bhar_vec = Union{Missing, Float64}[]
+ nobs_vec = Union{Missing, Int}[]
+
+ for row in eachrow(events)
+ entity_id = row[id_col]
+ event_date = row[event_date_col]
+
+ # Find this entity's returns
+ key = (entity_id,)
+ if !haskey(returns_by_id, key)
+ push!(car_vec, missing)
+ push!(bhar_vec, missing)
+ push!(nobs_vec, 0)
+ continue
+ end
+
+ entity_rets = returns_by_id[key]
+ dates = entity_rets[!, date_col]
+
+ # Find the event date index in the trading calendar
+ event_idx = findfirst(d -> d >= event_date, dates)
+ if isnothing(event_idx)
+ push!(car_vec, missing)
+ push!(bhar_vec, missing)
+ push!(nobs_vec, 0)
+ continue
+ end
+
+ # Extract event window and estimation window by trading-day offset
+ ew_start = event_idx + event_window[1]
+ ew_end = event_idx + event_window[2]
+ est_start = event_idx + estimation_window[1]
+ est_end = event_idx + estimation_window[2]
+
+ # Bounds check
+ if ew_start < 1 || ew_end > nrow(entity_rets) || est_start < 1 || est_end > nrow(entity_rets)
+ push!(car_vec, missing)
+ push!(bhar_vec, missing)
+ push!(nobs_vec, 0)
+ continue
+ end
+
+ # Get event window returns
+ ew_rets = entity_rets[ew_start:ew_end, ret_col]
+
+ # Compute abnormal returns based on model
+ abnormal_rets = _compute_abnormal_returns(
+ model, entity_rets, ew_rets,
+ ew_start, ew_end, est_start, est_end,
+ ret_col, market_col)
+
+ if ismissing(abnormal_rets)
+ push!(car_vec, missing)
+ push!(bhar_vec, missing)
+ push!(nobs_vec, 0)
+ continue
+ end
+
+ valid = .!ismissing.(abnormal_rets)
+ n_valid = count(valid)
+
+ if n_valid == 0
+ push!(car_vec, missing)
+ push!(bhar_vec, missing)
+ push!(nobs_vec, 0)
+ else
+ ar = collect(skipmissing(abnormal_rets))
+ push!(car_vec, sum(ar))
+ push!(bhar_vec, prod(1.0 .+ ar) - 1.0)
+ push!(nobs_vec, n_valid)
+ end
+ end
+
+ result = copy(events)
+ result[!, :car] = car_vec
+ result[!, :bhar] = bhar_vec
+ result[!, :n_obs] = nobs_vec
+
+ return result
+end
+# --------------------------------------------------------------------------------------------------
+
+
+# --------------------------------------------------------------------------------------------------
+function _compute_abnormal_returns(model::Symbol, entity_rets, ew_rets,
+ ew_start, ew_end, est_start, est_end,
+ ret_col, market_col)
+
+ if model == :market_adjusted
+ ew_mkt = entity_rets[ew_start:ew_end, market_col]
+ return _safe_subtract(ew_rets, ew_mkt)
+
+ elseif model == :mean_adjusted
+ est_rets = entity_rets[est_start:est_end, ret_col]
+ valid_est = collect(skipmissing(est_rets))
+ length(valid_est) < 10 && return missing
+ mu = mean(valid_est)
+ return [ismissing(r) ? missing : r - mu for r in ew_rets]
+
+ elseif model == :market_model
+ est_rets = entity_rets[est_start:est_end, ret_col]
+ est_mkt = entity_rets[est_start:est_end, market_col]
+
+ # Need non-missing pairs for OLS
+ valid = .!ismissing.(est_rets) .& .!ismissing.(est_mkt)
+ count(valid) < 30 && return missing
+
+ y = Float64.(est_rets[valid])
+ x = Float64.(est_mkt[valid])
+
+ # OLS: y = α + β*x
+ n = length(y)
+ x_mean = mean(x)
+ y_mean = mean(y)
+ β = sum((x .- x_mean) .* (y .- y_mean)) / sum((x .- x_mean) .^ 2)
+ α = y_mean - β * x_mean
+
+ # Abnormal returns in event window
+ ew_mkt = entity_rets[ew_start:ew_end, market_col]
+ return [ismissing(r) || ismissing(m) ? missing : r - (α + β * m)
+ for (r, m) in zip(ew_rets, ew_mkt)]
+ end
+end
+
+function _safe_subtract(a, b)
+ return [ismissing(x) || ismissing(y) ? missing : x - y for (x, y) in zip(a, b)]
+end
+# --------------------------------------------------------------------------------------------------
diff --git a/src/FinanceRoutines.jl b/src/FinanceRoutines.jl
@@ -40,6 +40,7 @@ include("ImportComp.jl")
include("Merge_CRSP_Comp.jl")
include("PortfolioUtils.jl")
include("Diagnostics.jl")
+include("EventStudy.jl")
# --------------------------------------------------------------------------------------------------
@@ -72,6 +73,7 @@ export link_MSF
export calculate_rolling_betas
export calculate_portfolio_returns
export diagnose
+export event_study
# --------------------------------------------------------------------------------------------------
diff --git a/test/UnitTests/EventStudy.jl b/test/UnitTests/EventStudy.jl
@@ -0,0 +1,85 @@
+@testset "Event Study" begin
+
+ import Dates: Date, Day
+ import Statistics: mean
+
+ # Build synthetic daily returns panel: 2 firms, 300 trading days
+ dates = Date("2010-01-04"):Day(1):Date("2011-04-01")
+ # Remove weekends for realistic trading calendar
+ trading_days = filter(d -> Dates.dayofweek(d) <= 5, dates)
+ trading_days = trading_days[1:300] # exactly 300 days
+
+ n = length(trading_days)
+ df_ret = DataFrame(
+ permno = vcat(fill(1, n), fill(2, n)),
+ date = vcat(trading_days, trading_days),
+ ret = vcat(0.001 .+ 0.01 .* randn(n), 0.0005 .+ 0.015 .* randn(n)),
+ mktrf = vcat(repeat([0.0005 + 0.008 * randn()], n), repeat([0.0005 + 0.008 * randn()], n))
+ )
+ # Regenerate mktrf properly (same market for both firms on same date)
+ mkt_returns = 0.0005 .+ 0.008 .* randn(n)
+ df_ret.mktrf = vcat(mkt_returns, mkt_returns)
+
+ # Inject a positive event: +5% abnormal return on event day for firm 1
+ event_idx_firm1 = 270 # well within bounds for estimation window
+ df_ret.ret[event_idx_firm1] += 0.05
+
+ events = DataFrame(
+ permno = [1, 2],
+ event_date = [trading_days[event_idx_firm1], trading_days[280]]
+ )
+
+ # ---- Market-adjusted model ----
+ @testset "Market-adjusted" begin
+ result = event_study(events, df_ret; model=:market_adjusted)
+ @test nrow(result) == 2
+ @test "car" in names(result)
+ @test "bhar" in names(result)
+ @test "n_obs" in names(result)
+ @test !ismissing(result.car[1])
+ @test !ismissing(result.car[2])
+ @test result.n_obs[1] == 21 # -10 to +10 inclusive
+ # Firm 1 should have positive CAR (we injected +5%)
+ @test result.car[1] > 0.03
+ end
+
+ # ---- Market model ----
+ @testset "Market model" begin
+ result = event_study(events, df_ret;
+ model=:market_model,
+ event_window=(-5, 5),
+ estimation_window=(-250, -11))
+ @test nrow(result) == 2
+ @test !ismissing(result.car[1])
+ @test result.n_obs[1] == 11 # -5 to +5
+ end
+
+ # ---- Mean-adjusted model ----
+ @testset "Mean-adjusted" begin
+ result = event_study(events, df_ret;
+ model=:mean_adjusted,
+ event_window=(-3, 3),
+ estimation_window=(-200, -11))
+ @test nrow(result) == 2
+ @test !ismissing(result.car[1])
+ @test result.n_obs[1] == 7 # -3 to +3
+ end
+
+ # ---- Edge cases ----
+ @testset "Edge cases" begin
+ # Entity not in returns
+ events_missing = DataFrame(permno=[9999], event_date=[Date("2010-06-01")])
+ result = event_study(events_missing, df_ret)
+ @test ismissing(result.car[1])
+ @test result.n_obs[1] == 0
+
+ # Event too early (no estimation window)
+ events_early = DataFrame(permno=[1], event_date=[trading_days[5]])
+ result = event_study(events_early, df_ret)
+ @test ismissing(result.car[1])
+
+ # Invalid model
+ @test_throws ArgumentError event_study(events, df_ret; model=:foo)
+ end
+
+end
diff --git a/test/runtests.jl b/test/runtests.jl
@@ -19,6 +19,7 @@ const testsuite = [
"Yields",
"PortfolioUtils",
"Diagnostics",
+ "EventStudy",
]
# --------------------------------------------------------------------------------------------------