1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-02 20:15:22 +02:00

Add security prices provider (Synth integration) (#1039)

* User tickers as primary lookup symbol instead of isin

* Add security price provider

* Fetch security prices in bulk to improve sync performance

* Fetch prices in bulk, better mocking for tests
This commit is contained in:
Zach Gollwitzer 2024-08-01 19:43:23 -04:00 committed by GitHub
parent c70c8b6d86
commit 453a54e5e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 584 additions and 118 deletions

View file

@ -204,7 +204,7 @@ class Account::Entry < ApplicationRecord
current_qty = account.holding_qty(account_trade.security)
if current_qty < account_trade.qty.abs
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.symbol} because you only own #{current_qty} shares")
errors.add(:base, "cannot sell #{account_trade.qty.abs} shares of #{account_trade.security.ticker} because you only own #{current_qty} shares")
end
end
end

View file

@ -13,7 +13,7 @@ class Account::Holding < ApplicationRecord
scope :for, ->(security) { where(security_id: security).order(:date) }
delegate :name, to: :security
delegate :symbol, to: :security
delegate :ticker, to: :security
def weight
return nil unless amount

View file

@ -32,16 +32,42 @@ class Account::Holding::Syncer
.order(:date)
end
def get_cached_price(ticker, date)
return nil unless security_prices.key?(ticker)
price = security_prices[ticker].find { |p| p.date == date }
price ? price[:price] : nil
end
def security_prices
@security_prices ||= begin
prices = {}
ticker_start_dates = {}
sync_entries.each do |entry|
unless ticker_start_dates[entry.account_trade.security.ticker]
ticker_start_dates[entry.account_trade.security.ticker] = entry.date
end
end
ticker_start_dates.each do |ticker, date|
prices[ticker] = Security::Price.find_prices(ticker: ticker, start_date: date, end_date: Date.current)
end
prices
end
end
def build_holdings_for_date(date)
trades = sync_entries.select { |trade| trade.date == date }
@portfolio = generate_next_portfolio(@portfolio, trades)
@portfolio.map do |isin, holding|
@portfolio.map do |ticker, holding|
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
trade_price = trade&.account_trade&.price
price = Security::Price.find_by(date: date, isin: isin)&.price || trade_price
price = get_cached_price(ticker, date) || trade_price
account.holdings.build \
date: date,
@ -58,10 +84,10 @@ class Account::Holding::Syncer
trade = entry.account_trade
price = trade.price
prior_qty = prior_portfolio.dig(trade.security.isin, :qty) || 0
prior_qty = prior_portfolio.dig(trade.security.ticker, :qty) || 0
new_qty = prior_qty + trade.qty
new_portfolio[trade.security.isin] = {
new_portfolio[trade.security.ticker] = {
qty: new_qty,
price: price,
amount: new_qty * price,
@ -86,7 +112,7 @@ class Account::Holding::Syncer
prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day)
prior_day_holdings.each do |holding|
@portfolio[holding.security.isin] = {
@portfolio[holding.security.ticker] = {
qty: holding.qty,
price: holding.price,
amount: holding.amount,

View file

@ -6,18 +6,25 @@ module Providable
extend ActiveSupport::Concern
class_methods do
def exchange_rates_provider
api_key = ENV["SYNTH_API_KEY"]
def security_prices_provider
synth_provider
end
if api_key.present?
Provider::Synth.new api_key
else
nil
end
def exchange_rates_provider
synth_provider
end
def git_repository_provider
Provider::Github.new
end
private
def synth_provider
@synth_provider ||= begin
api_key = ENV["SYNTH_API_KEY"]
api_key.present? ? Provider::Synth.new(api_key) : nil
end
end
end
end

View file

@ -167,12 +167,12 @@ class Demo::Generator
def load_securities!
# Create an unknown security to simulate edge cases
Security.create! isin: "unknown", symbol: "UNKNOWN", name: "Unknown Demo Stock"
Security.create! ticker: "UNKNOWN", name: "Unknown Demo Stock"
securities = [
{ isin: "US0378331005", symbol: "AAPL", name: "Apple Inc.", reference_price: 210 },
{ isin: "JP3633400001", symbol: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
{ isin: "US5949181045", symbol: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
{ ticker: "AAPL", name: "Apple Inc.", reference_price: 210 },
{ ticker: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
{ ticker: "MSFT", name: "Microsoft Corporation", reference_price: 455 }
]
securities.each do |security_attributes|
@ -184,7 +184,7 @@ class Demo::Generator
low_price = reference - 20
high_price = reference + 20
Security::Price.create! \
isin: security.isin,
ticker: security.ticker,
date: date,
price: Faker::Number.positive(from: low_price, to: high_price)
end
@ -201,10 +201,10 @@ class Demo::Generator
currency: "USD",
institution: family.institutions.find_or_create_by(name: "Robinhood")
aapl = Security.find_by(symbol: "AAPL")
tm = Security.find_by(symbol: "TM")
msft = Security.find_by(symbol: "MSFT")
unknown = Security.find_by(symbol: "UNKNOWN")
aapl = Security.find_by(ticker: "AAPL")
tm = Security.find_by(ticker: "TM")
msft = Security.find_by(ticker: "MSFT")
unknown = Security.find_by(ticker: "UNKNOWN")
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown)
@ -220,14 +220,14 @@ class Demo::Generator
date = Faker::Number.positive(to: 730).days.ago.to_date
security = trade[:security]
qty = trade[:qty]
price = Security::Price.find_by(isin: security.isin, date: date)&.price || 1
price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1
name_prefix = qty < 0 ? "Sell " : "Buy "
account.entries.create! \
date: date,
amount: qty * price,
currency: "USD",
name: name_prefix + "#{qty} shares of #{security.symbol}",
name: name_prefix + "#{qty} shares of #{security.ticker}",
entryable: Account::Trade.new(qty: qty, price: price, security: security)
end
end

View file

@ -5,6 +5,27 @@ class Provider::Synth
@api_key = api_key
end
def fetch_security_prices(ticker:, start_date:, end_date:)
prices = paginate(
"#{base_url}/tickers/#{ticker}/open-close",
start_date: start_date,
end_date: end_date
) do |body|
body.dig("prices").map do |price|
{
date: price.dig("date"),
price: price.dig("close")&.to_f || price.dig("open")&.to_f,
currency: "USD"
}
end
end
SecurityPriceResponse.new \
prices: prices,
success?: true,
raw_response: prices.to_json
end
def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt|
response = Faraday.get("#{base_url}/rates/historical") do |req|
@ -33,9 +54,11 @@ class Provider::Synth
end
private
attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true
SecurityPriceResponse = Struct.new :prices, :success?, :error, :raw_response, keyword_init: true
def base_url
"https://api.synthfinance.com"
@ -43,9 +66,43 @@ class Provider::Synth
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch exchange rate from #{self.class}
Failed to fetch data from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
def fetch_page(url, page, params = {})
Faraday.get(url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["page"] = page
end
end
def paginate(url, params = {})
results = []
page = 1
current_page = 0
total_pages = 1
while current_page < total_pages
response = fetch_page(url, page, params)
if response.success?
body = JSON.parse(response.body)
page_results = yield(body)
results.concat(page_results)
current_page = body.dig("paging", "current_page")
total_pages = body.dig("paging", "total_pages")
page += 1
else
raise build_error(response)
end
end
results
end
end

View file

@ -1,14 +1,13 @@
class Security < ApplicationRecord
before_save :normalize_identifiers
before_save :upcase_ticker
has_many :trades, dependent: :nullify, class_name: "Account::Trade"
validates :isin, presence: true, uniqueness: { case_sensitive: false }
validates :ticker, presence: true, uniqueness: { case_sensitive: false }
private
def normalize_identifiers
self.isin = isin.upcase
self.symbol = symbol.upcase
def upcase_ticker
self.ticker = ticker.upcase
end
end

View file

@ -1,2 +1,34 @@
class Security::Price < ApplicationRecord
include Provided
before_save :upcase_ticker
validates :ticker, presence: true, uniqueness: { scope: :date, case_sensitive: false }
class << self
def find_price(ticker:, date:, cache: true)
result = find_by(ticker:, date:)
result || fetch_price_from_provider(ticker:, date:, cache:)
end
def find_prices(ticker:, start_date:, end_date: Date.current, cache: true)
prices = where(ticker:, date: start_date..end_date).to_a
all_dates = (start_date..end_date).to_a.to_set
existing_dates = prices.map(&:date).to_set
missing_dates = (all_dates - existing_dates).sort
if missing_dates.any?
prices += fetch_prices_from_provider(ticker:, start_date: missing_dates.first, end_date: missing_dates.last, cache:)
end
prices
end
end
private
def upcase_ticker
self.ticker = ticker.upcase
end
end

View file

@ -0,0 +1,55 @@
module Security::Price::Provided
extend ActiveSupport::Concern
include Providable
class_methods do
private
def fetch_price_from_provider(ticker:, date:, cache: false)
return nil unless security_prices_provider.present?
response = security_prices_provider.fetch_security_prices \
ticker: ticker,
start_date: date,
end_date: date
if response.success? && response.prices.size > 0
price = Security::Price.new \
ticker: ticker,
date: response.prices.first[:date],
price: response.prices.first[:price],
currency: response.prices.first[:currency]
price.save! if cache
price
else
nil
end
end
def fetch_prices_from_provider(ticker:, start_date:, end_date:, cache: false)
return [] unless security_prices_provider.present?
response = security_prices_provider.fetch_security_prices \
ticker: ticker,
start_date: start_date,
end_date: end_date
if response.success?
response.prices.map do |price|
new_price = Security::Price.new \
ticker: ticker,
date: price[:date],
price: price[:price],
currency: price[:currency]
new_price.save! if cache
new_price
end
else
[]
end
end
end
end