1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +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*
!/.env*.erb !/.env*.erb
!.env.example !.env.example
!.env.test.example
# Ignore all logfiles and tempfiles. # Ignore all logfiles and tempfiles.
/log/* /log/*

View file

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

View file

@ -204,7 +204,7 @@ class Account::Entry < ApplicationRecord
current_qty = account.holding_qty(account_trade.security) current_qty = account.holding_qty(account_trade.security)
if current_qty < account_trade.qty.abs 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 end
end end

View file

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

View file

@ -32,16 +32,42 @@ class Account::Holding::Syncer
.order(:date) .order(:date)
end 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) def build_holdings_for_date(date)
trades = sync_entries.select { |trade| trade.date == date } trades = sync_entries.select { |trade| trade.date == date }
@portfolio = generate_next_portfolio(@portfolio, trades) @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 = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
trade_price = trade&.account_trade&.price 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 \ account.holdings.build \
date: date, date: date,
@ -58,10 +84,10 @@ class Account::Holding::Syncer
trade = entry.account_trade trade = entry.account_trade
price = trade.price 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_qty = prior_qty + trade.qty
new_portfolio[trade.security.isin] = { new_portfolio[trade.security.ticker] = {
qty: new_qty, qty: new_qty,
price: price, price: price,
amount: new_qty * 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 = account.holdings.where(date: sync_date_range.begin - 1.day)
prior_day_holdings.each do |holding| prior_day_holdings.each do |holding|
@portfolio[holding.security.isin] = { @portfolio[holding.security.ticker] = {
qty: holding.qty, qty: holding.qty,
price: holding.price, price: holding.price,
amount: holding.amount, amount: holding.amount,

View file

@ -6,18 +6,25 @@ module Providable
extend ActiveSupport::Concern extend ActiveSupport::Concern
class_methods do class_methods do
def exchange_rates_provider def security_prices_provider
api_key = ENV["SYNTH_API_KEY"] synth_provider
end
if api_key.present? def exchange_rates_provider
Provider::Synth.new api_key synth_provider
else
nil
end
end end
def git_repository_provider def git_repository_provider
Provider::Github.new Provider::Github.new
end 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
end end

View file

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

View file

@ -5,6 +5,27 @@ class Provider::Synth
@api_key = api_key @api_key = api_key
end 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:) def fetch_exchange_rate(from:, to:, date:)
retrying Provider::Base.known_transient_errors do |on_last_attempt| retrying Provider::Base.known_transient_errors do |on_last_attempt|
response = Faraday.get("#{base_url}/rates/historical") do |req| response = Faraday.get("#{base_url}/rates/historical") do |req|
@ -33,9 +54,11 @@ class Provider::Synth
end end
private private
attr_reader :api_key attr_reader :api_key
ExchangeRateResponse = Struct.new :rate, :success?, :error, :raw_response, keyword_init: true 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 def base_url
"https://api.synthfinance.com" "https://api.synthfinance.com"
@ -43,9 +66,43 @@ class Provider::Synth
def build_error(response) def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR) Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch exchange rate from #{self.class} Failed to fetch data from #{self.class}
Status: #{response.status} Status: #{response.status}
Body: #{response.body.inspect} Body: #{response.body.inspect}
ERROR ERROR
end 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 end

View file

@ -1,14 +1,13 @@
class Security < ApplicationRecord class Security < ApplicationRecord
before_save :normalize_identifiers before_save :upcase_ticker
has_many :trades, dependent: :nullify, class_name: "Account::Trade" 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 private
def normalize_identifiers def upcase_ticker
self.isin = isin.upcase self.ticker = ticker.upcase
self.symbol = symbol.upcase
end end
end end

View file

@ -1,2 +1,34 @@
class Security::Price < ApplicationRecord 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 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 %> <%= render "shared/circle_logo", name: holding.name %>
<div> <div>
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %> <%= 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>
</div> </div>

View file

@ -3,7 +3,7 @@
<header class="flex justify-between"> <header class="flex justify-between">
<div> <div>
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %> <%= 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> </div>
<%= render "shared/circle_logo", name: @holding.name %> <%= 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. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "pgcrypto" enable_extension "pgcrypto"
enable_extension "plpgsql" 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.boolean "is_active", default: true, null: false
t.date "last_sync_date" t.date "last_sync_date"
t.uuid "institution_id" 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 ["accountable_type"], name: "index_accounts_on_accountable_type"
t.index ["family_id"], name: "index_accounts_on_family_id" t.index ["family_id"], name: "index_accounts_on_family_id"
t.index ["institution_id"], name: "index_accounts_on_institution_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 end
create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "isin", null: false t.string "ticker"
t.string "symbol"
t.string "name" t.string "name"
t.datetime "created_at", null: false t.datetime "created_at", null: false
t.datetime "updated_at", null: false t.datetime "updated_at", null: false
end end
create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| create_table "security_prices", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
t.string "isin" t.string "ticker"
t.date "date" t.date "date"
t.decimal "price", precision: 19, scale: 4 t.decimal "price", precision: 19, scale: 4
t.string "currency", default: "USD" t.string "currency", default: "USD"

View file

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

View file

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

View file

@ -8,8 +8,8 @@ module ExchangeRateProviderInterfaceTest
end end
test "exchange rate provider response contract" do test "exchange rate provider response contract" do
accounting_for_http_calls do VCR.use_cassette "synth/exchange_rate" do
response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.current response = @subject.fetch_exchange_rate from: "USD", to: "MXN", date: Date.iso8601("2024-08-01")
assert_respond_to response, :rate assert_respond_to response, :rate
assert_respond_to response, :success? assert_respond_to response, :success?
@ -17,11 +17,4 @@ module ExchangeRateProviderInterfaceTest
assert_respond_to response, :raw_response assert_respond_to response, :raw_response
end end
end end
private
def accounting_for_http_calls
VCR.use_cassette "synth_exchange_rate" do
yield
end
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) syncer = Account::Balance::Syncer.new(@account)
assert_raises Money::ConversionError do with_env_overrides SYNTH_API_KEY: nil do
syncer.run assert_raises Money::ConversionError do
syncer.run
end
end end
end end
@ -104,7 +106,10 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
@account.update! currency: "EUR" @account.update! currency: "EUR"
syncer = Account::Balance::Syncer.new(@account) 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 assert_equal 1, syncer.warnings.count
end end

View file

@ -113,6 +113,6 @@ class Account::EntryTest < ActiveSupport::TestCase
entryable: Account::Trade.new(qty: -10, price: 200, security: security) entryable: Account::Trade.new(qty: -10, price: 200, security: security)
end 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
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 create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN
expected = [ expected = [
{ symbol: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date }, { ticker: "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 }, { ticker: "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 }, { ticker: "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 }, { ticker: "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: "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) run_sync_for(@account)
@ -46,21 +64,26 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
end end
test "generates all holdings even when missing security prices" do test "generates all holdings even when missing security prices" do
aapl = create_security("AMZN", prices: [ amzn = create_security("AMZN", prices: [])
{ date: 1.day.ago.to_date, price: 215 }
])
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 # 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 # 1 day ago — finds daily price, uses it
# Today — no daily price, no entry, so price and amount are `nil` # Today — no daily price, no entry, so price and amount are `nil`
expected = [ expected = [
{ symbol: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date }, { ticker: "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 }, { ticker: "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: 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) run_sync_for(@account)
assert_holdings(expected) assert_holdings(expected)
@ -71,17 +94,17 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
def assert_holdings(expected_holdings) def assert_holdings(expected_holdings)
holdings = @account.holdings.includes(:security).to_a holdings = @account.holdings.includes(:security).to_a
expected_holdings.each do |expected_holding| 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] date = expected_holding[:date]
expected_price = expected_holding[:price] expected_price = expected_holding[:price]
expected_qty = expected_holding[:qty] expected_qty = expected_holding[:qty]
expected_amount = expected_holding[:amount] expected_amount = expected_holding[:amount]
symbol = expected_holding[:symbol] ticker = expected_holding[:ticker]
assert actual_holding, "expected #{symbol} holding 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 #{symbol} 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 #{symbol} 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 #{symbol} on date: #{date}" assert_equal expected_holding[:price], actual_holding.price, "expected #{expected_price} price for holding #{ticker} on date: #{date}"
end end
end end

View file

@ -58,7 +58,7 @@ class Account::HoldingTest < ActiveSupport::TestCase
end end
def create_holding(security, date, qty) 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! \ @account.holdings.create! \
date: date, date: date,

View file

@ -76,7 +76,9 @@ class AccountTest < ActiveSupport::TestCase
end end
test "generates empty series if no balances and no exchange rate" do 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 end
test "calculates shares owned of holding for date" do test "calculates shares owned of holding for date" do

View file

@ -12,7 +12,7 @@ class ExchangeRateTest < ActiveSupport::TestCase
ExchangeRate.unstub(:exchange_rates_provider) ExchangeRate.unstub(:exchange_rates_provider)
with_env_overrides SYNTH_API_KEY: nil do with_env_overrides SYNTH_API_KEY: nil do
assert_nil ExchangeRate.exchange_rates_provider assert_not ExchangeRate.exchange_rates_provider
end end
end end
@ -21,7 +21,7 @@ class ExchangeRateTest < ActiveSupport::TestCase
rate = exchange_rates(:one) 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 end
test "finds single rate from provider and caches to DB" do 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 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)) @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 end
test "nil if rate is not found in DB and provider is disabled" do test "nil if rate is not found in DB and provider is disabled" do
ExchangeRate.unstub(:exchange_rates_provider) ExchangeRate.unstub(:exchange_rates_provider)
with_env_overrides SYNTH_API_KEY: nil do 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
end end

View file

@ -2,10 +2,18 @@ require "test_helper"
require "ostruct" require "ostruct"
class Provider::SynthTest < ActiveSupport::TestCase class Provider::SynthTest < ActiveSupport::TestCase
include ExchangeRateProviderInterfaceTest include ExchangeRateProviderInterfaceTest, SecurityPriceProviderInterfaceTest
setup do 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 end
test "retries then provides failed response" do 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 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 end
test "retrying, then raising on network error" do test "retrying, then raising on network error" do

View file

@ -1,7 +1,98 @@
require "test_helper" require "test_helper"
require "ostruct"
class Security::PriceTest < ActiveSupport::TestCase class Security::PriceTest < ActiveSupport::TestCase
# test "the truth" do setup do
# assert true @provider = mock
# end
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 end

View file

@ -29,7 +29,7 @@ module Account::EntriesTestHelper
end end
def create_trade(security, account:, qty:, date:, price: nil) 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 \ trade = Account::Trade.new \
qty: qty, qty: qty,

View file

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

View file

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

View file

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

File diff suppressed because one or more lines are too long