Compare commits

...

14 Commits

Author SHA1 Message Date
Zach Gollwitzer
edda5cb35b Bump to v0.1.0-alpha.13
Signed-off-by: Zach Gollwitzer <zach@maybe.co>
2024-08-02 17:10:16 -04:00
Zach Gollwitzer
ea8309eedd Show cash + holdings value for investment account view (#1046)
* Handle missing tickers in security price syncs

* Show combined cash and holdings value on account page

* Improve partial locals
2024-08-02 17:09:25 -04:00
Zach Gollwitzer
453a54e5e6 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
2024-08-01 19:43:23 -04:00
Zach Gollwitzer
c70c8b6d86 Ensure transfer name is populated (#1042)
* Ensure transfer name is populated

* Transfer amount fallback
2024-08-01 12:10:30 -04:00
Tony Vincent
f2a2d2f7e4 Fix demo data reset (#1041)
* Fix demo data reset

* Only delete test user
2024-08-01 08:56:32 -04:00
Mikhail Wahib
0a21c92643 fix: long emails overflow in account menu dropdown (#1034) 2024-07-31 12:24:01 -04:00
dependabot[bot]
2c5f647f53 Bump rails from 1b89033 to 5cb5cad (#1035)
Bumps [rails](https://github.com/rails/rails) from `1b89033` to `5cb5cad`.
- [Release notes](https://github.com/rails/rails/releases)
- [Commits](1b89033460...5cb5cad322)

---
updated-dependencies:
- dependency-name: rails
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:40:35 -04:00
dependabot[bot]
11f58537db Bump pagy from 9.0.2 to 9.0.3 (#1030)
Bumps [pagy](https://github.com/ddnexus/pagy) from 9.0.2 to 9.0.3.
- [Release notes](https://github.com/ddnexus/pagy/releases)
- [Changelog](https://github.com/ddnexus/pagy/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ddnexus/pagy/compare/9.0.2...9.0.3)

---
updated-dependencies:
- dependency-name: pagy
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:32:09 -04:00
dependabot[bot]
6231814e1e Bump mocha from 2.4.2 to 2.4.5 (#1029)
Bumps [mocha](https://github.com/freerange/mocha) from 2.4.2 to 2.4.5.
- [Changelog](https://github.com/freerange/mocha/blob/main/RELEASE.md)
- [Commits](https://github.com/freerange/mocha/compare/v2.4.2...v2.4.5)

---
updated-dependencies:
- dependency-name: mocha
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:31:13 -04:00
dependabot[bot]
7645a9ec56 Bump image_processing from 1.12.2 to 1.13.0 (#1028)
Bumps [image_processing](https://github.com/janko/image_processing) from 1.12.2 to 1.13.0.
- [Changelog](https://github.com/janko/image_processing/blob/master/CHANGELOG.md)
- [Commits](https://github.com/janko/image_processing/compare/v1.12.2...v1.13.0)

---
updated-dependencies:
- dependency-name: image_processing
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:31:06 -04:00
dependabot[bot]
08b59ad5fe Bump pg from 1.5.6 to 1.5.7 (#1027)
Bumps [pg](https://github.com/ged/ruby-pg) from 1.5.6 to 1.5.7.
- [Changelog](https://github.com/ged/ruby-pg/blob/master/History.md)
- [Commits](https://github.com/ged/ruby-pg/compare/v1.5.6...v1.5.7)

---
updated-dependencies:
- dependency-name: pg
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:30:39 -04:00
dependabot[bot]
02adba5280 Bump tailwindcss-rails from 2.6.3 to 2.6.4 (#1031)
Bumps [tailwindcss-rails](https://github.com/rails/tailwindcss-rails) from 2.6.3 to 2.6.4.
- [Release notes](https://github.com/rails/tailwindcss-rails/releases)
- [Changelog](https://github.com/rails/tailwindcss-rails/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rails/tailwindcss-rails/compare/v2.6.3...v2.6.4)

---
updated-dependencies:
- dependency-name: tailwindcss-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:30:31 -04:00
dependabot[bot]
1f5721a8b1 Bump sentry-rails from 5.18.1 to 5.18.2 (#1033)
Bumps [sentry-rails](https://github.com/getsentry/sentry-ruby) from 5.18.1 to 5.18.2.
- [Release notes](https://github.com/getsentry/sentry-ruby/releases)
- [Changelog](https://github.com/getsentry/sentry-ruby/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-ruby/compare/5.18.1...5.18.2)

---
updated-dependencies:
- dependency-name: sentry-rails
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-07-31 09:30:21 -04:00
pranavbabu
7ba9830db5 Fix: Omit layout for turbo frames with custom sidebar layout (#1024)
* Define layout method

* Use with_sidebar method

---------

Co-authored-by: Pranav Babu <babu@maindeck.io>
2024-07-26 12:00:41 -04:00
69 changed files with 813 additions and 196 deletions

17
.env.test.example Normal file
View File

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

1
.gitignore vendored
View File

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

View File

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

View File

@@ -7,7 +7,7 @@ GIT
GIT
remote: https://github.com/rails/rails.git
revision: 1b8903346000e1848e62e09429d325499af03b3f
revision: 5cb5cad3224d03114313fbe28c4dd1374c313d8f
branch: 7-2-stable
specs:
actioncable (7.2.0.beta3)
@@ -187,7 +187,12 @@ GEM
net-http
faraday-retry (2.2.1)
faraday (~> 2.0)
ffi (1.16.3)
ffi (1.17.0-aarch64-linux-gnu)
ffi (1.17.0-arm-linux-gnu)
ffi (1.17.0-arm64-darwin)
ffi (1.17.0-x86-linux-gnu)
ffi (1.17.0-x86_64-darwin)
ffi (1.17.0-x86_64-linux-gnu)
fugit (1.11.0)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
@@ -218,7 +223,7 @@ GEM
rails-i18n
rainbow (>= 2.2.2, < 4.0)
terminal-table (>= 1.5.1)
image_processing (1.12.2)
image_processing (1.13.0)
mini_magick (>= 4.9.5, < 5)
ruby-vips (>= 2.0.17, < 3)
importmap-rails (2.0.1)
@@ -254,10 +259,10 @@ GEM
net-smtp
marcel (1.0.4)
matrix (0.4.2)
mini_magick (4.12.0)
mini_magick (4.13.2)
mini_mime (1.1.5)
minitest (5.24.1)
mocha (2.4.2)
mocha (2.4.5)
ruby2_keywords (>= 0.0.5)
msgpack (1.7.2)
net-http (0.4.1)
@@ -272,27 +277,27 @@ GEM
net-smtp (0.5.0)
net-protocol
nio4r (2.7.3)
nokogiri (1.16.6-aarch64-linux)
nokogiri (1.16.7-aarch64-linux)
racc (~> 1.4)
nokogiri (1.16.6-arm-linux)
nokogiri (1.16.7-arm-linux)
racc (~> 1.4)
nokogiri (1.16.6-arm64-darwin)
nokogiri (1.16.7-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86-linux)
nokogiri (1.16.7-x86-linux)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-darwin)
nokogiri (1.16.7-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-linux)
nokogiri (1.16.7-x86_64-linux)
racc (~> 1.4)
octokit (9.1.0)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pagy (9.0.2)
pagy (9.0.3)
parallel (1.24.0)
parser (3.3.1.0)
ast (~> 2.4.1)
racc
pg (1.5.6)
pg (1.5.7)
prism (0.30.0)
propshaft (0.9.0)
actionpack (>= 7.0.0)
@@ -305,7 +310,7 @@ GEM
puma (6.4.2)
nio4r (~> 2.0)
raabro (1.4.0)
racc (1.8.0)
racc (1.8.1)
rack (3.1.7)
rack-session (2.0.0)
rack (>= 3.0.0)
@@ -379,8 +384,9 @@ GEM
ruby-lsp-rails (0.3.11)
ruby-lsp (>= 0.17.2, < 0.18.0)
ruby-progressbar (1.13.0)
ruby-vips (2.2.1)
ruby-vips (2.2.2)
ffi (~> 1.12)
logger
ruby2_keywords (0.0.5)
rubyzip (2.3.2)
sawyer (0.9.2)
@@ -392,10 +398,10 @@ GEM
rexml (~> 3.2, >= 3.2.5)
rubyzip (>= 1.2.2, < 3.0)
websocket (~> 1.0)
sentry-rails (5.18.1)
sentry-rails (5.18.2)
railties (>= 5.0)
sentry-ruby (~> 5.18.1)
sentry-ruby (5.18.1)
sentry-ruby (~> 5.18.2)
sentry-ruby (5.18.2)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
simplecov (0.22.0)
@@ -411,17 +417,17 @@ GEM
railties (>= 6.0.0)
stringio (3.1.1)
strscan (3.1.0)
tailwindcss-rails (2.6.3)
tailwindcss-rails (2.6.4)
railties (>= 7.0.0)
tailwindcss-rails (2.6.3-aarch64-linux)
tailwindcss-rails (2.6.4-aarch64-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.3-arm-linux)
tailwindcss-rails (2.6.4-arm-linux)
railties (>= 7.0.0)
tailwindcss-rails (2.6.3-arm64-darwin)
tailwindcss-rails (2.6.4-arm64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.3-x86_64-darwin)
tailwindcss-rails (2.6.4-x86_64-darwin)
railties (>= 7.0.0)
tailwindcss-rails (2.6.3-x86_64-linux)
tailwindcss-rails (2.6.4-x86_64-linux)
railties (>= 7.0.0)
terminal-table (3.0.2)
unicode-display_width (>= 1.1.1, < 3)
@@ -453,7 +459,7 @@ GEM
websocket-extensions (0.1.5)
xpath (3.2.0)
nokogiri (~> 1.8)
zeitwerk (2.6.16)
zeitwerk (2.6.17)
PLATFORMS
aarch64-linux

View File

@@ -0,0 +1,14 @@
class Account::CashesController < ApplicationController
layout :with_sidebar
before_action :set_account
def index
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
end

View File

@@ -1,5 +1,5 @@
class Account::EntriesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_account
before_action :set_entry, only: %i[ edit update show destroy ]
@@ -13,7 +13,7 @@ class Account::EntriesController < ApplicationController
end
def trades
@trades = @account.entries.account_trades.reverse_chronological
@trades = @account.entries.where(entryable_type: [ "Account::Transaction", "Account::Trade" ]).reverse_chronological
end
def new

View File

@@ -1,5 +1,5 @@
class Account::HoldingsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_account
before_action :set_holding, only: :show

View File

@@ -1,5 +1,5 @@
class Account::TransfersController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_transfer, only: :destroy

View File

@@ -1,5 +1,5 @@
class AccountsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
include Filterable
before_action :set_account, only: %i[ edit show destroy sync update ]
@@ -31,7 +31,8 @@ class AccountsController < ApplicationController
end
def show
@balance_series = @account.series(period: @period)
@series = @account.series(period: @period)
@trend = @series.trend
end
def edit

View File

@@ -4,4 +4,12 @@ class ApplicationController < ActionController::Base
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
private
def with_sidebar
return "turbo_rails/frame" if turbo_frame_request?
"with_sidebar"
end
end

View File

@@ -1,5 +1,5 @@
class CategoriesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_category, only: %i[ edit update ]
before_action :set_transaction, only: :create

View File

@@ -1,5 +1,5 @@
class Category::DeletionsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_category
before_action :set_replacement_category, only: :create

View File

@@ -1,5 +1,5 @@
class Help::ArticlesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
def show
@article = Help::Article.find(params[:id])

View File

@@ -1,5 +1,5 @@
class MerchantsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_merchant, only: %i[ edit update destroy ]

View File

@@ -1,5 +1,5 @@
class PagesController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
include Filterable

View File

@@ -1,3 +1,3 @@
class SettingsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
end

View File

@@ -1,5 +1,5 @@
class Tag::DeletionsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_tag
before_action :set_replacement_tag, only: :create

View File

@@ -1,5 +1,5 @@
class TagsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
before_action :set_tag, only: %i[ edit update ]

View File

@@ -1,5 +1,5 @@
class TransactionsController < ApplicationController
layout "with_sidebar"
layout :with_sidebar
def index
@q = search_params

View File

@@ -0,0 +1,13 @@
module Account::CashesHelper
def brokerage_cash(account)
currency = Money::Currency.new(account.currency)
account.holdings.build \
date: Date.current,
qty: account.balance,
price: 1,
amount: account.balance,
currency: account.currency,
security: Security.new(ticker: currency.iso_code, name: currency.name)
end
end

View File

@@ -25,11 +25,12 @@ module AccountsHelper
def account_tabs(account)
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
cash_tab = { key: "cash", label: t("accounts.show.cash"), path: account_path(account, tab: "cash"), content_path: account_cashes_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) }
return [ holdings_tab, trades_tab ] if account.investment?
return [ holdings_tab, cash_tab, trades_tab ] if account.investment?
[ value_tab, transactions_tab ]
end

View File

@@ -28,8 +28,10 @@ class Account < ApplicationRecord
delegated_type :accountable, types: Accountable::TYPES, dependent: :destroy
delegate :value, :series, to: :accountable
class << self
def by_group(period: Period.all, currency: Money.default_currency)
def by_group(period: Period.all, currency: Money.default_currency.iso_code)
grouped_accounts = { assets: ValueGroup.new("Assets", currency), liabilities: ValueGroup.new("Liabilities", currency) }
Accountable.by_classification.each do |classification, types|
@@ -82,18 +84,6 @@ class Account < ApplicationRecord
classification == "asset" ? "up" : "down"
end
def series(period: Period.all, currency: self.currency)
balance_series = balances.in_period(period).where(currency: Money::Currency.new(currency).iso_code)
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money)
end
rescue Money::ConversionError
TimeSeries.new([])
end
def update_balance!(balance)
valuation = entries.account_valuations.find_by(date: Date.current)

View File

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

View File

@@ -10,15 +10,17 @@ class Account::Holding < ApplicationRecord
scope :chronological, -> { order(:date) }
scope :current, -> { where(date: Date.current).order(amount: :desc) }
scope :in_period, ->(period) { period.date_range.nil? ? all : where(date: period.date_range) }
scope :known_value, -> { where.not(amount: nil) }
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
portfolio_value = account.holdings.current.where.not(amount: nil).sum(&:amount)
portfolio_value = account.holdings.current.known_value.sum(&:amount)
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
end

View File

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

View File

@@ -9,19 +9,18 @@ class Account::Transfer < ApplicationRecord
end
def amount_money
entries.first&.amount_money&.abs
entries.first&.amount_money&.abs || Money.new(0)
end
def from_name
outflow_transaction&.account&.name
outflow_transaction&.account&.name || I18n.t("account.transfer.from_fallback_name")
end
def to_name
inflow_transaction&.account&.name
inflow_transaction&.account&.name || I18n.t("account.transfer.to_fallback_name")
end
def name
return nil unless from_name && to_name
I18n.t("account.transfer.name", from_account: from_name, to_account: to_name)
end

View File

@@ -17,4 +17,20 @@ module Accountable
included do
has_one :account, as: :accountable, touch: true
end
def value
account.balance_money
end
def series(period: Period.all, currency: account.currency)
balance_series = account.balances.in_period(period).where(currency: currency)
if balance_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: account.balance_money.exchange_to(currency) } ])
else
TimeSeries.from_collection(balance_series, :balance_money)
end
rescue Money::ConversionError
TimeSeries.new([])
end
end

View File

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

View File

@@ -52,6 +52,7 @@ class Demo::Generator
end
def clear_data!
User.find_by_email("user@maybe.local")&.destroy
ExchangeRate.destroy_all
Security.destroy_all
Security::Price.destroy_all
@@ -166,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|
@@ -183,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
@@ -200,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)
@@ -219,14 +220,14 @@ class Demo::Generator
date = Faker::Number.positive(to: 730).days.ago.to_date
security = trade[:security]
qty = trade[:qty]
price = Security::Price.find_by(isin: security.isin, date: date)&.price || 1
price = Security::Price.find_by(ticker: security.ticker, date: date)&.price || 1
name_prefix = qty < 0 ? "Sell " : "Buy "
account.entries.create! \
date: date,
amount: qty * price,
currency: "USD",
name: name_prefix + "#{qty} shares of #{security.symbol}",
name: name_prefix + "#{qty} shares of #{security.ticker}",
entryable: Account::Trade.new(qty: qty, price: price, security: security)
end
end

View File

@@ -13,4 +13,35 @@ class Investment < ApplicationRecord
[ "Roth 401k", "roth_401k" ],
[ "Angel", "angel" ]
].freeze
def value
account.balance_money + holdings_value
end
def holdings_value
account.holdings.current.known_value.sum(&:amount) || Money.new(0, account.currency)
end
def series(period: Period.all, currency: account.currency)
balance_series = account.balances.in_period(period).where(currency: currency)
holding_series = account.holdings.known_value.in_period(period).where(currency: currency)
holdings_by_date = holding_series.group_by(&:date).transform_values do |holdings|
holdings.sum(&:amount)
end
combined_series = balance_series.map do |balance|
holding_amount = holdings_by_date[balance.date] || 0
{ date: balance.date, value: Money.new(balance.balance + holding_amount, currency) }
end
if combined_series.empty? && period.date_range.end == Date.current
TimeSeries.new([ { date: Date.current, value: self.value.exchange_to(currency) } ])
else
TimeSeries.new(combined_series)
end
rescue Money::ConversionError
TimeSeries.new([])
end
end

View File

@@ -5,6 +5,32 @@ 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
rescue StandardError => error
SecurityPriceResponse.new \
success?: false,
error: error,
raw_response: error
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 +59,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 +71,43 @@ class Provider::Synth
def build_error(response)
Provider::Base::ProviderError.new(<<~ERROR)
Failed to fetch exchange rate from #{self.class}
Failed to fetch data from #{self.class}
Status: #{response.status}
Body: #{response.body.inspect}
ERROR
end
def fetch_page(url, page, params = {})
Faraday.get(url) do |req|
req.headers["Authorization"] = "Bearer #{api_key}"
params.each { |k, v| req.params[k.to_s] = v.to_s }
req.params["page"] = page
end
end
def paginate(url, params = {})
results = []
page = 1
current_page = 0
total_pages = 1
while current_page < total_pages
response = fetch_page(url, page, params)
if response.success?
body = JSON.parse(response.body)
page_results = yield(body)
results.concat(page_results)
current_page = body.dig("paging", "current_page")
total_pages = body.dig("paging", "total_pages")
page += 1
else
raise build_error(response)
end
end
results
end
end

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
<%# locals: (holding:) %>
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-9 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name %>
<div>
<%= tag.p holding.name %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-3 text-right">
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
</div>
<% end %>

View File

@@ -0,0 +1,18 @@
<%= turbo_frame_tag dom_id(@account, "cash") do %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".cash"), class: "font-medium text-lg" %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
<%= tag.p t(".name"), class: "col-span-9" %>
<%= tag.p t(".value"), class: "col-span-3 justify-self-end" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %>
</div>
</div>
</div>
<% end %>

View File

@@ -1,4 +1,4 @@
<%# locals: (date:, entries:, selectable: true, **opts) %>
<%# locals: (date:, entries:, selectable: true, combine_transfers: false, **opts) %>
<div id="entry-group-<%= date %>" class="bg-gray-25 rounded-xl p-1 w-full" data-bulk-select-target="group">
<div class="py-2 px-4 flex items-center justify-between font-medium text-xs text-gray-500">
<div class="flex pl-0.5 items-center gap-4">
@@ -15,7 +15,11 @@
<%= totals_by_currency(collection: entries, money_method: :amount_money, negate: true) %>
</div>
<div class="bg-white shadow-xs rounded-md border border-alpha-black-25 divide-y divide-alpha-black-50">
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
<%= render transfer_entries(entries), selectable:, **opts %>
<% if combine_transfers %>
<%= render entries.reject { |e| e.transfer_id.present? }, selectable:, **opts %>
<%= render transfer_entries(entries), selectable: false, **opts %>
<% else %>
<%= render entries, selectable:, **opts %>
<% end %>
</div>
</div>

View File

@@ -1,8 +1,9 @@
<%# locals: (entry:, selectable: true, editable: true, short: false, show_tags: false, **opts) %>
<% transaction, account = entry.account_transaction, entry.account %>
<% is_investment_transfer = entry.account.investment? && entry.transfer.present? %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<% name_col_span = entry.marked_as_transfer? ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
<% name_col_span = unconfirmed_transfer?(entry) ? "col-span-10" : short ? "col-span-6" : "col-span-4" %>
<div class="pr-10 flex items-center gap-4 <%= name_col_span %>">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
@@ -51,6 +52,12 @@
<% end %>
</div>
<% if is_investment_transfer %>
<div class="col-span-5 text-right">
<%= tag.p entry.inflow? ? t(".deposit") : t(".withdrawal") %>
</div>
<% end %>
<% unless entry.marked_as_transfer? %>
<% unless short %>
<div class="flex items-center gap-1 <%= show_tags ? "col-span-6" : "col-span-3" %>">
@@ -82,7 +89,7 @@
<% end %>
<% end %>
<div class="col-span-2 ml-auto">
<div class="<%= is_investment_transfer ? "col-span-3" : "col-span-2" %> ml-auto">
<%= content_tag :p,
format_money(-entry.amount_money),
class: ["text-green-600": entry.inflow?] %>

View File

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

View File

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

View File

@@ -11,7 +11,7 @@
<%= tag.div class: short ? "max-w-[250px]" : "max-w-[325px]" do %>
<div class="flex items-center gap-2">
<%= circle_logo("T") %>
<%= circle_logo(transfer.from_name[0].upcase) %>
<%= tag.p transfer.name, class: "truncate text-gray-900" %>
</div>

View File

@@ -53,13 +53,13 @@
<div class="p-4 flex justify-between">
<div class="space-y-2">
<%= tag.p t(".total_value"), class: "text-sm font-medium text-gray-500" %>
<%= tag.p format_money(@account.balance_money, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
<%= tag.p format_money(@account.value, precision: 0), class: "text-gray-900 text-3xl font-medium" %>
<div>
<% if @balance_series.trend.direction.flat? %>
<% if @series.trend.direction.flat? %>
<%= tag.span t(".no_change"), class: "text-gray-500" %>
<% else %>
<%= tag.span format_money(@balance_series.trend.value), style: "color: #{@balance_series.trend.color}" %>
<%= tag.span "(#{@balance_series.trend.percent}%)", style: "color: #{@balance_series.trend.color}" %>
<%= tag.span format_money(@series.trend.value), style: "color: #{@trend.color}" %>
<%= tag.span "(#{@trend.percent}%)", style: "color: #{@trend.color}" %>
<% end %>
<%= tag.span period_label(@period), class: "text-gray-500" %>
@@ -70,7 +70,7 @@
<% end %>
</div>
<div class="h-96 flex items-center justify-center text-2xl font-bold">
<%= render partial: "shared/line_chart", locals: { series: @balance_series } %>
<%= render partial: "shared/line_chart", locals: { series: @series } %>
</div>
</div>

View File

@@ -22,7 +22,7 @@
<% else %>
<div class="text-white shrink-0 w-9 h-9 bg-gray-400 rounded-full flex items-center justify-center text-lg uppercase"><%= Current.user.initial %></div>
<% end %>
<div>
<div class="overflow-hidden text-ellipsis">
<span class="text-gray-900 font-medium text-sm"><%= Current.user.display_name %></span>
<% if Current.user.display_name != Current.user.email %>
<span class="text-gray-500 text-sm"><%= Current.user.email %></span>

View File

@@ -28,7 +28,7 @@
</div>
<div class="space-y-6">
<% @transaction_entries.group_by(&:date).each do |date, entries| %>
<%= render "account/entries/entry_group", date:, entries: %>
<%= render "account/entries/entry_group", date:, combine_transfers: true, entries: %>
<% end %>
</div>
</div>

View File

@@ -10,7 +10,7 @@ module Maybe
private
def semver
"0.1.0-alpha.12"
"0.1.0-alpha.13"
end
end
end

View File

@@ -2,4 +2,6 @@
en:
account:
transfer:
from_fallback_name: Originator
name: Transfer from %{from_account} to %{to_account}
to_fallback_name: Receiver

View File

@@ -0,0 +1,8 @@
---
en:
account:
cashes:
index:
cash: Cash
name: Name
value: Total Balance

View File

@@ -47,9 +47,11 @@ en:
settings: Settings
tags_label: Select one or more tags
transaction:
deposit: Deposit
remove_transfer: Remove transfer
remove_transfer_body: This will remove the transfer from this transaction
remove_transfer_confirm: Confirm
withdrawal: Withdrawal
valuation:
form:
cancel: Cancel
@@ -70,10 +72,10 @@ en:
loading: Loading entries...
trades:
amount: Amount
new: New trade
no_trades: No trades for this account yet.
trade: trade
trades: Trades
new: New transaction
no_trades: No transactions for this account yet.
trade: transaction
trades: Transactions
type: Type
transactions:
new: New transaction

View File

@@ -48,6 +48,7 @@ en:
title: Add an account
ungrouped: "(none)"
show:
cash: Cash
confirm_accept: Delete "%{name}"
confirm_body_html: "<p>By deleting this account, you will erase its value history,
affecting various aspects of your overall account. This action will have a
@@ -63,7 +64,7 @@ en:
graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync.
total_value: Total Value
trades: Trades
trades: Transactions
transactions: Transactions
value: Value
summary:

View File

@@ -79,6 +79,7 @@ Rails.application.routes.draw do
resource :logo, only: :show
resources :holdings, only: %i[ index new show ]
resources :cashes, only: :index
resources :entries, except: :index do
collection do

View File

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

9
db/schema.rb generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long