Cointegration

Here we demonstrate the most basic form of Cointegration/statistical arbitrage by pair trading AAPL and MSFT

using Trading
using Trading.Strategies
using Trading.Basic
using Trading.Indicators
using Trading.Portfolio
using Trading.Time

First we define our strategy Systems and the additional components that we need.

@component struct Spread <: Trading.SingleValIndicator{Float64}
    v::Float64
end

struct SpreadCalculator <: System
    γ::NTuple{5,Float64} # 1 cointegration ratio per day of the week
end

Overseer.requested_components(::SpreadCalculator) = (LogVal{Close}, )

struct PairStrat{horizon} <: System
    γ::NTuple{5,Float64} # 1 cointegration ratio per day of the week
    z_thr::Float64 # Threshold of the z_score above or below which we enter positions
end
Overseer.requested_components(::PairStrat{horizon}) where {horizon} = (Spread, SMA{horizon, Spread},MovingStdDev{horizon, Spread})

@component struct ZScore{T} <: Trading.SingleValIndicator{Float64}
    v::T
end

Subtyping the SingleValIndicator will allow the automatic calculation of the single moving average and moving standard deviation necessary for our strategy. It is also important to have .v as the value field. If this is undesired you can overload Trading.value for your component type.

Next we specify the update functions. In this more complicated strategy, we need to keep track of the Spread between two assets. To facilitate this notion of shared or combined data, a ledger will be automatically generated with the name of shared assets separated by _, i.e. MSFT_AAPL in our current example. This combined ledger will be the last entry in the asset_ledgers argument.

function Overseer.update(s::SpreadCalculator, m::Trading.Trader, asset_ledgers)

    @assert length(asset_ledgers) == 3 "Pairs Strategy only implemented for 2 assets at a time"
    combined_ledger = asset_ledgers[end]

    curt = current_time(m)

    # We clear all data from the previous day at market open
    if Trading.is_market_open(curt)
        for l in asset_ledgers[1:2]
            reset!(l, s)
        end
    end

    new_bars1 = new_entities(asset_ledgers[1], s)
    new_bars2 = new_entities(asset_ledgers[2], s)

    assets = map(x->x.asset, asset_ledgers[1:2])
    @assert length(new_bars1) == length(new_bars2) "New bars differ for assets $assets"

    γ = s.γ[dayofweek(curt)]
    for (b1, b2) in zip(new_bars1, new_bars2)
        Entity(combined_ledger, Trading.TimeStamp(curt), Spread(b1.v - γ * b2.v))
    end
    update(combined_ledger)
end

function Overseer.update(s::PairStrat, m::Trading.Trader, asset_ledgers)
    curt = current_time(m)
    if Trading.is_market_open(curt)
        reset!(asset_ledgers[end], s)
    end

    !in_day(curt) && return

    cash = m[Trading.PurchasePower][1].cash
    new_pos = any(x -> x ∉ m[Trading.Order], @entities_in(m, Purchase || Sale))

    asset1 = asset_ledgers[1].asset
    asset2 = asset_ledgers[2].asset

    γ = s.γ[dayofweek(curt)]
    z_comp = asset_ledgers[end][ZScore{Spread}]

    for e in new_entities(asset_ledgers[end], s)

        v         = e.v
        sma       = e.sma
        σ         = e.σ
        z_score   = (v - sma) / σ
        asset_ledgers[end][e] = ZScore{Spread}(z_score)
        new_pos && continue


        curpos1 = current_position(m, asset1)
        curpos2 = current_position(m, asset2)

        p1 = current_price(m, asset1)
        p2 = current_price(m, asset2)

        quantity2(n1) = round(Int, n1 * p1 * γ / p2)

        in_bought_leg = curpos1 > 0
        in_sold_leg = curpos1 < 0

        if z_score < -s.z_thr&& (in_sold_leg || curpos1 == 0)
            new_pos = true
            if in_sold_leg
                q = -2*curpos1
            else
                q = cash/p1
            end
            Entity(m, Purchase(asset1, round(Int, q)))
            Entity(m, Sale(asset2, quantity2(q)))


        elseif z_score > s.z_thr && (in_bought_leg || curpos1 == 0)
            new_pos = true
            if in_bought_leg
                q = 2*curpos1
            else
                q = cash / p1
            end
            Entity(m, Purchase(asset2, quantity2(q)))
            Entity(m, Sale(asset1, round(Int, q)))
        end

        prev_e = prev(e, 1)
        prev_e === nothing && continue

        if new_pos || prev_e ∉ z_comp
            continue
        end

        going_up = z_score - z_comp[prev_e].v > 0
        if z_score > 0 && in_bought_leg && !going_up
            Entity(m, Sale(asset1, curpos1))
            Entity(m, Purchase(asset2, -curpos2))
            new_pos = true
        elseif z_score < 0 && in_sold_leg && going_up
            Entity(m, Purchase(asset1, -curpos1))
            Entity(m, Sale(asset2, curpos2))
            new_pos = true
        end
    end
end

As usual we then specify our broker for the backtest. We here show an advanced feature that allows one to specify transaction fees associated with trades. We here define a fee per traded share, but no fixed or variable transaction fees. See HistoricalBroker for more information.

broker = HistoricalBroker(AlpacaBroker(ENV["ALPACA_KEY_ID"], ENV["ALPACA_SECRET"]))

broker.variable_transaction_fee = 0.0
broker.fee_per_share = 0.005
broker.fixed_transaction_fee = 0.0;

Next we specify the daily cointegration parameters that were fit to 2022 data, and run the backtest on Minute data from the first 3 months of 2023.

γ = (0.83971041721211, 0.7802162996942561, 0.8150936011572303, 0.8665354500999517, 0.8253480013737815)

stratsys = [SpreadCalculator(γ), PairStrat{20}(γ, 2.5)]
sim_start =TimeDate("2023-01-01T00:00:00")
sim_stop = TimeDate("2023-03-31T23:59:59")
trader = BackTester(broker; strategies=[Strategy(:pair, stratsys, assets=[Stock("MSFT"), Stock("AAPL")])],
                            start=sim_start,
                            stop=sim_stop)

start(trader)
Trader

Main task:    Task (done) @0x00007fcb2acb04c0
Trading task: Task (done) @0x00007fcb2acb01a0
Data tasks:   Dict{Trading.AssetType.T, Task}(Trading.AssetType.Stock => Task (done) @0x00007fcb2acb0330)

Portfolio -- positions: 0.0, cash: 764162.0893833287, tot: 764162.0893833287

Current positions:
┌────────┬──────────┬───────┐
│ Ticker │ Quantity │ Value │
├────────┼──────────┼───────┤
│   AAPL │      0.0 │   0.0 │
│   MSFT │      0.0 │   0.0 │
└────────┴──────────┴───────┘

Strategies:

Trades:
┌─────────────────────┬────────┬──────┬──────────┬───────────┬───────────┐
│                Time │ Ticker │ Side │ Quantity │ Avg Price │ Tot Price │
├─────────────────────┼────────┼──────┼──────────┼───────────┼───────────┤
│ 2023-03-31T21:56:00 │   AAPL │ sell │   3832.0 │    164.92 │ 6.31973e5 │
│ 2023-03-31T21:56:00 │   MSFT │  buy │   2650.0 │    288.38 │  764207.0 │
│ 2023-03-31T21:46:00 │   MSFT │ sell │   2650.0 │    288.01 │ 7.63226e5 │
│ 2023-03-31T21:46:00 │   AAPL │  buy │   3832.0 │    164.38 │ 6.29904e5 │
│ 2023-03-31T20:38:00 │   AAPL │  buy │   3837.0 │    164.06 │ 6.29498e5 │
│ 2023-03-31T20:37:00 │   MSFT │ sell │   2661.0 │    286.92 │ 7.63494e5 │
│ 2023-03-31T20:29:00 │   MSFT │  buy │   2661.0 │    286.54 │ 7.62483e5 │
│ 2023-03-31T20:28:00 │   AAPL │ sell │   3837.0 │    163.99 │  6.2923e5 │
│ 2023-03-31T20:18:00 │   AAPL │ sell │   3842.0 │   164.035 │ 6.30222e5 │
│ 2023-03-31T20:18:00 │   MSFT │  buy │   2663.0 │   286.753 │ 7.63624e5 │
│ 2023-03-31T19:47:00 │   MSFT │ sell │   2663.0 │    286.43 │ 7.62763e5 │
│ 2023-03-31T19:47:00 │   AAPL │  buy │   3842.0 │   163.849 │ 6.29506e5 │
│ 2023-03-31T19:37:00 │   MSFT │ sell │   2662.0 │    286.26 │ 7.62024e5 │
│ 2023-03-31T19:37:00 │   AAPL │  buy │   3841.0 │    163.78 │  629079.0 │
│ 2023-03-31T19:22:00 │   AAPL │ sell │   3841.0 │   163.959 │  629765.0 │
│ 2023-03-31T19:22:00 │   MSFT │  buy │   2662.0 │    286.66 │ 7.63089e5 │
│          ⋮          │   ⋮    │  ⋮   │    ⋮     │     ⋮     │     ⋮     │
└─────────────────────┴────────┴──────┴──────────┴───────────┴───────────┘
                                                         1463 rows omitted

We then can analyse our portfolio value. Here we use Trading.only_trading to only show the data during trading days.

using Plots
plot(only_trading(TimeArray(trader)[:portfolio_value]))

Again we see that this strategy does not particularly work.

Invert

The interesting part is that our strategy is not just bad, it's consistently bad. This means that again, we can invert it and theoretically get a consistently good strategy (big grains of salt here). This can be achieved by inserting one line in the above PairStrat: asset1, asset2 = asset2, asset1.

function Overseer.update(s::PairStrat, m::Trading.Trader, asset_ledgers)
    curt = current_time(m)
    if Trading.is_market_open(curt)
        reset!(asset_ledgers[end], s)
    end

    !in_day(curt) && return

    cash = m[Trading.PurchasePower][1].cash
    new_pos = any(x -> x ∉ m[Trading.Order], @entities_in(m, Purchase || Sale))

    asset1 = asset_ledgers[1].asset
    asset2 = asset_ledgers[2].asset

    γ = s.γ[dayofweek(curt)]
    z_comp = asset_ledgers[end][ZScore{Spread}]

    for e in new_entities(asset_ledgers[end], s)

        v         = e.v
        sma       = e.sma
        σ         = e.σ
        z_score   = (v - sma) / σ
        asset_ledgers[end][e] = ZScore{Spread}(z_score)
        new_pos && continue

        asset1, asset2 = asset2, asset1

        curpos1 = current_position(m, asset1)
        curpos2 = current_position(m, asset2)

        p1 = current_price(m, asset1)
        p2 = current_price(m, asset2)

        quantity2(n1) = round(Int, n1 * p1 * γ / p2)

        in_bought_leg = curpos1 > 0
        in_sold_leg = curpos1 < 0

        if z_score < -s.z_thr&& (in_sold_leg || curpos1 == 0)
            new_pos = true
            if in_sold_leg
                q = -2*curpos1
            else
                q = cash/p1
            end
            Entity(m, Purchase(asset1, round(Int, q)))
            Entity(m, Sale(asset2, quantity2(q)))


        elseif z_score > s.z_thr && (in_bought_leg || curpos1 == 0)
            new_pos = true
            if in_bought_leg
                q = 2*curpos1
            else
                q = cash / p1
            end
            Entity(m, Purchase(asset2, quantity2(q)))
            Entity(m, Sale(asset1, round(Int, q)))
        end

        prev_e = prev(e, 1)
        prev_e === nothing && continue

        if new_pos || prev_e ∉ z_comp
            continue
        end
        going_up = z_score - z_comp[prev_e].v > 0
        if z_score > 0 && in_bought_leg && !going_up
            Entity(m, Sale(asset1, curpos1))
            Entity(m, Purchase(asset2, -curpos2))
            new_pos = true
        elseif z_score < 0 && in_sold_leg && going_up
            Entity(m, Purchase(asset1, -curpos1))
            Entity(m, Sale(asset2, curpos2))
            new_pos = true
        end
    end
end

We reset the trader, and check our results (see Trading.relative):

reset!(trader)
start(trader)

ta = Trading.relative(only_trading(TimeArray(trader)))
to_plot = merge(ta[:portfolio_value], Trading.relative(rename(bars(broker, Stock("MSFT"), sim_start, sim_stop, timeframe=Minute(1))[:c], :MSFT_Close)),
                    Trading.relative(rename(bars(broker, Stock("AAPL"), sim_start, sim_stop, timeframe=Minute(1))[:c], :AAPL_Close)))

plot(to_plot)

Behold, a seemingly succesful strategy.

Performance analysis

See Performance Analysis

using Trading.Analysis

println("""
sharpe:           $(sharpe(trader))
downside_risk:    $(downside_risk(trader))
value_at_risk:    $(value_at_risk(trader))
maximum_drawdown: $(maximum_drawdown(trader))
"""
);
sharpe:           0.4700558295206738
downside_risk:    0.01193056883857176
value_at_risk:    -0.006594468076949317
maximum_drawdown: 0.03700608580708509