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:
parent
c70c8b6d86
commit
453a54e5e6
33 changed files with 584 additions and 118 deletions
17
.env.test.example
Normal file
17
.env.test.example
Normal 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
1
.gitignore
vendored
|
@ -11,6 +11,7 @@
|
|||
/.env*
|
||||
!/.env*.erb
|
||||
!.env.example
|
||||
!.env.test.example
|
||||
|
||||
# Ignore all logfiles and tempfiles.
|
||||
/log/*
|
||||
|
|
2
Gemfile
2
Gemfile
|
@ -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"
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
55
app/models/security/price/provided.rb
Normal file
55
app/models/security/price/provided.rb
Normal 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
|
|
@ -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>
|
||||
|
||||
|
|
|
@ -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 %>
|
||||
|
|
|
@ -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
9
db/schema.rb
generated
|
@ -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"
|
||||
|
|
6
test/fixtures/securities.yml
vendored
6
test/fixtures/securities.yml
vendored
|
@ -1,9 +1,7 @@
|
|||
aapl:
|
||||
isin: US0378331005
|
||||
symbol: aapl
|
||||
ticker: AAPL
|
||||
name: Apple
|
||||
|
||||
msft:
|
||||
isin: US5949181045
|
||||
symbol: msft
|
||||
ticker: MSFT
|
||||
name: Microsoft
|
||||
|
|
4
test/fixtures/security/prices.yml
vendored
4
test/fixtures/security/prices.yml
vendored
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
20
test/interfaces/security_price_provider_interface_test.rb
Normal file
20
test/interfaces/security_price_provider_interface_test.rb
Normal 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
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
135
test/vcr_cassettes/synth/security_prices.yml
Normal file
135
test/vcr_cassettes/synth/security_prices.yml
Normal file
File diff suppressed because one or more lines are too long
Loading…
Add table
Add a link
Reference in a new issue