AbnormalReturns Example

As a quick example:

using CSV, DataFramesMeta, Dates, AbnormalReturns

df_firm = CSV.File(joinpath(data_dir, "daily_ret.csv")) |> DataFrame
df_mkt = CSV.File(joinpath(data_dir, "mkt_ret.csv")) |> DataFrame
df_mkt[!, :mkt] = df_mkt.mktrf .+ df_mkt.rf
df_events = CSV.File(joinpath(data_dir, "firm_earnings_announcements.csv")) |> DataFrame
mkt_data = MarketData(
    df_mkt,
    df_firm
)
df_events = @chain df_events begin
    @rtransform(
        :est_start = advancebdays(mkt_data.calendar, :ea, -120),
        :est_end = advancebdays(mkt_data.calendar, :ea, -2),
        :event_start = advancebdays(mkt_data.calendar, :ea, -1),
        :event_end = advancebdays(mkt_data.calendar, :ea, 1),
    )
    @transform(:reg = quick_reg(mkt_data[:permno, :est_start .. :est_end], @formula(ret ~ mkt + smb + hml)))
    @transform(
        :bhar_reg = bhar(mkt_data[:permno, :event_start .. :event_end], :reg),
        :bhar_simple = bhar(mkt_data[:permno, :event_start .. :event_end, ["ret", "mkt"]]),
        :car_reg = car(mkt_data[:permno, :event_start .. :event_end], :reg),
        :car_simple = car(mkt_data[:permno, :event_start .. :event_end, ["ret", "mkt"]]),
        :total_ret = bh_return(mkt_data[:permno, :event_start .. :event_end, ["ret"]]),
        :total_mkt_ret = bh_return(mkt_data[:permno, :event_start .. :event_end, ["mkt"]]),
    )
    @rtransform(
        :std = std(:reg),
        :var = var(:reg),

    )
    select(Not([:est_start, :est_end, :event_start, :event_end, :reg]))
    # columns eliminated to save space:
    select(Not([:car_reg, :car_simple, :var, :total_mkt_ret]))
end
283×6 DataFrame
 Row │ permno  ea          bhar_reg     bhar_simple  total_ret     std
     │ Int64   Date        Float64?     Float64      Float64       Float64?
─────┼────────────────────────────────────────────────────────────────────────
   1 │  49373  2020-03-05  -0.0114753   -0.0368966   -0.0494716    0.0120205
   2 │  23660  2020-03-19  -0.0761877   -0.0799651   -0.16273      0.0100327
   3 │  17144  2020-03-18   0.00584866  -0.00749583   0.00562007   0.0122784
   4 │  52708  2020-03-19   0.0449673    0.057314    -0.0254504    0.023234
   5 │  57665  2020-03-24   0.105811     0.0931054    0.171386     0.0105352
   6 │  61621  2020-03-25   0.125142     0.129935     0.303036     0.0132682
   7 │  10104  2020-03-12   0.0295405    0.0515017   -0.01338      0.00903142
   8 │  75154  2020-03-19   0.15375      0.0269029   -0.0558615    0.0312084
  ⋮  │   ⋮         ⋮            ⋮            ⋮            ⋮            ⋮
 277 │  90373  2020-10-29  -0.00714584  -0.00486512  -0.0422143    0.016151
 278 │  90386  2020-11-02  -0.120063    -0.0769287   -0.0606557    0.0256068
 279 │  90993  2020-10-29  -0.00087777   0.00495886  -0.0323903    0.0109232
 280 │  90880  2020-10-28   0.00943539  -0.00174027  -0.0271723    0.0109558
 281 │  91668  2020-10-30   0.0290468    0.0200758    0.0283726    0.017888
 282 │  12449  2020-11-05  -0.0402344   -0.0572696   -0.0128859    0.0183221
 283 │  61399  2020-11-18  -0.0761987   -0.0706244   -0.0759727    0.0134081
                                                              268 rows omitted

Data

For the basic data, this uses the files in the test folder of this package ("test\data"). The "daily_ret.csv" file is a selection of firm returns, while "mkt_ret.csv" includes the average market return along with some Fama-French factor returns, you can download similar Fama-French data from here or from FamaFrenchData.jl and stock market data from AlphaVantage.jl or WRDSMerger.jl (requires access to the WRDS database).

The firm data uses "Permno" to identify a stock. This package will work with other identifiers, as long as the identifier-date pair is unique. However, Integers and Symbols will often be fastest (as opposed to String identifiers).

Load the firm data:

df_firm = CSV.File(joinpath(data_dir, "daily_ret.csv")) |> DataFrame
48659×4 DataFrame
   Row │ permno  date        ret           vol
       │ Int64   Date        Float64?      Float64
───────┼──────────────────────────────────────────────────
     1 │  10104  2019-01-02   0.00155038        1.43204e7
     2 │  10104  2019-01-03  -0.00973026        1.98687e7
     3 │  10104  2019-01-04   0.0430996         2.0984e7
     4 │  10104  2019-01-07   0.0158425         1.79679e7
     5 │  10104  2019-01-08   0.00906218        1.62557e7
     6 │  10104  2019-01-09  -0.0020886         1.91002e7
     7 │  10104  2019-01-10   0.00083719        1.66527e7
     8 │  10104  2019-01-11   0.00982855        1.63975e7
   ⋮   │   ⋮         ⋮            ⋮              ⋮
 48653 │  91668  2020-12-22   0.0112328    332197.0
 48654 │  91668  2020-12-23   0.00639975   182199.0
 48655 │  91668  2020-12-24   0.00367913    39932.0
 48656 │  91668  2020-12-28   0.0184641    162785.0
 48657 │  91668  2020-12-29  -0.0155966    140853.0
 48658 │  91668  2020-12-30   0.0124131    119724.0
 48659 │  91668  2020-12-31  -0.00222926   196481.0
                                        48644 rows omitted

and the market data:

df_mkt = CSV.File(joinpath(data_dir, "mkt_ret.csv")) |> DataFrame
672×6 DataFrame
 Row │ date        mktrf    smb      hml      rf       umd
     │ Date        Float64  Float64  Float64  Float64  Float64
─────┼─────────────────────────────────────────────────────────
   1 │ 2019-01-02   0.0023   0.006    0.0113   0.0001  -0.023
   2 │ 2019-01-03  -0.0245   0.0036   0.012    0.0001  -0.0078
   3 │ 2019-01-04   0.0355   0.0041  -0.007    0.0001  -0.0097
   4 │ 2019-01-07   0.0094   0.0101  -0.0074   0.0001  -0.0077
   5 │ 2019-01-08   0.0101   0.0054  -0.0063   0.0001   0.0011
   6 │ 2019-01-09   0.0056   0.0046   0.001    0.0001  -0.0083
   7 │ 2019-01-10   0.0042   0.0003  -0.0046   0.0001  -0.0037
   8 │ 2019-01-11  -0.0001   0.0012   0.0022   0.0001  -0.002
  ⋮  │     ⋮          ⋮        ⋮        ⋮        ⋮        ⋮
 666 │ 2021-08-23   0.0108   0.0092  -0.0083   0.0      0.0076
 667 │ 2021-08-24   0.0041   0.0034   0.0038   0.0      0.0094
 668 │ 2021-08-25   0.0026  -0.0004   0.0034   0.0      0.0072
 669 │ 2021-08-26  -0.0066  -0.004   -0.0031   0.0     -0.0031
 670 │ 2021-08-27   0.0108   0.0162   0.003    0.0      0.0094
 671 │ 2021-08-30   0.0035  -0.0035  -0.0138   0.0     -0.0117
 672 │ 2021-08-31  -0.0013   0.0043  -0.0005   0.0     -0.002
                                               657 rows omitted

Arranging and Accessing the Data

Next, load the data into a MarketData object:

mkt_data = MarketData(
    df_mkt,
    df_firm;
    id_col=:permno,# default
    date_col_firms=:date,# default
    date_col_market=:date,# default
    add_intercept_col=true,# default
    valuecols_firms=[:ret],# defaults to nothing, in which case
    # all columns other than id and date are used
    valuecols_market=[:mktrf, :rf, :smb, :hml, :umd]# defaults to
    # nothing, in which case all columns other than date are used
)
MarketData with ID type Int64 with 99 unique firms
MarketCalendar: 2019-01-02 .. 2021-08-31 with 672 business days
Market Columns: hml, umd, intercept, mktrf, smb, rf
Firm Columns: ret
Note

For performance, especially when loading large datasets of firm data, it is best to make sure the firm dataframe is presorted by ID then Date.

This object rearranges the data so it can be quickly accessed later. The mkt_data now contains 3 things:

  1. A BusinessDays.jl calendar that exactly matches the days loaded in the market data.
  2. Each column of the df_mkt stored
  3. Each column of the df_firm stored in a Dict for each firm.

Data is accessed on a by firm basis, for a given date range and specific columns. For example, say you wanted to get the data for Oracle (ORCL) ("Permno" of 10104), for a specific date (using IntervalSets.jl) and set of columns:

julia> orcl_data = mkt_data[10104, Date(2020) .. Date(2020, 6, 30), [:ret, :mktrf, :smb]]125×3 FixedTable{3, Float64, SubArray{Float64, 1, OffsetArrays.OffsetVector{Float64, Vector{Float64}}, Tuple{UnitRange{Int64}}, true}, Symbol}:
  0.0183088    0.0086  -0.0089
 -0.00352182  -0.0067   0.0039
  0.00520838   0.0036  -0.0007
  0.00222056  -0.0019  -0.0001
  0.00387742   0.0047  -0.0006
  0.00461851   0.0065  -0.0064
  0.00128723  -0.0034  -0.0018
  0.00238753   0.0073  -0.0011
  0.0054965   -0.0006   0.0038
 -0.00218664   0.0016   0.0046
  ⋮
  0.0421195    0.0019   0.0009
  0.0132241   -0.0045   0.0009
  0.0132352    0.0071   0.0083
  0.00126995   0.0042   0.0016
 -0.0135894   -0.0261  -0.0045
  0.0016532    0.0112   0.0023
 -0.00641846  -0.0244   0.0013
  0.010705     0.0151   0.0126
  0.00931341   0.0158   0.0008

Sometimes it is helpful to add a new column (either for convenience or performance reasons, discussed later). To do so, this package borrows the transform! function from DataFrames.jl, using a formula where the left side is the column that is created:

julia> transform!(mkt_data, @formula(mkt ~ mktrf + rf));

It is also easy to specify the columns as a formula from StatsModels.jl. This allows for arbitrary functions, interactions and lags/leads:

julia> orcl_data = mkt_data[10104, Date(2020) .. Date(2020, 6, 30), @formula(ret ~ mkt + lag(mkt) + log1p(smb) * hml)]125×7 FixedTable{7, Float64, SubArray{Float64, 1, OffsetArrays.OffsetVector{Float64, Vector{Float64}}, Tuple{UnitRange{Int64}}, true}, String}:
  0.0183088   1.0   0.00866   0.00287  -0.00893984   -0.0032   2.86075e-5
 -0.00352182  1.0  -0.00664   0.00866   0.00389241    0.0      0.0
  0.00520838  1.0   0.00366  -0.00664  -0.000700245  -0.0054   3.78132e-6
  0.00222056  1.0  -0.00184   0.00366  -0.000100005  -0.0025   2.50013e-7
  0.00387742  1.0   0.00476  -0.00184  -0.00060018   -0.0065   3.90117e-6
  0.00461851  1.0   0.00656   0.00476  -0.00642057   -0.0049   3.14608e-5
  0.00128723  1.0  -0.00334   0.00656  -0.00180162   -0.0036   6.48584e-6
  0.00238753  1.0   0.00736  -0.00334  -0.00110061   -0.0009   9.90545e-7
  0.0054965   1.0  -0.00054   0.00736   0.0037928    -0.0016  -6.06848e-6
 -0.00218664  1.0   0.00166  -0.00054   0.00458945   -0.008   -3.67156e-5
  ⋮                                                   ⋮
  0.0421195   1.0   0.0019   -0.004     0.000899595  -0.0053  -4.76785e-6
  0.0132241   1.0  -0.0045    0.0019    0.000899595  -0.0065  -5.84737e-6
  0.0132352   1.0   0.0071   -0.0045    0.00826574   -0.0133  -0.000109934
  0.00126995  1.0   0.0042    0.0071    0.00159872   -0.006   -9.59233e-6
 -0.0135894   1.0  -0.0261    0.0042   -0.00451016   -0.0136   6.13381e-5
  0.0016532   1.0   0.0112   -0.0261    0.00229736    0.0053   1.2176e-5
 -0.00641846  1.0  -0.0244    0.0112    0.00129916   -0.014   -1.81882e-5
  0.010705    1.0   0.0151   -0.0244    0.0125213     0.0183   0.000229139
  0.00931341  1.0   0.0158    0.0151    0.00079968    0.0001   7.9968e-8
Note

While interactions and arbitrary functions are supported, they can significantly slow down performance since a new vector is allocated in each call. Therefore, it is generally recommended to create a new pice of data by calling transform! on the dataset to create the new columns. This advice does not apply to lag/lead terms since those do not need to allocate a new column.

The data returned by accessing mkt_data is a FixedTable, which is essentially a matrix with a fixed width (useful for multiplication and returning a StaticMatrix from StaticArrays.jl). Access into this data is done either by a slice as you would any other matrix:

julia> orcl_data[:, 1]125-element view(OffsetArray(::Vector{Float64}, 1:505), 253:377) with eltype Float64:
  0.018308818340301514
 -0.0035218247212469578
  0.0052083819173276424
  0.002220557536929846
  0.003877422772347927
  0.004618511069566011
  0.0012872322695329785
  0.002387531101703644
  0.005496504716575146
 -0.0021866390015929937
  ⋮
  0.042119529098272324
  0.013224118389189243
  0.013235245831310749
  0.0012699509970843792
 -0.01358941849321127
  0.0016531989676877856
 -0.006418457254767418
  0.010705020278692245
  0.00931340642273426

Or via the names used to access it in the first place:

julia> orcl_data[:, :mkt]125-element view(OffsetArray(::Vector{Float64}, 1:672), 253:377) with eltype Float64:
  0.00866
 -0.00664
  0.00366
 -0.00184
  0.0047599999999999995
  0.006560000000000001
 -0.0033399999999999997
  0.00736
 -0.0005399999999999999
  0.00166
  ⋮
  0.0019
 -0.004500000000000001
  0.0071
  0.0042
 -0.0261
  0.0112
 -0.0244
  0.0151
  0.0158

Estimating Regressions

The main goal of this package is quickly running regressions for firm events. The example used here is a firm's earnings announcement. Starting with one example, Oracle announced its Q3 2020 earnings on 2020-9-10. Calculating abnormal returns typically follows three steps:

  1. Estimate how the firm typically responds to market factors during a control (or estimation) window
  2. Use the coefficients from that regression to estimate how the firm should do during the event window
  3. Subtract the estimated return from the actual firm return during the event window. Depending on how this difference is aggregated, these are typically buy and hold abnormal returns (bhar) or cumulative abnormla returns (CAR)

First, to create the table for the estimation window, define an estimation window and an event window:

julia> event_date = Date("2020-09-10")2020-09-10
julia> est_start = advancebdays(mkt_data.calendar, event_date, -120)2020-03-20
julia> est_end = advancebdays(mkt_data.calendar, event_date, -2)2020-09-08
julia> event_start = advancebdays(mkt_data.calendar, event_date, -1)2020-09-09
julia> event_end = advancebdays(mkt_data.calendar, event_date, 1)2020-09-11

Next, run the estimation regression (the regression automatically selects the correct columns from the data, so it is not necessary to do that beforehand):

orcl_data = mkt_data[10104, est_start .. est_end]
rr = quick_reg(orcl_data, @formula(ret ~ mkt + smb + hml))
Obs: 119, ret ~ -0.0 + 0.77*mkt + -0.302*smb + -0.012*hml, AdjR2: 52.744%

Then get the data for the event window:

julia> orcl_data = mkt_data[10104, event_start .. event_end];

Now it is easy to run some statistics for the event window:

julia> bhar(orcl_data, rr) # BHAR based on regression0.026330396109940146
julia> car(orcl_data, rr) # CAR based on regression0.02612077404623775

It is also easy to calculate some statistics for the estimation window:

julia> var(rr) # Variance of firm returns (similar equation for standard deviation)0.00020283685456709137
julia> beta(rr) # Firm's market beta0.7698040453871025
julia> alpha(rr) # Firm's market alpha-0.0002921887656030498

More Data Using DataFramesMeta

While the above works well, abnormal returns are often calculated on thousands or more firm-events. Here, I use earnings announcements for about 100 firms from March to November 2020:

julia> df_events = CSV.File(joinpath(data_dir, "firm_earnings_announcements.csv")) |> DataFrame283×2 DataFrame
 Row │ permno  ea
     │ Int64   Date
─────┼────────────────────
   1 │  49373  2020-03-05
   2 │  23660  2020-03-19
   3 │  17144  2020-03-18
   4 │  52708  2020-03-19
   5 │  57665  2020-03-24
   6 │  61621  2020-03-25
   7 │  10104  2020-03-12
   8 │  75154  2020-03-19
  ⋮  │   ⋮         ⋮
 277 │  90373  2020-10-29
 278 │  90386  2020-11-02
 279 │  90993  2020-10-29
 280 │  90880  2020-10-28
 281 │  91668  2020-10-30
 282 │  12449  2020-11-05
 283 │  61399  2020-11-18
          268 rows omitted

Using DataFramesMeta.jl and the @chain macro from Chain.jl, the above steps become:

df_events = @chain df_events begin
    @rtransform(
        :est_start = advancebdays(mkt_data.calendar, :ea, -120),
        :est_end = advancebdays(mkt_data.calendar, :ea, -2),
        :event_start = advancebdays(mkt_data.calendar, :ea, -1),
        :event_end = advancebdays(mkt_data.calendar, :ea, 1),
    )
    @rtransform(:reg = quick_reg(mkt_data[:permno, :est_start .. :est_end], @formula(ret ~ mkt + smb + hml)))
    @rtransform(
        :bhar_reg = bhar(mkt_data[:permno, :event_start .. :event_end], :reg),
        :bhar_simple = bhar(mkt_data[:permno, :event_start .. :event_end, ["ret", "mkt"]]),
        :std = std(:reg),
        :total_ret = bh_return(mkt_data[:permno, :event_start .. :event_end, ["ret"]]),
    )
    select(Not([:est_start, :est_end, :event_start, :event_end, :reg]))
end
283×6 DataFrame
 Row │ permno  ea          bhar_reg     bhar_simple  std         total_ret
     │ Int64   Date        Float64?     Float64      Float64?    Float64
─────┼────────────────────────────────────────────────────────────────────────
   1 │  49373  2020-03-05  -0.0114753   -0.0368966   0.0120205   -0.0494716
   2 │  23660  2020-03-19  -0.0761877   -0.0799651   0.0100327   -0.16273
   3 │  17144  2020-03-18   0.00584866  -0.00749583  0.0122784    0.00562007
   4 │  52708  2020-03-19   0.0449673    0.057314    0.023234    -0.0254504
   5 │  57665  2020-03-24   0.105811     0.0931054   0.0105352    0.171386
   6 │  61621  2020-03-25   0.125142     0.129935    0.0132682    0.303036
   7 │  10104  2020-03-12   0.0295405    0.0515017   0.00903142  -0.01338
   8 │  75154  2020-03-19   0.15375      0.0269029   0.0312084   -0.0558615
  ⋮  │   ⋮         ⋮            ⋮            ⋮           ⋮            ⋮
 277 │  90373  2020-10-29  -0.00714584  -0.00486512  0.016151    -0.0422143
 278 │  90386  2020-11-02  -0.120063    -0.0769287   0.0256068   -0.0606557
 279 │  90993  2020-10-29  -0.00087777   0.00495886  0.0109232   -0.0323903
 280 │  90880  2020-10-28   0.00943539  -0.00174027  0.0109558   -0.0271723
 281 │  91668  2020-10-30   0.0290468    0.0200758   0.017888     0.0283726
 282 │  12449  2020-11-05  -0.0402344   -0.0572696   0.0183221   -0.0128859
 283 │  61399  2020-11-18  -0.0761987   -0.0706244   0.0134081   -0.0759727
                                                              268 rows omitted

Vectorizing the Data

While the above works, and is reasonably fast (Doing a test on 1 million regressions takes about 26 seconds on a Ryzen 7 5700X), faster is better.

In particular, a significant reason the above is slow method is that the formula is parsed for each iteration. If the formula is the same for all of the cases, it is better if it is simply parsed once. Therefore, it is optimal to do as much as possible using vectors.

To make this possible, this package provides a type IterateFixedTable which will return a FixedTable based on a supplied set of ids, dates and columns (or formula as above):

julia> est_starts = advancebdays.(mkt_data.calendar, df_events.ea, -120)283-element Vector{Dates.Date}:
 2019-09-12
 2019-09-26
 2019-09-25
 2019-09-26
 2019-10-01
 2019-10-02
 2019-09-19
 2019-09-26
 2019-09-10
 2019-11-13
 ⋮
 2020-05-13
 2020-05-07
 2020-05-11
 2020-05-13
 2020-05-11
 2020-05-08
 2020-05-12
 2020-05-18
 2020-06-01
julia> est_ends = advancebdays.(mkt_data.calendar, df_events.ea, -2)283-element Vector{Dates.Date}: 2020-03-03 2020-03-17 2020-03-16 2020-03-17 2020-03-20 2020-03-23 2020-03-10 2020-03-17 2020-02-28 2020-05-05 ⋮ 2020-10-29 2020-10-23 2020-10-27 2020-10-29 2020-10-27 2020-10-26 2020-10-28 2020-11-03 2020-11-16
julia> vec_data = mkt_data[df_events.permno, est_starts .. est_ends, [:ret, :mkt, :smb]]Iterable set of FixedTable with 283 unique datapoints MarketData with ID type Int64 with 99 unique firms MarketCalendar: 2019-01-02 .. 2021-08-31 with 672 business days Market Columns: mkt, hml, umd, intercept, mktrf, smb, rf Firm Columns: ret

Each element of vec_data is then easily accessible by an integer or can be looped over in a for loop:

# for x in vec_data
#     x
# end
# or
vec_data[10]
119×3 FixedTable{3, Float64, SubArray{Float64, 1, OffsetArrays.OffsetVector{Float64, Vector{Float64}}, Tuple{UnitRange{Int64}}, true}, Symbol}:
 -0.0395488    0.00016  -0.0022
 -0.016239     0.00076  -0.001
  0.0025055    0.00746  -0.0026
 -0.0180829    0.00026  -0.0035
 -0.00883372   0.00026   0.0058
  0.0140483   -0.00324   0.0
 -0.00327713  -0.00134  -0.0036
 -0.0379615    0.00246   0.0005
  0.00590333   0.00926   0.0132
 -0.0182239    0.00196   0.0006
  ⋮                     
  0.0581513    0.0013    0.0112
  0.0189585    0.0144    0.0027
  0.0167216    0.0173    0.0161
  0.0261756   -0.0045    0.01
  0.106321     0.0292    0.0188
 -0.00754946  -0.0118   -0.0142
 -0.0896382   -0.0291   -0.0056
  0.045393     0.0053    0.0018
 -0.00324047   0.0095    0.0004

This object can be similarly passed to the above functions, just like a firm level table. The function will iterate through the data and return a vector of results.

However, the above is rather ugly. A more practical way to use this is to continue using the @chain macro:

df_events = @chain df_events begin
    @rtransform(
        :est_start = advancebdays(mkt_data.calendar, :ea, -120),
        :est_end = advancebdays(mkt_data.calendar, :ea, -2),
        :event_start = advancebdays(mkt_data.calendar, :ea, -1),
        :event_end = advancebdays(mkt_data.calendar, :ea, 1),
    )
    @transform(:reg = quick_reg(mkt_data[:permno, :est_start .. :est_end], @formula(ret ~ mkt + smb + hml)))
    @transform(
        :bhar_reg = bhar(mkt_data[:permno, :event_start .. :event_end], :reg),
        :bhar_simple = bhar(mkt_data[:permno, :event_start .. :event_end, ["ret", "mkt"]]),
    )
    @transform(
        :std = std.(:reg),
        :total_ret = bh_return(mkt_data[:permno, :event_start .. :event_end, ["ret"]]),
    )
    select(Not([:est_start, :est_end, :event_start, :event_end, :reg]))
end
283×6 DataFrame
 Row │ permno  ea          bhar_reg     bhar_simple  std         total_ret
     │ Int64   Date        Float64?     Float64      Float64?    Float64
─────┼────────────────────────────────────────────────────────────────────────
   1 │  49373  2020-03-05  -0.0114753   -0.0368966   0.0120205   -0.0494716
   2 │  23660  2020-03-19  -0.0761877   -0.0799651   0.0100327   -0.16273
   3 │  17144  2020-03-18   0.00584866  -0.00749583  0.0122784    0.00562007
   4 │  52708  2020-03-19   0.0449673    0.057314    0.023234    -0.0254504
   5 │  57665  2020-03-24   0.105811     0.0931054   0.0105352    0.171386
   6 │  61621  2020-03-25   0.125142     0.129935    0.0132682    0.303036
   7 │  10104  2020-03-12   0.0295405    0.0515017   0.00903142  -0.01338
   8 │  75154  2020-03-19   0.15375      0.0269029   0.0312084   -0.0558615
  ⋮  │   ⋮         ⋮            ⋮            ⋮           ⋮            ⋮
 277 │  90373  2020-10-29  -0.00714584  -0.00486512  0.016151    -0.0422143
 278 │  90386  2020-11-02  -0.120063    -0.0769287   0.0256068   -0.0606557
 279 │  90993  2020-10-29  -0.00087777   0.00495886  0.0109232   -0.0323903
 280 │  90880  2020-10-28   0.00943539  -0.00174027  0.0109558   -0.0271723
 281 │  91668  2020-10-30   0.0290468    0.0200758   0.017888     0.0283726
 282 │  12449  2020-11-05  -0.0402344   -0.0572696   0.0183221   -0.0128859
 283 │  61399  2020-11-18  -0.0761987   -0.0706244   0.0134081   -0.0759727
                                                              268 rows omitted

Notice that the only difference between these two @chain macros is that this one uses @transform instead of @rtransform. This sends the entire column vector to the function, and allows for much faster overall results. Those same 1 million regressions now takes just 0.44 seconds on the same computer.