1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 05:09:38 +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

17
.env.test.example Normal file
View file

@ -0,0 +1,17 @@
# ================
# Data Providers
# ---------------------------------------------------------------------------------
# Uncomment and fill in live keys when you need to generate a VCR cassette fixture
# ================
# SYNTH_API_KEY=<add live key here>
# ================
# Miscellaneous
# ================
# Set to true if you want SimpleCov reports generated
COVERAGE=false
# Set to true to run test suite serially
DISABLE_PARALLELIZATION=false

1
.gitignore vendored
View file

@ -11,6 +11,7 @@
/.env*
!/.env*.erb
!.env.example
!.env.test.example
# Ignore all logfiles and tempfiles.
/log/*

View file

@ -52,10 +52,10 @@ group :development, :test do
gem "rubocop-rails-omakase", require: false
gem "i18n-tasks"
gem "erb_lint"
gem "dotenv-rails"
end
group :development do
gem "dotenv-rails"
gem "hotwire-livereload"
gem "letter_opener"
gem "ruby-lsp-rails"

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

View file

@ -6,7 +6,7 @@
<%= render "shared/circle_logo", name: holding.name %>
<div>
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= tag.p holding.symbol, class: "text-gray-500 text-xs uppercase" %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>

View file

@ -3,7 +3,7 @@
<header class="flex justify-between">
<div>
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %>
<%= tag.p @holding.symbol.upcase, class: "text-sm text-gray-500" %>
<%= tag.p @holding.ticker, class: "text-sm text-gray-500" %>
</div>
<%= render "shared/circle_logo", name: @holding.name %>

View file

@ -0,0 +1,7 @@
class ChangePrimaryIdentifierForSecurity < ActiveRecord::Migration[7.2]
def change
rename_column :securities, :symbol, :ticker
remove_column :securities, :isin, :string
rename_column :security_prices, :isin, :ticker
end
end

9
db/schema.rb generated
View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do
ActiveRecord::Schema[7.2].define(version: 2024_07_31_191344) do
# These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto"
enable_extension "plpgsql"
@ -118,7 +118,7 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do
t.boolean "is_active", default: true, null: false
t.date "last_sync_date"
t.uuid "institution_id"
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY ((ARRAY['Loan'::character varying, 'CreditCard'::character varying, 'OtherLiability'::character varying])::text[])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.virtual "classification", type: :string, as: "\nCASE\n WHEN ((accountable_type)::text = ANY (ARRAY[('Loan'::character varying)::text, ('CreditCard'::character varying)::text, ('OtherLiability'::character varying)::text])) THEN 'liability'::text\n ELSE 'asset'::text\nEND", stored: true
t.index ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id"], name: "index_accounts_on_family_id"
t.index ["institution_id"], name: "index_accounts_on_institution_id"
@ -347,15 +347,14 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_25_163339) do
end
create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "isin", null: false
t.string "symbol"
t.string "ticker"
t.string "name"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "isin"
t.string "ticker"
t.date "date"
t.decimal "price", precision: 19, scale: 4
t.string "currency", default: "USD"

View file

@ -1,9 +1,7 @@
aapl:
isin: US0378331005
symbol: aapl
ticker: AAPL
name: Apple
msft:
isin: US5949181045
symbol: msft
ticker: MSFT
name: Microsoft

View file

@ -1,11 +1,11 @@
one:
isin: US0378331005 # AAPL
ticker: AAPL
date: <%= Date.current %>
price: 215
currency: USD
two:
isin: US0378331005 # AAPL
ticker: AAPL
date: <%= 1.day.ago.to_date %>
price: 214
currency: USD

View file

@ -8,8 +8,8 @@ module ExchangeRateProviderInterfaceTest
end
test "exchange rate provider response contract" do
accounting_for_http_calls do
response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
VCR.use_cassette "synth/exchange_rate" do
response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01")
assert_respond_to response, :rate
assert_respond_to response, :success?
@ -17,11 +17,4 @@ module ExchangeRateProviderInterfaceTest
assert_respond_to response, :raw_response
end
end
private
def accounting_for_http_calls
VCR.use_cassette "synth_exchange_rate" do
yield
end
end
end

View file

@ -0,0 +1,20 @@
require "test_helper"
module SecurityPriceProviderInterfaceTest
extend ActiveSupport::Testing::Declarative
test "security price provider interface" do
assert_respond_to @subject, :fetch_security_prices
end
test "security price provider response contract" do
VCR.use_cassette "synth/security_prices" do
response = @subject.fetch_security_prices ticker: "AAPL", start_date: Date.iso8601("2024-01-01"), end_date: Date.iso8601("2024-08-01")
assert_respond_to response, :prices
assert_respond_to response, :success?
assert_respond_to response, :error
assert_respond_to response, :raw_response
end
end
end

View file

@ -93,8 +93,10 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
syncer = Account::Balance::Syncer.new(@account)
assert_raises Money::ConversionError do
syncer.run
with_env_overrides SYNTH_API_KEY: nil do
assert_raises Money::ConversionError do
syncer.run
end
end
end
@ -104,7 +106,10 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
@account.update! currency: "EUR"
syncer = Account::Balance::Syncer.new(@account)
syncer.run
with_env_overrides SYNTH_API_KEY: nil do
syncer.run
end
assert_equal 1, syncer.warnings.count
end

View file

@ -113,6 +113,6 @@ class Account::EntryTest < ActiveSupport::TestCase
entryable: Account::Trade.new(qty: -10, price: 200, security: security)
end
assert_match /cannot sell 10.0 shares of aapl because you only own 0.0 shares/, error.message
assert_match /cannot sell 10.0 shares of AAPL because you only own 0.0 shares/, error.message
end
end

View file

@ -33,11 +33,29 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN
expected = [
{ symbol: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
{ symbol: "AMZN", qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date },
{ symbol: "AMZN", qty: 2, price: 216, amount: 2 * 216, date: Date.current },
{ symbol: "NVDA", qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date },
{ symbol: "NVDA", qty: 20, price: 124, amount: 20 * 124, date: Date.current }
{ ticker: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
{ ticker: "AMZN", qty: 12, price: 215, amount: 12 * 215, date: 1.day.ago.to_date },
{ ticker: "AMZN", qty: 2, price: 216, amount: 2 * 216, date: Date.current },
{ ticker: "NVDA", qty: 20, price: 122, amount: 20 * 122, date: 1.day.ago.to_date },
{ ticker: "NVDA", qty: 20, price: 124, amount: 20 * 124, date: Date.current }
]
run_sync_for(@account)
assert_holdings(expected)
end
test "generates holdings with prices" do
provider = mock
Security::Price.stubs(:security_prices_provider).returns(provider)
provider.expects(:fetch_security_prices).never
amzn = create_security("AMZN", prices: [ { date: Date.current, price: 215 } ])
create_trade(amzn, account: @account, qty: 10, date: Date.current, price: 215)
expected = [
{ ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: Date.current }
]
run_sync_for(@account)
@ -46,21 +64,26 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
end
test "generates all holdings even when missing security prices" do
aapl = create_security("AMZN", prices: [
{ date: 1.day.ago.to_date, price: 215 }
])
amzn = create_security("AMZN", prices: [])
create_trade(aapl, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210)
create_trade(amzn, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210)
# 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price
# 1 day ago — finds daily price, uses it
# Today — no daily price, no entry, so price and amount are `nil`
expected = [
{ symbol: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
{ symbol: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
{ symbol: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current }
{ ticker: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
{ ticker: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
{ ticker: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current }
]
Security::Price.expects(:find_prices)
.with(start_date: 2.days.ago.to_date, end_date: Date.current, ticker: "AMZN")
.once
.returns([
Security::Price.new(ticker: "AMZN", date: 1.day.ago.to_date, price: 215)
])
run_sync_for(@account)
assert_holdings(expected)
@ -71,17 +94,17 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
def assert_holdings(expected_holdings)
holdings = @account.holdings.includes(:security).to_a
expected_holdings.each do |expected_holding|
actual_holding = holdings.find { |holding| holding.security.symbol == expected_holding[:symbol] && holding.date == expected_holding[:date] }
actual_holding = holdings.find { |holding| holding.security.ticker == expected_holding[:ticker] && holding.date == expected_holding[:date] }
date = expected_holding[:date]
expected_price = expected_holding[:price]
expected_qty = expected_holding[:qty]
expected_amount = expected_holding[:amount]
symbol = expected_holding[:symbol]
ticker = expected_holding[:ticker]
assert actual_holding, "expected #{symbol} holding on date: #{date}"
assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{symbol} on date: #{date}"
assert_equal expected_holding[:amount], actual_holding.amount, "expected #{expected_amount} amount for holding #{symbol} on date: #{date}"
assert_equal expected_holding[:price], actual_holding.price, "expected #{expected_price} price for holding #{symbol} on date: #{date}"
assert actual_holding, "expected #{ticker} holding on date: #{date}"
assert_equal expected_holding[:qty], actual_holding.qty, "expected #{expected_qty} qty for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:amount], actual_holding.amount, "expected #{expected_amount} amount for holding #{ticker} on date: #{date}"
assert_equal expected_holding[:price], actual_holding.price, "expected #{expected_price} price for holding #{ticker} on date: #{date}"
end
end

View file

@ -58,7 +58,7 @@ class Account::HoldingTest < ActiveSupport::TestCase
end
def create_holding(security, date, qty)
price = Security::Price.find_by(date: date, isin: security.isin).price
price = Security::Price.find_by(date: date, ticker: security.ticker).price
@account.holdings.create! \
date: date,

View file

@ -76,7 +76,9 @@ class AccountTest < ActiveSupport::TestCase
end
test "generates empty series if no balances and no exchange rate" do
assert_equal 0, @account.series(currency: "NZD").values.count
with_env_overrides SYNTH_API_KEY: nil do
assert_equal 0, @account.series(currency: "NZD").values.count
end
end
test "calculates shares owned of holding for date" do

View file

@ -12,7 +12,7 @@ class ExchangeRateTest < ActiveSupport::TestCase
ExchangeRate.unstub(:exchange_rates_provider)
with_env_overrides SYNTH_API_KEY: nil do
assert_nil ExchangeRate.exchange_rates_provider
assert_not ExchangeRate.exchange_rates_provider
end
end
@ -21,7 +21,7 @@ class ExchangeRateTest < ActiveSupport::TestCase
rate = exchange_rates(:one)
assert_equal exchange_rates(:one), ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date)
assert_equal rate, ExchangeRate.find_rate(from: rate.from_currency, to: rate.to_currency, date: rate.date)
end
test "finds single rate from provider and caches to DB" do
@ -38,14 +38,14 @@ class ExchangeRateTest < ActiveSupport::TestCase
test "nil if rate is not found in DB and provider throws an error" do
@provider.expects(:fetch_exchange_rate).with(from: "USD", to: "EUR", date: Date.current).once.returns(OpenStruct.new(success?: false))
assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
end
test "nil if rate is not found in DB and provider is disabled" do
ExchangeRate.unstub(:exchange_rates_provider)
with_env_overrides SYNTH_API_KEY: nil do
assert_nil ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
assert_not ExchangeRate.find_rate(from: "USD", to: "EUR", date: Date.current)
end
end

View file

@ -2,10 +2,18 @@ require "test_helper"
require "ostruct"
class Provider::SynthTest < ActiveSupport::TestCase
include ExchangeRateProviderInterfaceTest
include ExchangeRateProviderInterfaceTest, SecurityPriceProviderInterfaceTest
setup do
@subject = @synth = Provider::Synth.new("fookey")
@subject = @synth = Provider::Synth.new(ENV["SYNTH_API_KEY"])
end
test "fetches paginated securities prices" do
VCR.use_cassette("synth/security_prices") do
response = @synth.fetch_security_prices ticker: "AAPL", start_date: Date.iso8601("2024-01-01"), end_date: Date.iso8601("2024-08-01")
assert 213, response.size
end
end
test "retries then provides failed response" do
@ -13,7 +21,7 @@ class Provider::SynthTest < ActiveSupport::TestCase
response = @synth.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current
assert_match "Failed to fetch exchange rate from Provider::Synth", response.error.message
assert_match "Failed to fetch data from Provider::Synth", response.error.message
end
test "retrying, then raising on network error" do

View file

@ -1,7 +1,98 @@
require "test_helper"
require "ostruct"
class Security::PriceTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
setup do
@provider = mock
Security::Price.stubs(:security_prices_provider).returns(@provider)
end
test "security price provider nil if no api key provided" do
Security::Price.unstub(:security_prices_provider)
with_env_overrides SYNTH_API_KEY: nil do
assert_not Security::Price.security_prices_provider
end
end
test "finds single security price in DB" do
@provider.expects(:fetch_security_prices).never
price = security_prices(:one)
assert_equal price, Security::Price.find_price(ticker: price.ticker, date: price.date)
end
test "caches prices to DB" do
expected_price = 314.34
@provider.expects(:fetch_security_prices)
.once
.returns(
OpenStruct.new(
success?: true,
prices: [ { date: Date.current, price: expected_price } ]
)
)
fetched_rate = Security::Price.find_price(ticker: "NVDA", date: Date.current, cache: true)
refetched_rate = Security::Price.find_price(ticker: "NVDA", date: Date.current, cache: true)
assert_equal expected_price, fetched_rate.price
assert_equal expected_price, refetched_rate.price
end
test "returns nil if no price found in DB or from provider" do
@provider.expects(:fetch_security_prices)
.with(ticker: "NVDA", start_date: Date.current, end_date: Date.current)
.once
.returns(OpenStruct.new(success?: false))
assert_not Security::Price.find_price(ticker: "NVDA", date: Date.current)
end
test "returns nil if price not found in DB and provider disabled" do
Security::Price.unstub(:security_prices_provider)
with_env_overrides SYNTH_API_KEY: nil do
assert_not Security::Price.find_price(ticker: "NVDA", date: Date.current)
end
end
test "fetches multiple dates at once" do
@provider.expects(:fetch_security_prices).never
price1 = security_prices(:one) # AAPL today
price2 = security_prices(:two) # AAPL yesterday
fetched_prices = Security::Price.find_prices(start_date: 1.day.ago.to_date, end_date: Date.current, ticker: "AAPL").sort_by(&:date)
assert_equal price1, fetched_prices[1]
assert_equal price2, fetched_prices[0]
end
test "caches multiple prices to DB" do
missing_price = 213.21
@provider.expects(:fetch_security_prices)
.with(ticker: "AAPL", start_date: 2.days.ago.to_date, end_date: 2.days.ago.to_date)
.returns(OpenStruct.new(success?: true, prices: [ { date: 2.days.ago.to_date, price: missing_price } ]))
.once
price1 = security_prices(:one) # AAPL today
price2 = security_prices(:two) # AAPL yesterday
fetched_prices = Security::Price.find_prices(ticker: "AAPL", start_date: 2.days.ago.to_date, end_date: Date.current, cache: true)
refetched_prices = Security::Price.find_prices(ticker: "AAPL", start_date: 2.days.ago.to_date, end_date: Date.current, cache: true)
assert_equal [ missing_price, price2.price, price1.price ], fetched_prices.sort_by(&:date).map(&:price)
assert_equal [ missing_price, price2.price, price1.price ], refetched_prices.sort_by(&:date).map(&:price)
end
test "returns empty array if no prices found in DB or from provider" do
Security::Price.unstub(:security_prices_provider)
with_env_overrides SYNTH_API_KEY: nil do
assert_equal [], Security::Price.find_prices(ticker: "NVDA", start_date: 10.days.ago.to_date, end_date: Date.current)
end
end
end

View file

@ -29,7 +29,7 @@ module Account::EntriesTestHelper
end
def create_trade(security, account:, qty:, date:, price: nil)
trade_price = price || Security::Price.find_by!(isin: security.isin, date: date).price
trade_price = price || Security::Price.find_by!(ticker: security.ticker, date: date).price
trade = Account::Trade.new \
qty: qty,

View file

@ -1,16 +1,9 @@
module SecuritiesTestHelper
def create_security(symbol, prices:)
isin_codes = {
"AMZN" => "US0231351067",
"NVDA" => "US67066G1040"
}
isin = isin_codes[symbol]
def create_security(ticker, prices:)
prices.each do |price|
Security::Price.create! isin: isin, date: price[:date], price: price[:price]
Security::Price.create! ticker: ticker, date: price[:date], price: price[:price]
end
Security.create! isin: isin, symbol: symbol
Security.create! ticker: ticker
end
end

View file

@ -1,13 +1,12 @@
if ENV["COVERAGE"]
if ENV["COVERAGE"] == "true"
require "simplecov"
SimpleCov.start "rails" do
enable_coverage :branch
end
end
# Test ENV setup:
# By default, all features should be disabled
# Use the `with_env_overrides` helper to enable features for individual tests
require_relative "../config/environment"
ENV["SELF_HOSTING_ENABLED"] = "false"
ENV["UPGRADES_ENABLED"] = "false"
ENV["RAILS_ENV"] ||= "test"
@ -16,7 +15,6 @@ ENV["RAILS_ENV"] ||= "test"
# https://github.com/ged/ruby-pg/issues/538#issuecomment-1591629049
ENV["PGGSSENCMODE"] = "disable"
require_relative "../config/environment"
require "rails/test_help"
require "minitest/mock"
require "minitest/autorun"
@ -33,10 +31,10 @@ end
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors) unless ENV["DISABLE_PARALLELIZATION"]
parallelize(workers: :number_of_processors) unless ENV["DISABLE_PARALLELIZATION"] == "true"
# https://github.com/simplecov-ruby/simplecov/issues/718#issuecomment-538201587
if ENV["COVERAGE"]
if ENV["COVERAGE"] == "true"
parallelize_setup do |worker|
SimpleCov.command_name "#{SimpleCov.command_name}-#{worker}"
end

View file

@ -2,13 +2,13 @@
http_interactions:
- request:
method: get
uri: https://api.synthfinance.com/rates/historical?date=<%= Date.current.to_s %>&from=USD&to=MXN
uri: https://api.synthfinance.com/rates/historical?date=2024-08-01&from=USD&to=MXN
body:
encoding: US-ASCII
string: ''
headers:
User-Agent:
- Faraday v2.9.0
- Faraday v2.10.0
Authorization:
- Bearer <SYNTH_API_KEY>
Accept-Encoding:
@ -21,21 +21,21 @@ http_interactions:
message: OK
headers:
Date:
- Wed, 27 Mar 2024 02:54:11 GMT
- Thu, 01 Aug 2024 17:20:28 GMT
Content-Type:
- application/json; charset=utf-8
Content-Length:
- '138'
Transfer-Encoding:
- chunked
Connection:
- keep-alive
Cf-Ray:
- 86ac182ad9ec7ce5-LAX
- 8ac77fbcc9d013ae-CMH
Cf-Cache-Status:
- DYNAMIC
Cache-Control:
- max-age=0, private, must-revalidate
Etag:
- W/"46780d3f34043bb3bc799b1efae62418"
- W/"668c8ac287a5ff6d6a705c35c69823b1"
Strict-Transport-Security:
- max-age=63072000; includeSubDomains
Vary:
@ -43,7 +43,7 @@ http_interactions:
Referrer-Policy:
- strict-origin-when-cross-origin
Rndr-Id:
- 3ca97b82-f963-43a3
- ff56c2fe-6252-4b2c
X-Content-Type-Options:
- nosniff
X-Frame-Options:
@ -53,9 +53,9 @@ http_interactions:
X-Render-Origin-Server:
- Render
X-Request-Id:
- 64731a8c-4cad-4e42-81c9-60b0d3634a0f
- 61992b01-969b-4af5-8119-9b17e385da07
X-Runtime:
- '0.021432'
- '0.369358'
X-Xss-Protection:
- '0'
Server:
@ -64,6 +64,6 @@ http_interactions:
- h3=":443"; ma=86400
body:
encoding: ASCII-8BIT
string: '{"data":{"date":"<%= Date.current.to_s %>","source":"USD","rates":{"MXN":16.64663}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":976}}'
recorded_at: Wed, 27 Mar 2024 02:54:11 GMT
string: '{"data":{"date":"2024-08-01","source":"USD","rates":{"MXN":18.645877}},"meta":{"total_records":1,"credits_used":1,"credits_remaining":248999}}'
recorded_at: Thu, 01 Aug 2024 17:20:28 GMT
recorded_with: VCR 6.2.0

File diff suppressed because one or more lines are too long