This repository was archived by the owner on Jul 27, 2025. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 4.5k
Stock imports #1363
Merged
Merged
Stock imports #1363
Changes from 4 commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
fda1bf3
Initial pass
Shpigford 918a048
Merge branch 'main' into stock-imports
Shpigford 4a2f0b9
Marketstack data provider
Shpigford 73b0c60
Marketstack data provider
Shpigford 2dfbb24
Refactor a bit
Shpigford 0ff0adc
Merge branch 'main' into stock-imports
Shpigford 1d63d25
Merge branch 'main' into stock-imports
Shpigford File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
class SecuritiesController < ApplicationController | ||
def import | ||
SecuritiesImportJob.perform_later(params[:exchange_mic]) | ||
end | ||
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
module SecuritiesHelper | ||
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
class SecuritiesImportJob < ApplicationJob | ||
queue_as :default | ||
|
||
def perform(exchange_mic = nil) | ||
market_stack_client = Provider::Marketstack.new(ENV["MARKETSTACK_API_KEY"]) | ||
importer = Security::Importer.new(market_stack_client, exchange_mic) | ||
|
||
importer.import | ||
end | ||
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,143 @@ | ||
class Provider::Marketstack | ||
include Retryable | ||
|
||
def initialize(api_key) | ||
@api_key = api_key | ||
end | ||
|
||
def fetch_security_prices(ticker:, start_date:, end_date:) | ||
prices = paginate("#{base_url}/eod", { | ||
symbols: ticker, | ||
date_from: start_date.to_s, | ||
date_to: end_date.to_s | ||
}) do |body| | ||
body.dig("data").map do |price| | ||
{ | ||
date: price["date"], | ||
price: price["close"]&.to_f, | ||
currency: "USD" | ||
} | ||
end | ||
end | ||
|
||
SecurityPriceResponse.new( | ||
prices: prices, | ||
success?: true, | ||
raw_response: prices.to_json | ||
) | ||
rescue StandardError => error | ||
SecurityPriceResponse.new( | ||
success?: false, | ||
error: error, | ||
raw_response: error | ||
) | ||
end | ||
|
||
def fetch_all_tickers | ||
Shpigford marked this conversation as resolved.
Show resolved
Hide resolved
|
||
tickers = paginate("#{base_url}/tickers") do |body| | ||
body.dig("data").map do |ticker| | ||
{ | ||
name: ticker["name"], | ||
symbol: ticker["symbol"], | ||
exchange: ticker.dig("stock_exchange", "mic"), | ||
country_code: ticker.dig("stock_exchange", "country_code") | ||
} | ||
end | ||
end | ||
|
||
TickerResponse.new( | ||
tickers: tickers, | ||
success?: true, | ||
raw_response: tickers.to_json | ||
) | ||
rescue StandardError => error | ||
TickerResponse.new( | ||
success?: false, | ||
error: error, | ||
raw_response: error | ||
) | ||
end | ||
|
||
def fetch_exchange_tickers(exchange_mic:) | ||
tickers = paginate("#{base_url}/tickers?exchange=#{exchange_mic}") do |body| | ||
body.dig("data").map do |ticker| | ||
{ | ||
name: ticker["name"], | ||
symbol: ticker["symbol"], | ||
exchange: exchange_mic, | ||
country_code: ticker.dig("stock_exchange", "country_code") | ||
} | ||
end | ||
end | ||
|
||
TickerResponse.new( | ||
tickers: tickers, | ||
success?: true, | ||
raw_response: tickers.to_json | ||
) | ||
rescue StandardError => error | ||
TickerResponse.new( | ||
success?: false, | ||
error: error, | ||
raw_response: error | ||
) | ||
end | ||
|
||
private | ||
|
||
attr_reader :api_key | ||
|
||
SecurityPriceResponse = Struct.new(:prices, :success?, :error, :raw_response, keyword_init: true) | ||
TickerResponse = Struct.new(:tickers, :success?, :error, :raw_response, keyword_init: true) | ||
|
||
def base_url | ||
"https://api.marketstack.com/v1" | ||
end | ||
|
||
def client | ||
@client ||= Faraday.new(url: base_url) do |faraday| | ||
faraday.params["access_key"] = api_key | ||
end | ||
end | ||
|
||
def build_error(response) | ||
Provider::Base::ProviderError.new(<<~ERROR) | ||
Failed to fetch data from #{self.class} | ||
Status: #{response.status} | ||
Body: #{response.body.inspect} | ||
ERROR | ||
end | ||
|
||
def fetch_page(url, page, params = {}) | ||
client.get(url) do |req| | ||
params.each { |k, v| req.params[k.to_s] = v.to_s } | ||
req.params["offset"] = (page - 1) * 100 # Marketstack uses offset-based pagination | ||
req.params["limit"] = 10000 # Maximum allowed by Marketstack | ||
end | ||
end | ||
|
||
def paginate(url, params = {}) | ||
results = [] | ||
page = 1 | ||
total_results = Float::INFINITY | ||
|
||
while results.length < total_results | ||
response = fetch_page(url, page, params) | ||
|
||
if response.success? | ||
body = JSON.parse(response.body) | ||
page_results = yield(body) | ||
results.concat(page_results) | ||
|
||
total_results = body.dig("pagination", "total") | ||
page += 1 | ||
else | ||
raise build_error(response) | ||
end | ||
|
||
break if results.length >= total_results | ||
end | ||
|
||
results | ||
end | ||
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
class Security::Importer | ||
def initialize(provider, stock_exchange = nil) | ||
@provider = provider | ||
@stock_exchange = stock_exchange | ||
end | ||
|
||
def import | ||
if @stock_exchange | ||
securities = @provider.fetch_exchange_tickers(exchange_mic: @stock_exchange)&.tickers | ||
else | ||
securities = @provider.fetch_all_tickers&.tickers | ||
end | ||
|
||
stock_exchanges = StockExchange.where(mic: securities.map { |s| s[:exchange] }).index_by(&:mic) | ||
existing_securities = Security.where(ticker: securities.map { |s| s[:symbol] }, stock_exchange_id: stock_exchanges.values.map(&:id)).pluck(:ticker, :stock_exchange_id).to_set | ||
|
||
securities_to_create = securities.map do |security| | ||
stock_exchange_id = stock_exchanges[security[:exchange]]&.id | ||
next if existing_securities.include?([ security[:symbol], stock_exchange_id ]) | ||
|
||
{ | ||
name: security[:name], | ||
ticker: security[:symbol], | ||
stock_exchange_id: stock_exchange_id, | ||
country_code: security[:country_code] | ||
} | ||
end.compact | ||
|
||
Security.insert_all(securities_to_create) unless securities_to_create.empty? | ||
end | ||
end |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,7 @@ | ||
class AddStockExchangeReference < ActiveRecord::Migration[7.2] | ||
def change | ||
add_column :securities, :country_code, :string | ||
add_reference :securities, :stock_exchange, type: :uuid, foreign_key: true | ||
add_index :securities, :country_code | ||
end | ||
end |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.