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*
|
||||||
!/.env*.erb
|
!/.env*.erb
|
||||||
!.env.example
|
!.env.example
|
||||||
|
!.env.test.example
|
||||||
|
|
||||||
# Ignore all logfiles and tempfiles.
|
# Ignore all logfiles and tempfiles.
|
||||||
/log/*
|
/log/*
|
||||||
|
|
2
Gemfile
2
Gemfile
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
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 %>
|
<%= 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>
|
||||||
|
|
||||||
|
|
|
@ -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 %>
|
||||||
|
|
|
@ -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.
|
# 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"
|
||||||
|
|
6
test/fixtures/securities.yml
vendored
6
test/fixtures/securities.yml
vendored
|
@ -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
|
||||||
|
|
4
test/fixtures/security/prices.yml
vendored
4
test/fixtures/security/prices.yml
vendored
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
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)
|
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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
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