12 in 24 February: Stock Trading Bot

Well here we go again. I'll be building a stock trading bot in Julia using Ally Bank to execute trades and Alpha Vantage for stocks. I started by implementing the Aroon Oscillator in Julia, to see if I could implement an actual technical indicator.

The Aroon Oscillator function looks like this:

\[oscillator = Aroon Up - Aroon Down\]

\[Aroon Up = 100 * \frac{(25 - PeriodsSince25PeriodHigh)}{25} \]

\[Aroon Down = 100 * \frac{(25 - PeriodsSince25PeriodLow)}{25} \]

To implement in Julia, I had to get the financial data first, then slide an Aroon Oscillator function over it.

function daily_df_yfinance(symbol, period)
    data = DataFrame(yahoo(symbol, period))
    rename!(data, [:timestamp, :open, :high, :low, :close, :adjclose, :volume])
    data
end

function aroon(df)
    if length(df.timestamp) < 25
        return nothing
    end
    periods = first(df, 25)
    aroon_up = 100 * ((25 - argmax(periods.high)) / 25)
    aroon_down = 100 * ((25 - argmin(periods.low)) / 25)
    aroon_up - aroon_down
end

Julia makes things simple, so that I can focus on the actual indicator that I'm building.

Displaying the Aroon Oscillator

Let's actually look at it, and see what it looks like for a variety of stocks and ticker symbols. I used Plots.jl to display the data, and generated an Aroon dataframe to eventually plot

sym = :TSLA
df = MyAlgoTrader.daily_df_yfinance(sym, YahooOpt(period1=DateTime(2023,1,1)))

# Pass a sliding window over the aroon window
aroon = DataFrame(MyAlgoTrader.aroon_series(df))

using Plots

p1 = plot(aroon.ts, aroon.A)
p2 = plot(aroon.ts, df.close)
plot(p1, p2, layout=(2,1))

Here's what the plot looked like:

Aroon Oscillator for TLSA, close price plot below.

The above line is the Aroon Oscillator for TSLA, the below line is the daily adjusted close for TSLA since 2023. You can see as the line oscillates around 0 whether an uptrend or a downtrend is indicated. Above 0 means that an uptrend is present, below 0 means a downtrend is present. We can use this to implement a trend-following strategy.

Mean Reversion Strategy

The end goal of this strategy is to fit a line to any given stock, and then find the standard deviations up and down from that line. We trade based on values reverting to the mean.

\[y = mx + ny + b \]

m and n are the weights we are trying to learn. x and y are the features. b is the bias. What I want to do is forecast the values of assets using a simple linear regression. Why linear regression? Because this is a great place to start due to it's simplicity and understandability.

We are trying to learn m and n, and we are trying to minimize error from the learned m and n by choosing values that minimize the squared error between the target and the predictions.

Initial Analysis

We can begin the analysis using a "time dummy", which is just the time steps since the origin time 0.

Here's what that looks like in Julia:

julia> first(df, 10)
10×7 DataFrame
 Row │ timestamp   open     high     low      close    adjclose  volume    
     │ Date        Float64  Float64  Float64  Float64  Float64   Float64   
─────┼─────────────────────────────────────────────────────────────────────
   1 │ 2023-01-03   118.47   118.8    104.64   108.1     108.1   2.31403e8
   2 │ 2023-01-04   109.11   114.59   107.52   113.64    113.64  1.80389e8
   3 │ 2023-01-05   110.51   111.75   107.16   110.34    110.34  1.57986e8
   4 │ 2023-01-06   103.0    114.39   101.81   113.06    113.06  2.20911e8
   5 │ 2023-01-09   118.96   123.52   117.11   119.77    119.77  1.90284e8
   6 │ 2023-01-10   121.07   122.76   114.92   118.85    118.85  1.67642e8
   7 │ 2023-01-11   122.09   125.95   120.51   123.22    123.22  1.83811e8
   8 │ 2023-01-12   122.56   124.13   117.0    123.56    123.56  1.69401e8
   9 │ 2023-01-13   116.55   122.63   115.6    122.4     122.4   1.80714e8
  10 │ 2023-01-17   125.7    131.7    125.02   131.49    131.49  1.86477e8

julia> df.dummy = 1:nrow(df)
1:282

julia> first(df, 1)
1×8 DataFrame
 Row │ timestamp   open     high     low      close    adjclose  volume     dummy 
     │ Date        Float64  Float64  Float64  Float64  Float64   Float64    Int64 
─────┼────────────────────────────────────────────────────────────────────────────
   1 │ 2023-01-03   118.47    118.8   104.64    108.1     108.1  2.31403e8      1

Now we are trying to fit the following line

\[ y = time * weight + bias \]

We will fit that line using the package GLM in Julia. This package mimics currently available R packages.

using GLM, StatsBase
ols = lm(@formula(adjclose ~ dummy), df)

b, m = coef(ols)
pred = @. m * (df.dummy) + b
plot(df.dummy, df.adjclose)
plot!(df.dummy, pred)

The ols variable looks like this:

Coefficients:
────────────────────────────────────────────────────────────────────────────
                  Coef.  Std. Error      t  Pr(>|t|)   Lower 95%   Upper 95%
────────────────────────────────────────────────────────────────────────────
(Intercept)  179.629      4.09776    43.84    <1e-99  171.563     187.695
dummy          0.258806   0.0251018  10.31    <1e-20    0.209394    0.308218

So what is the code above doing? pred is the result of applying our learned linear regression function to the existing dummy data. When we plot the time dummy with the adjclose, we come up with this plot

TSLA Adjusted Close with Linear Regression Fitted

So let's create a linear regression mean reversion strategy on time and price. This will be used as a simple means to trade based on our mathematical formula here.

The green bands provide us with the standard deviation above and below the linear regression line. I calculated the standard deviation from the time period of the data.

My strategy, is going to be to buy when I cross the bottom standard deviation going up, stop loss at 10% less than the bottom standard deviation, exit half the position at mean, and the rest of the position at the top standard deviation.

This can be broken down into a simple set of rules. Here is my trading strategy in it's entirety.

  1. On a new day, calculate the linear regression, standard deviation for the last 200 days.
  2. Does the last close price now fall below the linear regression - standard deviation? Hold
  3. Is the last close price now above the lower standard deviation line? Buy
  4. Did the last close price cross the linear regression going upward? Sell half.
  5. Did the last close price cross the upper standard deviation line? Sell all.

Now, how can we implement this in Julia?

Backtesting

To start, I'd like to implement my own backtester. Backtesting in stock trading means to test your strategy against historical data, and see what returns the strategy can generate. It's a crucial part to evaluating the effectiveness of the strategy.

I'll borrow ideas from the excellent package: https://kernc.github.io/backtesting.py/. I need to initialize a strategy then run it over a time period.

I can create custom types for my strategies that allow me to use Julia's multiple dispatch functionality.

Types · The Julia Language

I'll define an abstract type for my strategy, that all my other strategies can derive from. Then I'll create a concrete type that derives from Strategy to represent my mean reversion strategy.

abstract type Strategy end

# Custom strategies
mutable struct MeanReversionStrategy <: Strategy
    data::DataFrame
end

With these custom strategies I need to define backtest methods that give me the ability to test. Julia's multiple dispatch is of use here, because I can use dispatch to create a default method that warns us if the strategy backtest method has not been created.

function run(s::Strategy, money::Float64)
    @warn "Initializing a strategy without an implementation"
end

function run(s::MeanReversionStrategy, money::Float64)
  #...
end

This means that if Julia cannot find an implementation of run for a concrete strategy type, it will warn instead of doing anything. This will tell developers that they need to provide the implementation.

Finally, I put my strategy to use in a basic backtest. I don't have any useful indicators yet that I can trade on other than my half-baked linear regression, and I don't calculate anything like a Sharpe ratio yet (I'm an absolute beginner). However, I was able to put together a basic backtester that provided me with a good idea of how the strategy actually worked!

function run(s::MeanReversionStrategy, money::Float64)
    # Calculate the standard deviation.
    basis = money
    σ = std(s.data.adjclose)
    share_count = 0

    # Get the time dummy for fitting the linreg.
    s.data.dummy = 1:nrow(s.data)

    # Get the linear regression
    linreg = lm(@formula(adjclose ~ dummy), s.data)

    # With the linear regression, we can get our functions that necessitate buy or sell.
    b, m = coef(linreg)
    mean = @. m * (s.data.dummy) + b

    # High and low functions
    high = mean .+ σ
    low = mean .- σ

    @info "Starting backtest with cost basis: $(basis)"

    for i ∈ 2:nrow(s.data)
        last_close = s.data[i,:].adjclose
        previous = s.data[i-1,:].adjclose
        low_bar = low[i]
        high_bar = high[i]
        mean_bar = mean[i]

        # If we were greater than the low bar, and the previous was less.
        if last_close >= low_bar && previous < low_bar
            @info "Buy @ $(last_close)"
            share_count += 1
            money -= last_close
        # If we were greater than the mean bar and the previous was less
        elseif last_close >= mean_bar && previous < mean_bar && share_count > 0
            @info "Sell half @ $(last_close)"
            share_count -= (share_count / 2)
            money += (last_close * (share_count / 2))
        elseif last_close >= high_bar && previous < high_bar && share_count > 0
            @info "Sell remaining @ $(last_close)"
            money += last_close * share_count
            share_count = 0
        # If we've crossed a stop-loss threshold
        elseif last_close < low_bar - (low_bar * .2) && share_count > 0
            @info "Stop-loss, sell @ $(last_close)"
            money += (last_close * share_count) 
            share_count = 0
        end
    end

    @info "Ended with $(money), profit percentage: $((money - basis) / 100)"
end

This puts together all the previous ideas in a way that I can use to see how the strategy actually works on historical data. The strategy performs decently well (it doesn't lose money!)

I can call the backtest now like this

julia> MyAlgoTrader.backtest(now() - Dates.Month(30), 30000.)
[ Info: Starting backtest with cost basis: 30000.0
[ Info: Buy @ 243.636673
[ Info: Sell half @ 298.0
[ Info: Sell remaining @ 359.013336
[ Info: Buy @ 219.600006
[ Info: Sell half @ 271.706665
[ Info: Sell half @ 274.820007
[ Info: Buy @ 190.720001
[ Info: Buy @ 194.699997
[ Info: Stop-loss, sell @ 109.099998
[ Info: Buy @ 181.410004
[ Info: Buy @ 183.259995
[ Info: Buy @ 170.059998
[ Info: Buy @ 173.860001
[ Info: Sell half @ 224.570007
[ Info: Sell remaining @ 282.480011
[ Info: Ended with 29834.044184624996, profit percentage: -1.6595581537500401

Doesn't look great, but it's a great first step for me in the world of algo-trading

Next Steps

  • Further refine the backtest algorithm to be more parameterizable. Can I make stop-loss orders have a configurable threshold?
  • Better trade sizing, are there trade sizing algorithms out there that I can use?
  • Fix backtester bugs. I can't sell fractional shares 😄

Sources

The Linear Regression of Time and Price
This investment strategy can help investors be successful by identifying price trends while eliminating human bias.