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.
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.
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 |
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.
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):
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
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:
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.
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:
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:
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:
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.
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:
# {"SYM" => [Rank, Value]} rankings = Ticker.rankings NYSE, :date => date rankings[ticker.symbol] #=> [Rank, Value]
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.
I want to maximize the ROI from each stock that I invest in. This is balanced with my risk-tolerance.
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.
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:
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.
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 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.
# 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
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):
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:
"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 |
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.
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
This will be refactored into the assessor, since it's mostly redirection.
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]}")
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
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).
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}
As a matter of boilerplate, I need to link up to the Alpaca API in order to trade.
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
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.
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.
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