Skip to content
This repository was archived by the owner on Jul 27, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ PORT=3000
# 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.
SYNTH_API_KEY=

# Non-US Stock Pricing API
# 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.
MARKETSTACK_API_KEY=

# SMTP Configuration
# This is only needed if you intend on sending emails from your Maybe instance (such as for password resets or email financial reports).
# Resend.com is a good option that offers a free tier for sending emails.
Expand Down
3 changes: 3 additions & 0 deletions .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,6 @@ SELF_HOSTED=false

# Enable Synth market data (careful, this will use your API credits)
SYNTH_API_KEY=yourapikeyhere

# Enable Marketstack market data (careful, this will use your API credits)
MARKETSTACK_API_KEY=yourapikeyhere
5 changes: 5 additions & 0 deletions app/controllers/securities_controller.rb
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
2 changes: 2 additions & 0 deletions app/helpers/securities_helper.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
module SecuritiesHelper
end
10 changes: 10 additions & 0 deletions app/jobs/securities_import_job.rb
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
143 changes: 143 additions & 0 deletions app/models/provider/marketstack.rb
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
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
31 changes: 31 additions & 0 deletions app/models/security/importer.rb
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
7 changes: 7 additions & 0 deletions db/migrate/20241023195438_add_stock_exchange_reference.rb
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
7 changes: 6 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.