Computer-aided Day Trading

1. Results

Made by Ari Brown in February - April 2021. The results are visible here, with links to other years as well. The code is available on GitHub.

This is not financial advice -- I am not a financial advisor.

2. Computer-aided Day Trading

The stock market generally overreacts. When good news is published, the price rises before subsequently falling some amount. When bad news is published, the price plummets before subsequently rising some amount. One method of automation would be scanning the news for headlines, measuring the sentiment, and then investing when the price drops -- the news tells you where to look. Another option is to scan the entire market and look for drops that likely result from such news.

My method is to do exactly that: scan the entire market for a precipitous drop in price over two days (opening of the first to closing of the second), and then invest. How can I take advantage of that? How do I know when to sell? When should I be satisfied with a stock's performance?

The database is discussed here.

3. The Process

The process starts simply: we answer the questions of "when do we buy" and "when do we sell".

A stock price will drop and then it will likely go back up, due to mean reversion. We are measuring several different dimensions for the best outcome, which make this a difficult (annoying?) problem to solve without the help of more computing power. As an intermediate step, I've settled on the figures in this document, which have lent themselves to the below results:

Year # of Buys # Unsold # Delist. Med. Hold S&P 500 Mean ROI Median STDDEV Sharpe
2022 17 17 0 (unsold) -7.394% -100.0% -100% 0.0% -Infinity
2021 124 83 10 (unsold) 27.734% -39.765% -100% 94.131% -0.422
2020 326 24 5 85 16.644% 79.78% 51.376% 106.792% 0.747
2019 56 9 6 99 28.971% 40.3% 18.254% 102.768% 0.392
2018 36 9 6 120 -6.691% 2.038% 3.093% 79.494% 0.026
2017 35 8 4 119 18.747% -9.838% 2.154% 52.169% -0.189
2016 44 5 4 82 8.97% 52.521% 69.702% 74.784% 0.702
2015 40 3 3 98 -1.981% 72.54% 25.402% 126.855% 0.572
2014 26 2 1 105 11.664% 15.221% 9.182% 45.428% 0.335
2013 14 1 1 98 32.243% 30.435% 15.355% 53.406% 0.57
2012 29 5 5 94 13.006% 16.959% 25.285% 63.027% 0.269
2011 22 1 0 100 -0.024% 11.895% 6.763% 38.076% 0.312
2010 7 0 0 83 11.51% 72.816% 51.956% 74.075% 0.983
2009 214 0 0 64 25.101% 131.657% 130.678% 87.7% 1.501
2008 357 2 0 100 -38.654% 65.516% 12.766% 178.345% 0.367
2007 17 1 0 97 2.784% 135.078% 15.147% 373.974% 0.361
2006 16 0 0 95 13.478% 65.414% 25.448% 97.949% 0.668
2005 28 0 0 97 2.646% 41.323% 14.634% 53.659% 0.77
2004 20 0 0 132 8.676% 12.973% 2.288% 19.325% 0.671
2003 23 0 0 86 26.469% 52.899% 46.833% 48.317% 1.095
2002 135 0 0 93 -24.038% 63.809% 30.597% 78.085% 0.817
2001 76 0 0 98 -13.409% 49.542% 21.118% 67.397% 0.735
2000 139 10 0 100 -10.662% 33.84% 12.119% 76.202% 0.444

4. When Do We Buy?

What a great question.

We buy when the price drops and we are confident that the price will go back up.

The strategy is to invest a little bit into a lot of stocks, betting that this "sector" of the market will go up, without placing that guarantee on any individual stock. As a result, we need to ensure that we have enough opportunities to invest (since we will likely be investing evenly into each stock -- I can't predict the future of how many buy signals we'll see over the course of the year), and we have to balance that with how long we hold each stock and how much the average ROI is.

Price Drop

First off, what does a "drop in price" look like? What kinds of drops are there? Reviewing the data from the NYSE for 2019 (1 January - 31 December), we get the following numbers for the fractional 2-day change (from opening of one day to the close of the next):

{Price Change and Occurrences 4}
tids  = Ticker.where(:exchange => 'NYSE').all.map {|t| t.id }
debut = Time.parse('1 jan 2019')
fin   = Time.parse('31 dec 2019')
bars  = Bar.where(:date => debut..fin, :ticker_id => tids)
           .order(:ticker_id, Sequel.asc(:date))
           .all
groups  = bars.group_by {|b| b.ticker_id }
changes = groups.map do |ticker_id, bars|
  bars.each_cons(2).map {|a, b| b.change_from a }
end.flatten

# This will sort the changes into the bucket that are closest in value
hist = changes.histogram [-0.35, -0.25, -0.15, -0.05, 0.05, 0.15, 0.25, 0.35]

Redefined in section 4

{Price Change and Occurrences 4} :=
2019
"Fractional 2-day Change" => "# of Occurrences"
               -100..-0.3 => 99
               -0.3..-0.2 => 243
               -0.2..-0.1 => 2532
               -0.1.. 0   => 267752
                0  .. 0.1 => 309317
                0.1.. 0.2 => 3218
                0.2.. 0.3 => 377
                0.3.. 100 => 151

Redefined in section 4

Looking at the results for 2015, we get:

{Price Change and Occurrences 4} :=
2015
"Fractional 2-day Change" => "# of Occurrences"
               -100..-0.3 => 36
               -0.3..-0.2 => 192
               -0.2..-0.1 => 2171
               -0.1.. 0   => 237530
                0  .. 0.1 => 243177
                0.1.. 0.2 => 2490
                0.2.. 0.3 => 238
                0.3.. 100 => 78

Redefined in section 4

I therefore decided that -0.2 would be sufficient to start with: we can cull the herd of opportunities from there.

Price Movement

The logic here is based on my theory of price change: the price of a stock changes when enough sales have occurred to raise the price (an unenlightening statement), with \Delta P_{min} being the minimum increase in a stock's price:

\Delta P_{day} = \Delta P_{min} * N_{shares}

Each share that is traded involves a buyer and a seller. The question is whether the change in price is positive or negative. This is marked by the minimum change in price.

The minimum increase in a stock's price comes from the idea that you can always produce a number that is between two others based, so in theory, you could always undercut someone else's bid that is still higher than the original price. Since you likely cannot pursue the depths of the Rationals when buying stock, there is some minimum price (\Delta P_{min}) that defines the minimum a stock price must increase.

With a larger minimum price increase, then fewer sales are required to raise the price to meet a certain threshold. Dividing both sides by P_{day - 1}, we get the fractional change in price:

\frac{\Delta P_{day}}{P_{day - 1}} = \frac{\Delta P_{min} * N_{shares}}{P_{day - 1}}

This now represents the fractional ROI. Assuming the P_{min} is constant for all stocks, \frac{\Delta P_{min}}{P_{day - 1}} will be greater when P is lower. We want to maximize N_{shares} and minimize P, which is summarized in our maximization of \frac{N_{shares}}{P_{day - 1}}, visible in the following rephrasing:

\frac{\Delta P_{day}}{P_{day - 1}} = \Delta P_{min} * \frac{N_{shares}}{P_{day - 1}}

The change in price works in both directions, so maximizing the above term could also lead to a sever price drop. But since we're looking at stocks after they have already dropped considerably, we're relying on mean reversion to push the price up.

today.close >= min sets a minimum price that we're willing to pay; this (generally, historically) prevents us from investing in stocks that are about to be delisted (sometimes, not always). It's better to have this than to not.

{When Do We Buy? 4}
      assessor.buy_when :history => 2 do |history|
        today     = history[-1]
        yesterday = history[-2]
      
        [[today.change_from(yesterday) <= drop,
          today.change_from(today)     <= drop].any?,
  
         today.rank <= rank,
         today.close >= min
        ].all?
      end

today.rank here is the rank of that ticker at the end of that day. It is computed through the following lines, and each ticker's daily rank and rank value are stored in the database within the bar:

{Computing Rank 4}
# {"SYM" => [Rank, Value]}
rankings = Ticker.rankings NYSE, :date => date
rankings[ticker.symbol] #=> [Rank, Value]

5. When Do We Sell?

The goal is not to get the most ROI over an arbitrary amount of time. The goal is to get the most ROI over a finite period of time, the smaller the better.

This is a work-in-progress: I am balancing individual ROI, because that affects the hold-time for each stock, because that affects when I have access to the earnings, because that affects my ability to reinvest that money into this trading plan.

Individual ROI

I want to maximize the ROI from each stock that I invest in. This is balanced with my risk-tolerance.

Risk-Tolerance Reduces Over Time

As time goes on, more opportunities to invest will arise, and, being a man with limited impulse control, I am going to invest in them.

=> This means that as time goes on, I am risking more and more of my money.

=> Since I am risking more and more of my money, I get more and more desperate to get it back.

=> Since I am getting desperate, I am going to lower my standards for the kinds of returns that I will accept.

What Degree of Polynomial?

The line that marks the threshold above which I will sell a stock could be anything, so from one perspective, I need to try polynomials of all different degrees. However, a polynomial would imply that there is a grand, overarching pattern to the way stocks rise after a drop, which I do not believe to be true, so I'm just looking to manage my risk appropriately.


Anyways, I figured a linear drop (y = m * x + b) would be good enough, so I ran 1,200 tests on the data to find which curve would give me the best results. It looked roughly like this:

{Line Fitting 5}
0.00.step(:to => 0.1, :by => 0.005) do |m|
  m = -m
  0.step(:to => 6, :by => 0.1) do |b|
    sim.m = m
    sim.b = b
    sells = sim.assess_sells

    # ...
  end
end

The output gave the results of paper trading those stocks, based on the idea that I am going to invest the same amount per investment opportunity (instead of investing the same amount per year, splitting that amount evenly across an unknown number of opportunities that will occur during the year).

This raises an interesting point: I am not looking to simply maximize the ROI over an arbitrary time horizon. Rather, I am trying to maximize my earnings, which depend on my ability to reinvest my earnings from individual stocks, which means I want to maximize my ROI but to minimize my hold time, all depending upon how many buying opportunities present themselves.

Hold Time

Minimizing the hold time while maximizing the ROI (maximizing ROI / t_{hold}) is tempting, but misleading: the result is that you won't have enough opportunities to grow your investment, since you end up selling at 10% growth as soon as you can. It turns out, this is a good strategy, if you have the opportunities for it.

Reinvesting Earnings

Reinvesting earnings requires a short hold time (compared to your ultimate time horizon).

What I have found is that maximizing the ROI while minimizing the hold time leads me to high-frequency trading (HFT), which presupposes an infinite supply of trading opportunities. When that supply is limited (e.g., due to trading fees, commission, etc.), then we end up raising our hold times. I do not yet have a formula for representing this trade-off.

I've found that with a 20% drop, the defaults of m = -0.02 and b = 5.2 are suitable for getting reasonable returns.

{When Do We Sell? 5}
      # m = -0.02, b = 5.2
      assessor.sell_when do |original, today|
        days_held = today.trading_days_from original
        
        sell_point = [@m * days_held + @b, 0].max
      
        today.change_from(original) >= sell_point
      end

6. Metrics of Success

The only real metric of succes is how much "money I've made", but even that is suspect: is that in reference to the liquid cash I have available at any given moment, or the total amount of my investments? Can I refine my coefficients (m and b) based on only 2018-2020, or as far back as I can? Should I be concerned that 2013 and 2010 had so few buy signals?

The "reinvested profits" figure comes from investing $15K into the market that year. When a buy signal comes along, you invest the value in circulation divided by 30 into the stock (initially $15K / 30 = $500). When a sell signal comes along, you sell. You then take the profits and put them back into circulation (e.g., if you made $600 profit by selling a stock, your circulation is now $15.6K, which means you would now be investing $520 per stock). The "cash" figure is the amount of cash you have leftover after all of the buy signals and all of their sell signals; if a sell signal for a given stock never occurs, then that investment is still in circulation, but you don't have access to it, which is reflected in your "cash" value.

Here is the explicit breakdown of the buy and sell signals of 2015, with the requisite drop being 20% instead (note that some sell signals will occur beyond 2015):

Breakdown [+]


Below, we have rough summaries for the performance of various sell lines (as termed by their m and b components) after a 20% drop and a rank of the top 61:

{Metrics for 2018-2020 6}
"In the cumulative timespan of 2018-2020"

[drop = 20%, m = -0.02, b = 5.2] => ROI: 0.9839851521267415,
                                    Profits: 21.912217326647706,
                                    Hold: 216 days,
                                    Pieces: 50

Yearly Breakdown

| Timeframe |    m  |  b  | Buys | Median Hold | Skips |  Mean ROI  | Reinvested Profits|
|-----------|-------|-----|------|-------------|-------|------------|------------|
|   2018    | -0.02 | 5.2 |   36 |        619  |   0   | -0.14905347|   $8.93 |
|   2019    | -0.02 | 5.2 |   56 |        258  |   4   | 0.322075814|  $14.18 |
|   2020    | -0.02 | 5.2 |  326 |        202  | 235   | 1.222808200|  $28.90 |

7. The Algorithm

While the assessor provides the framework for routinely and cleanly assessing the data, and the simulator pairs the algorithm to the assessor, the contents of that framework, the actual algorithm, are implemented as subclasses of the simulator.

{algos/volatile_drop.rb 7}
module Algorithms
  class VolatileDrop < Simulator
    attr_accessor :m
    attr_accessor :b
    attr_accessor :drop
    attr_accessor :rise # TODO get this out of here

    FOLDER = "volatile_drop"

    # use 23 pieces
    def initialize(stocks:  nil,
                   after:   nil,
                   before:  nil,
                   drop:   -0.2,
                   rank:    60,
                   m:      -0.02,
                   b:       5.2,
                   min:     0.4,
                   **extra)
      super(:stocks => stocks,
            :after  => after,
            :before => before)
      @drop = drop
      @m = m
      @b = b
  
      assessor.buy_when :history => 2 do |history|
        today     = history[-1]
        yesterday = history[-2]
      
        [[today.change_from(yesterday) <= drop,
          today.change_from(today)     <= drop].any?,
  
         today.rank <= rank,
         today.close >= min
        ].all?
      end
      
      # for ROI: m = -0.03, b = 3.0
      # for $$$: m = -0.02, b = 5.2
      #      or: m = -0.00, b = 0.6
      #      (those two average roughly the same from 2008-2020)
      assessor.sell_when do |original, today|
        days_held = today.trading_days_from original
        
        sell_point = [@m * days_held + @b, 0].max
      
        today.change_from(original) >= sell_point
      end
    end
  end
end

8. The Simulator

This will be refactored into the assessor, since it's mostly redirection.

{simulator.rb 8}
require './assessor.rb'

class Simulator
  attr_accessor :assessor
  attr_accessor :stocks
  attr_accessor :after
  attr_accessor :before

  def initialize(stocks:  nil,
                 after:   nil,
                 before:  nil)
    @stocks = stocks
    @after  = after
    @before = before
    @assessor = Assessor.new
  end

  # TODO rebuild all caches in the commented-out format
  def cache_name
    vars = instance_variables - [:@after, :@before, :@stocks, :@assessor]

    #"data/#{self.class::FOLDER}/" +
    #"#{[after.year.to_s, before.year.to_s].uniq.join "-"}_"+ 
    #"#{after.to_i}-#{before.to_i}_" +
    #vars.sort.map {|v| v.to_s[1] + instance_variable_get(v).to_s }.join("_") +
    #".sim"

    "data/#{self.class::FOLDER}/" +
    "#{[after.year.to_s, before.year.to_s].uniq.join "-"}_" +
    vars.map {|v| v.to_s[1] + instance_variable_get(v).to_s }.join("_") +
    ".sim"
  end

  def assess_buys
    @assessor.assess_buys @stocks, :after  => @after,
                                   :before => @before,
                                   :force  => @force
  end

  def assess_sells(partial: false)
    @assessor.assess_sells :partial => partial
  end

  def run
    assess_buys
    assess_sells
  end

  def results
    @assessor.results
  end

  def results=(val)
    @assessor.results = val
  end

  def holding
    @assessor.holding
  end

  def holding=(val)
    @assessor.holding = val
  end

  # Maybe `h[:hold]` should always be filled out?
  # Same with `h[:latest]`?
  def still_negative
    unsold = results.filter {|h| h[:sold] == nil }

    ticks = unsold.map {|h| h[:buy].ticker }
    #latests = Bar.where(:ticker => ticks)
    #             .order(Sequel.desc(:date))
    #             .group(:ticker_id)
    #             .all
    latests = ticks.map do |t|
      [t, Bar.where(:ticker => t)
             .order(Sequel.desc(:date))
             .first]
    end.to_h

    unsold.each do |h|
      h[:latest] = latests[h[:buy].ticker]
      h[:ROI]    = h[:latest].change_from h[:buy]
      h[:hold]   = h[:latest].trading_days_from h[:buy]
    end

    unsold.filter {|h| h[:ROI] < 0 }
  end

  def stats
    statz  = {:date        => after..before,
              :buys        => results.size,
              :unsold      => results.filter {|h| h[:sell].nil? }.size,
              :delisted    => results.filter {|h| h[:delisted] }.size,
              :median_hold => results.map {|h| h[:hold] || 1000 }.median,
              :sp500       => spy(after, before),
              :mean_ROI    => results.map {|h| h[:ROI] }.mean,
              :median_ROI  => results.map {|h| h[:ROI] }.median,
              :stddev_ROI  => results.map {|h| h[:ROI] }.standard_deviation
             }
    statz[:sharpe] = statz[:mean_ROI] / statz[:stddev_ROI]

    statz
  end
end

Dir['./algos/*.rb'].each {|f| require f }

Algorithm = eval("Algorithms::#{CONFIG[:algorithm]}")


9. The Assessor

{assessor.rb 9}
class Assessor
  attr_accessor :buying_plan
  attr_accessor :selling_plan
  attr_accessor :history_requirement

  attr_accessor :holding
  attr_accessor :results

  DELISTING_DEADBAND = 14.days

  def buy_when(history: 2, &b)
    @buying_plan = b
    @history_requirement = history
  end

  def sell_when(&b)
    @selling_plan = b
  end

  def buy?(ticker)
    buying_plan[ticker]
  end

  def sell?(ticker, original)
    selling_plan[ticker, original]
  end

  def assess_buys(tickers, opts={})
    tids = tickers.map {|t| t.id }

    debut  = Time.parse(opts[:after].to_s  || '1 march 1900')
    fin    = Time.parse(opts[:before].to_s || Date.today.to_s)

    bars   = Bar.where(:date => debut..fin, :ticker_id => tids)
                .order(:ticker_id, Sequel.asc(:date))
                .all
    groups = bars.group_by {|b| b.ticker_id }

    @holding = []

    # create groups of size `@history_requirement`, and then
    # pass that history to the checker
    # most recent bar is at -1, oldest bar is at 0
    @holding = groups.map do |ticker_id, bars|
      # assume the history is 
      histories = bars.each_cons history_requirement

      histories.filter do |history|

        # verify that the history is consecutive
        day_deltas = history.each_cons(2).map {|a, b| b.date - a.date }

        if day_deltas.any? {|v| v > 4.days }
          false
        else
          buy? history
        end
      end.map {|history| history.last }
    end.flatten

    # `@holding` currently references the days that a decision to buy is made
    # (using the day's closing price), but we don't *actually* buy until the
    # next morning. So we need to replace these stocks with the next day's
    # stock.
    # 
    # This is key because the `Bar#change_from` method operates on the opening
    # price of the earlier day.
    #
    # If `bars[index + 1]` is nil because we're dealing with some HOT OF THE
    # PRESS stock recommendations, then... I don't really have a plan for that
    # yet.  Then the stock doesn't exist, so just present the stock itself.
    # It'll stay until the time period is recalculated, which happens often.
    #
    # From here on out, we're dealing with *simulation*.
    @holding = @holding.map do |stock|
      bars  = Bar.where(:ticker => stock.ticker,
                        :date => stock.date..(stock.date + 7.days))
                 .order(Sequel.asc(:date))
                 .all
      index = bars.index stock
      bars[index + 1] || stock
    end
  end

  def assess_sells(partial: false)
    # assumes `@holding` and `@results` are accurately mapped
    if partial
      verified = @results.filter {|h| h[:sell] }
      unverified_stocks = @results.filter {|h| h[:sell].nil? }
                                  .map    {|h| h[:buy] }
    else
      unverified_stocks, verified = @holding, []
    end

    # Stocks can be delisted, at which point stocks held will be no longer
    # valid, but then a *new* ticker can start and can *reuse* the old name.
    # And since any stocks held from the previous incarnation won't be valid
    # for the new incarnation of the symbol, we need to separate those
    # instances. We do this by looking for a stretch of 7 days (using the date,
    # not the trading days, since trading days is calculated based on the
    # availability of bar information for that specific stock) during which the
    # stock is not traded (stocks can go intermittently inactive for short
    # periods of time, but that doesn't imply delistment).
    unverified = unverified_stocks.map do |stock|
      bars     = Bar.where(:ticker => stock.ticker) { date >= stock.date }
                    .order(Sequel.asc(:date))
                    .all
      periods = bars.slice_when do |before, after|
        after.date - before.date >= DELISTING_DEADBAND
      end

      # this is the only period that starts from the holding bar
      range = periods.first

      sell_bar = range.find {|day| sell? stock, day }
      delisted = if sell_bar
                   false
                 else 
                   Time.now - range.last.date >= DELISTING_DEADBAND
                 end

      {:buy  => stock,
       :sell => sell_bar,
       :hold => sell_bar ? sell_bar.trading_days_from(stock)  : nil,
       :ROI  => sell_bar ? sell_bar.change_from(stock) : -1,
       :delisted => delisted
      }
    end

    @results = (unverified + verified).sort_by {|h| h[:buy].date }
  end

  def assess(tickers, opts={})
    assess_buys tickers, opts
    assess_sells :partial => opts[:partial]
  end
end

10. Accessing Market Data

I use Alpaca.Markets to access market data. At the moment, I only use daily data. Alpaca's data is unadjusted for splits, and I have found at least one unresolved issue with their data (January 2018 data for AFL).

{market.rb 10}
require 'yaml'
Dir.chdir File.dirname(File.expand_path(__FILE__))
CONFIG = YAML.load File.read("config.yml")

require 'open-uri'
require 'alpaca/trade/api'
require 'alphavantagerb'
require './db.rb'
require './simulator.rb'
require 'kder'
require 'histogram/array'

{Configure the Market APIs, 11}

11. Configure the Market APIs

As a matter of boilerplate, I need to link up to the Alpaca API in order to trade.

{Configure the Market APIs 11}
Alpaca::Trade::Api.configure do |config|
  config.endpoint   = "https://api.alpaca.markets"
  config.key_id     = CONFIG[:Alpaca][:ID]
  config.key_secret = CONFIG[:Alpaca][:secret]
end

ALP_CLIENT = Alpaca::Trade::Api::Client.new
AV_CLIENT  = Alphavantage::Client.new :key => CONFIG[:AV][:key]

{Enhance the Alpaca Ruby API, 12}

Used in section 10

12. Enhance the Alpaca Ruby API

Minor issue with the Ruby API: it only allows you to specify the symbols and limit the number of results returned. The below change allows you to supply arbitrary options so that the usage of the CLIENT.bars can match the web API.

{Enhance the Alpaca Ruby API 12}
class Alpaca::Trade::Api::Client
  # This takes care of the issue where I was not able to provide other options
  # to the GET request. Now, I can specify "before" and "after" IAW the API.
  def bars(timeframe, symbols, opts={})
    opts[:limit] ||= 100
    opts[:symbols] = symbols.join(',')

    validate_timeframe(timeframe)
    response = get_request(data_endpoint, "v1/bars/#{timeframe}", opts)
    json = JSON.parse(response.body)
    json.keys.each_with_object({}) do |symbol, hash|
      hash[symbol] = json[symbol].map { |bar| Alpaca::Trade::Api::Bar.new(bar) }
    end
  end 

  # Enabling me to use the "qty" query parameter. Playing it extra safe
  # by not even sending the parameter unless there's a specified number
  def close_position(symbol: nil, qty: nil)
    response = delete_request(endpoint,
                              "v2/positions/#{symbol}#{qty ? "?qty=#{qty}" : ""}")
    raise NoPositionForSymbol,
          JSON.parse(response.body)['message'] if response.status == 404

    Position.new(JSON.parse(response.body))
  end
end

{Market Abstraction, 12}

Used in section 11

This is what I use to download stock data. No need to stay at the lower levels of abstraction when all I want to do is download and install the bars.

{Market Abstraction 12}
module Market
  module Stock
    extend self

    CLOSE = "16:00" # closing time of the markets
    DELAY = 15 * 60 # how long to wait (in sec) before grabbing data

    # Alpaca download but don't install
    def download(tickers, opts={})
      span = opts.delete(:span) || 'day'
      opts[:limit] ||= 1000

      opts.each do |k, v| 
        if [String, Date, DateTime, Time].include? v.class
          opts[k] = DateTime.parse(v.to_s).to_s
        end
      end

      # `CLIENT.bars` returns a hash, so this will also merge them all
      # into one. key collision will only happen if the key is duplicated
      # in the `ticker` argument.
      symbols = tickers.map {|t| t.symbol }
      data    = symbols.each_slice(50).map do |ticks|
        ALP_CLIENT.bars span, ticks, opts
      end.inject({}) {|h, v| h.merge v }

      # strip out any bar that could be from today's incomplete data
      data.each do |sym, bars|
        bars.delete_if do |bar|
          bar.date == Time.parse(Date.today.to_s) &&
          Time.now < (Time.parse(CLOSE) + DELAY)
        end
      end

      # provide a hash so that we can get the ID without
      # fetching the symbol from the DB
      tids = tickers.map {|t| [t.symbol, t] }.to_h

      # Put the data in a hash format so that it's consistent with the
      # AlphaVantage style and allows for easy use with DB#multi_insert
      data.map do |sym, bars|
        [sym, bars.map do |bar|
          {:date   => bar.time,
           :open   => bar.open,
           :high   => bar.high,
           :low    => bar.low,
           :close  => bar.close,
           :volume => bar.volume,
           :span   => 'day',
           :ticker_id => tids[sym].id
          }
        end]
      end.to_h
    end

    def install(tickers, opts={})
      return {} if opts[:after] == Time.parse(Date.today.to_s)
      return {} if opts[:after] == Time.parse(Date.today.to_s) - 1.day &&
                   Time.now < (Time.parse(CLOSE) + DELAY)

      updates = download tickers, opts
      DB[:bars].multi_insert updates.values.flatten

      updates
    end

    # can only do one stock at a time
    # AlphaVantage
    def download_stock(ticker, after: '1900-01-01', before: Date.today.strftime("%Y-%m-%d"))
      stock  = AV_CLIENT.stock :symbol => ticker.symbol
      series = stock.timeseries :outputsize => 'full'

      bars = series.output['Time Series (Daily)']
      bars = bars.filter {|k, bar| k > after && k < before }

      bars.map do |k, bar|
        {:date   => Time.parse(k),
         :open   => bar['1. open'].to_f,
         :high   => bar['2. high'].to_f,
         :low    => bar['3. low'].to_f,
         :close  => bar['4. close'].to_f,
         :volume => bar['5. volume'].to_i,
         :span   => 'day',
         :ticker_id => ticker.id
        }
      end
    end

    def install_stock(stock, **kwargs)
      data = download_stock stock, **kwargs
      DB[:bars].multi_insert data
    end
  end

  module Futures
    def download(future: nil, after: '1900-01-01', before: Date.today.strftime("%Y-%m-%d"))
      url = "https://query1.finance.yahoo.com/v7/finance/download/" +
            "#{future.ymbol}?" +
            "period1=#{after.to_i}&" +
            "period2=#{before.to_i}&" +
            "interval=1d&events=history&includeAdjustedClose=true"
      user_agent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 11_2_3) " +
                   "AppleWebKit/605.1.15 (KHTML, like Gecko) Version/14.0.3 " +
                   "Safari/605.1.15"
      data = URI.open(url, "User-Agent" => user_agent) do |site|
        site.read
      end

      data.split("\n").map {|line| line.split "," }.map do |line|
        {:date  => Time.parse(line[0]),
         :open  => line[1].to_f,
         :high  => line[2].to_f,
         :low   => line[3].to_f,
         :close => line[5].to_f,
         :volume => line[6].to_f}
      end
    end
  end
end