EventStudy.jl (8316B)
1 # -------------------------------------------------------------------------------------------------- 2 # EventStudy.jl 3 4 # Event study utilities for computing abnormal returns around events 5 # -------------------------------------------------------------------------------------------------- 6 7 8 # -------------------------------------------------------------------------------------------------- 9 """ 10 event_study(events, returns; 11 event_window=(-10, 10), estimation_window=(-260, -11), 12 model=:market_adjusted, 13 id_col=:permno, date_col=:date, ret_col=:ret, 14 event_date_col=:event_date, market_col=:mktrf) 15 16 Compute cumulative abnormal returns (CAR) and buy-and-hold abnormal returns (BHAR) 17 around events using standard event study methodology. 18 19 # Arguments 20 - `events::AbstractDataFrame`: One row per event with entity ID and event date 21 - `returns::AbstractDataFrame`: Panel of returns with entity ID, date, and return 22 23 # Keywords 24 - `event_window::Tuple{Int,Int}=(-10, 10)`: Trading days around event (inclusive) 25 - `estimation_window::Tuple{Int,Int}=(-260, -11)`: Trading days for estimating normal returns 26 - `model::Symbol=:market_adjusted`: Normal return model: 27 - `:market_adjusted` — abnormal = ret - mktrf 28 - `:market_model` — OLS α+β on market in estimation window 29 - `:mean_adjusted` — abnormal = ret - mean(ret in estimation window) 30 - `id_col::Symbol=:permno`: Entity identifier column (must exist in both DataFrames) 31 - `date_col::Symbol=:date`: Date column in returns 32 - `ret_col::Symbol=:ret`: Return column in returns 33 - `event_date_col::Symbol=:event_date`: Event date column in events 34 - `market_col::Symbol=:mktrf`: Market return column in returns (for `:market_adjusted` and `:market_model`) 35 36 # Returns 37 - `DataFrame` with columns: 38 - All columns from `events` 39 - `:car` — Cumulative Abnormal Return over the event window 40 - `:bhar` — Buy-and-Hold Abnormal Return over the event window 41 - `:n_obs` — Number of non-missing return observations in the event window 42 43 # Examples 44 ```julia 45 events = DataFrame(permno=[10001, 10002], event_date=[Date("2010-06-15"), Date("2011-03-20")]) 46 47 # Market-adjusted (simplest) 48 results = event_study(events, df_msf) 49 50 # Market model with custom windows 51 results = event_study(events, df_msf; 52 event_window=(-5, 5), estimation_window=(-252, -21), 53 model=:market_model) 54 55 # Mean-adjusted (no market return needed) 56 results = event_study(events, df_msf; model=:mean_adjusted) 57 ``` 58 59 # Notes 60 - **Experimental:** this function has not been extensively validated against established 61 event study implementations. Verify results independently before relying on them. 62 - Returns must be sorted by (id, date) and contain trading days only 63 - Events with insufficient estimation window data are included with `missing` CAR/BHAR 64 - The function uses relative trading-day indexing (not calendar days) 65 """ 66 function event_study(events::AbstractDataFrame, returns::AbstractDataFrame; 67 event_window::Tuple{Int,Int}=(-10, 10), 68 estimation_window::Tuple{Int,Int}=(-260, -11), 69 model::Symbol=:market_adjusted, 70 id_col::Symbol=:permno, 71 date_col::Symbol=:date, 72 ret_col::Symbol=:ret, 73 event_date_col::Symbol=:event_date, 74 market_col::Symbol=:mktrf) 75 76 if model ∉ (:market_adjusted, :market_model, :mean_adjusted) 77 throw(ArgumentError("model must be :market_adjusted, :market_model, or :mean_adjusted, got :$model")) 78 end 79 if event_window[1] > event_window[2] 80 throw(ArgumentError("event_window start must be ≤ end")) 81 end 82 if estimation_window[1] > estimation_window[2] 83 throw(ArgumentError("estimation_window start must be ≤ end")) 84 end 85 if model != :mean_adjusted && market_col ∉ propertynames(returns) 86 throw(ArgumentError("returns must contain market column :$market_col for model :$model")) 87 end 88 89 # Sort returns by entity and date 90 returns_sorted = sort(returns, [id_col, date_col]) 91 92 # Group returns by entity for fast lookup 93 returns_by_id = groupby(returns_sorted, id_col) 94 95 # Process each event 96 car_vec = Union{Missing, Float64}[] 97 bhar_vec = Union{Missing, Float64}[] 98 nobs_vec = Union{Missing, Int}[] 99 100 for row in eachrow(events) 101 entity_id = row[id_col] 102 event_date = row[event_date_col] 103 104 # Find this entity's returns 105 key = (entity_id,) 106 if !haskey(returns_by_id, key) 107 push!(car_vec, missing) 108 push!(bhar_vec, missing) 109 push!(nobs_vec, 0) 110 continue 111 end 112 113 entity_rets = returns_by_id[key] 114 dates = entity_rets[!, date_col] 115 116 # Find the event date index in the trading calendar 117 event_idx = findfirst(d -> d >= event_date, dates) 118 if isnothing(event_idx) 119 push!(car_vec, missing) 120 push!(bhar_vec, missing) 121 push!(nobs_vec, 0) 122 continue 123 end 124 125 # Extract event window and estimation window by trading-day offset 126 ew_start = event_idx + event_window[1] 127 ew_end = event_idx + event_window[2] 128 est_start = event_idx + estimation_window[1] 129 est_end = event_idx + estimation_window[2] 130 131 # Bounds check 132 if ew_start < 1 || ew_end > nrow(entity_rets) || est_start < 1 || est_end > nrow(entity_rets) 133 push!(car_vec, missing) 134 push!(bhar_vec, missing) 135 push!(nobs_vec, 0) 136 continue 137 end 138 139 # Get event window returns 140 ew_rets = entity_rets[ew_start:ew_end, ret_col] 141 142 # Compute abnormal returns based on model 143 abnormal_rets = _compute_abnormal_returns( 144 model, entity_rets, ew_rets, 145 ew_start, ew_end, est_start, est_end, 146 ret_col, market_col) 147 148 if ismissing(abnormal_rets) 149 push!(car_vec, missing) 150 push!(bhar_vec, missing) 151 push!(nobs_vec, 0) 152 continue 153 end 154 155 valid = .!ismissing.(abnormal_rets) 156 n_valid = count(valid) 157 158 if n_valid == 0 159 push!(car_vec, missing) 160 push!(bhar_vec, missing) 161 push!(nobs_vec, 0) 162 else 163 ar = collect(skipmissing(abnormal_rets)) 164 push!(car_vec, sum(ar)) 165 push!(bhar_vec, prod(1.0 .+ ar) - 1.0) 166 push!(nobs_vec, n_valid) 167 end 168 end 169 170 result = copy(events) 171 result[!, :car] = car_vec 172 result[!, :bhar] = bhar_vec 173 result[!, :n_obs] = nobs_vec 174 175 return result 176 end 177 # -------------------------------------------------------------------------------------------------- 178 179 180 # -------------------------------------------------------------------------------------------------- 181 function _compute_abnormal_returns(model::Symbol, entity_rets, ew_rets, 182 ew_start, ew_end, est_start, est_end, 183 ret_col, market_col) 184 185 if model == :market_adjusted 186 ew_mkt = entity_rets[ew_start:ew_end, market_col] 187 return _safe_subtract(ew_rets, ew_mkt) 188 189 elseif model == :mean_adjusted 190 est_rets = entity_rets[est_start:est_end, ret_col] 191 valid_est = collect(skipmissing(est_rets)) 192 length(valid_est) < 10 && return missing 193 mu = mean(valid_est) 194 return [ismissing(r) ? missing : r - mu for r in ew_rets] 195 196 elseif model == :market_model 197 est_rets = entity_rets[est_start:est_end, ret_col] 198 est_mkt = entity_rets[est_start:est_end, market_col] 199 200 # Need non-missing pairs for OLS 201 valid = .!ismissing.(est_rets) .& .!ismissing.(est_mkt) 202 count(valid) < 30 && return missing 203 204 y = Float64.(est_rets[valid]) 205 x = Float64.(est_mkt[valid]) 206 207 # OLS: y = α + β*x 208 n = length(y) 209 x_mean = mean(x) 210 y_mean = mean(y) 211 β = sum((x .- x_mean) .* (y .- y_mean)) / sum((x .- x_mean) .^ 2) 212 α = y_mean - β * x_mean 213 214 # Abnormal returns in event window 215 ew_mkt = entity_rets[ew_start:ew_end, market_col] 216 return [ismissing(r) || ismissing(m) ? missing : r - (α + β * m) 217 for (r, m) in zip(ew_rets, ew_mkt)] 218 end 219 end 220 221 function _safe_subtract(a, b) 222 return [ismissing(x) || ismissing(y) ? missing : x - y for (x, y) in zip(a, b)] 223 end 224 # --------------------------------------------------------------------------------------------------