Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.

Commit aa3342b

Browse files
authored
Stock imports (#1363)
* Initial pass * Marketstack data provider * Marketstack data provider * Refactor a bit
1 parent b611dfd commit aa3342b

File tree

10 files changed

+187
-1
lines changed

10 files changed

+187
-1
lines changed

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ PORT=3000
1515
# This is used to convert between different currencies in the app. In addition, it fetches US stock prices. We use Synth, which is a Maybe product. You can sign up for a free account at synthfinance.com.
1616
SYNTH_API_KEY=
1717

18+
# Non-US Stock Pricing API
19+
# This is used to fetch non-US stock prices. We use Marketstack.com for this and while they offer a free tier, it is quite limited. You'll almost certainly need their Basic plan, which is $9.99 per month.
20+
MARKETSTACK_API_KEY=
21+
1822
# SMTP Configuration
1923
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
2024
# Resend.com is a good option that offers a free tier for sending emails.

.env.local.example

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,6 @@ SELF_HOSTED=false
33

44
# Enable Synth market data (careful, this will use your API credits)
55
SYNTH_API_KEY=yourapikeyhere
6+
7+
# Enable Marketstack market data (careful, this will use your API credits)
8+
MARKETSTACK_API_KEY=yourapikeyhere
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
class SecuritiesController < ApplicationController
2+
def import
3+
SecuritiesImportJob.perform_later(params[:exchange_mic])
4+
end
5+
end

app/helpers/securities_helper.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
module SecuritiesHelper
2+
end

app/jobs/securities_import_job.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
class SecuritiesImportJob < ApplicationJob
2+
queue_as :default
3+
4+
def perform(country_code = nil)
5+
exchanges = StockExchange.in_country(country_code)
6+
market_stack_client = Provider::Marketstack.new(ENV["MARKETSTACK_API_KEY"])
7+
8+
exchanges.each do |exchange|
9+
importer = Security::Importer.new(market_stack_client, exchange.mic)
10+
importer.import
11+
end
12+
end
13+
end

app/models/provider/marketstack.rb

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
class Provider::Marketstack
2+
include Retryable
3+
4+
def initialize(api_key)
5+
@api_key = api_key
6+
end
7+
8+
def fetch_security_prices(ticker:, start_date:, end_date:)
9+
prices = paginate("#{base_url}/eod", {
10+
symbols: ticker,
11+
date_from: start_date.to_s,
12+
date_to: end_date.to_s
13+
}) do |body|
14+
body.dig("data").map do |price|
15+
{
16+
date: price["date"],
17+
price: price["close"]&.to_f,
18+
currency: "USD"
19+
}
20+
end
21+
end
22+
23+
SecurityPriceResponse.new(
24+
prices: prices,
25+
success?: true,
26+
raw_response: prices.to_json
27+
)
28+
rescue StandardError => error
29+
SecurityPriceResponse.new(
30+
success?: false,
31+
error: error,
32+
raw_response: error
33+
)
34+
end
35+
36+
def fetch_tickers(exchange_mic: nil)
37+
url = exchange_mic ? "#{base_url}/tickers?exchange=#{exchange_mic}" : "#{base_url}/tickers"
38+
tickers = paginate(url) do |body|
39+
body.dig("data").map do |ticker|
40+
{
41+
name: ticker["name"],
42+
symbol: ticker["symbol"],
43+
exchange: exchange_mic || ticker.dig("stock_exchange", "mic"),
44+
country_code: ticker.dig("stock_exchange", "country_code")
45+
}
46+
end
47+
end
48+
49+
TickerResponse.new(
50+
tickers: tickers,
51+
success?: true,
52+
raw_response: tickers.to_json
53+
)
54+
rescue StandardError => error
55+
TickerResponse.new(
56+
success?: false,
57+
error: error,
58+
raw_response: error
59+
)
60+
end
61+
62+
private
63+
64+
attr_reader :api_key
65+
66+
SecurityPriceResponse = Struct.new(:prices, :success?, :error, :raw_response, keyword_init: true)
67+
TickerResponse = Struct.new(:tickers, :success?, :error, :raw_response, keyword_init: true)
68+
69+
def base_url
70+
"https://api.marketstack.com/v1"
71+
end
72+
73+
def client
74+
@client ||= Faraday.new(url: base_url) do |faraday|
75+
faraday.params["access_key"] = api_key
76+
end
77+
end
78+
79+
def build_error(response)
80+
Provider::Base::ProviderError.new(<<~ERROR)
81+
Failed to fetch data from #{self.class}
82+
Status: #{response.status}
83+
Body: #{response.body.inspect}
84+
ERROR
85+
end
86+
87+
def fetch_page(url, page, params = {})
88+
client.get(url) do |req|
89+
params.each { |k, v| req.params[k.to_s] = v.to_s }
90+
req.params["offset"] = (page - 1) * 100 # Marketstack uses offset-based pagination
91+
req.params["limit"] = 10000 # Maximum allowed by Marketstack
92+
end
93+
end
94+
95+
def paginate(url, params = {})
96+
results = []
97+
page = 1
98+
total_results = Float::INFINITY
99+
100+
while results.length < total_results
101+
response = fetch_page(url, page, params)
102+
103+
if response.success?
104+
body = JSON.parse(response.body)
105+
page_results = yield(body)
106+
results.concat(page_results)
107+
108+
total_results = body.dig("pagination", "total")
109+
page += 1
110+
else
111+
raise build_error(response)
112+
end
113+
114+
break if results.length >= total_results
115+
end
116+
117+
results
118+
end
119+
end

app/models/security/importer.rb

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
class Security::Importer
2+
def initialize(provider, stock_exchange = nil)
3+
@provider = provider
4+
@stock_exchange = stock_exchange
5+
end
6+
7+
def import
8+
securities = @provider.fetch_tickers(exchange_mic: @stock_exchange)&.tickers
9+
10+
stock_exchanges = StockExchange.where(mic: securities.map { |s| s[:exchange] }).index_by(&:mic)
11+
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
12+
13+
securities_to_create = securities.map do |security|
14+
stock_exchange_id = stock_exchanges[security[:exchange]]&.id
15+
next if existing_securities.include?([ security[:symbol], stock_exchange_id ])
16+
17+
{
18+
name: security[:name],
19+
ticker: security[:symbol],
20+
stock_exchange_id: stock_exchange_id,
21+
country_code: security[:country_code]
22+
}
23+
end.compact
24+
25+
Security.insert_all(securities_to_create) unless securities_to_create.empty?
26+
end
27+
end

app/models/stock_exchange.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
class StockExchange < ApplicationRecord
2+
scope :in_country, ->(country_code) { where(country_code: country_code) }
23
end
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
class AddStockExchangeReference < ActiveRecord::Migration[7.2]
2+
def change
3+
add_column :securities, :country_code, :string
4+
add_reference :securities, :stock_exchange, type: :uuid, foreign_key: true
5+
add_index :securities, :country_code
6+
end
7+
end

db/schema.rb

Lines changed: 6 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)