FinanceRoutines.jl

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

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 # --------------------------------------------------------------------------------------------------