FinanceRoutines.jl

Financial data routines for Julia
Log | Files | Refs | README | LICENSE

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:
MNEWS.md | 3+++
MREADME.md | 17+++++++++++++++++
Mdocs/src/index.md | 1+
Asrc/EventStudy.jl | 224+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/FinanceRoutines.jl | 2++
Atest/UnitTests/EventStudy.jl | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/runtests.jl | 1+
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", ] # --------------------------------------------------------------------------------------------------