diff --git a/app/models/account.rb b/app/models/account.rb index 2fa08845..98a9f843 100644 --- a/app/models/account.rb +++ b/app/models/account.rb @@ -12,6 +12,8 @@ class Account < ApplicationRecord has_many :entries, dependent: :destroy, class_name: "Account::Entry" has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction" has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation" + has_many :trades, through: :entries, source: :entryable, source_type: "Account::Trade" + has_many :holdings, dependent: :destroy has_many :balances, dependent: :destroy has_many :imports, dependent: :destroy has_many :syncs, dependent: :destroy @@ -107,4 +109,12 @@ class Account < ApplicationRecord entryable: Account::Valuation.new end end + + def holding_qty(security, date: Date.current) + entries.account_trades + .joins("JOIN account_trades ON account_entries.entryable_id = account_trades.id") + .where(account_trades: { security_id: security.id }) + .where("account_entries.date <= ?", date) + .sum("account_trades.qty") + end end diff --git a/app/models/account/balance/syncer.rb b/app/models/account/balance/syncer.rb index 63bc346e..1756edc3 100644 --- a/app/models/account/balance/syncer.rb +++ b/app/models/account/balance/syncer.rb @@ -27,13 +27,9 @@ class Account::Balance::Syncer attr_reader :sync_start_date, :account def upsert_balances!(balances) + current_time = Time.now balances_to_upsert = balances.map do |balance| - { - date: balance.date, - balance: balance.balance, - currency: balance.currency, - updated_at: Time.now - } + balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time) end account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency]) @@ -49,9 +45,9 @@ class Account::Balance::Syncer return valuation.amount if valuation return derived_sync_start_balance(entries) unless prior_balance - transactions = entries.select { |e| e.date == date && e.account_transaction? } + entries = entries.select { |e| e.date == date } - prior_balance - net_transaction_flows(transactions) + prior_balance - net_entry_flows(entries) end def calculate_daily_balances @@ -95,19 +91,19 @@ class Account::Balance::Syncer end def derived_sync_start_balance(entries) - transactions = entries.select { |e| e.account_transaction? && e.date > sync_start_date } + transactions_and_trades = entries.reject { |e| e.account_valuation? }.select { |e| e.date > sync_start_date } - account.balance + net_transaction_flows(transactions) + account.balance + net_entry_flows(transactions_and_trades) end def find_prior_balance account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance end - def net_transaction_flows(transactions, target_currency = account.currency) - converted_transaction_amounts = transactions.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) } + def net_entry_flows(entries, target_currency = account.currency) + converted_entry_amounts = entries.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) } - flows = converted_transaction_amounts.sum(&:amount) + flows = converted_entry_amounts.sum(&:amount) account.liability? ? flows * -1 : flows end diff --git a/app/models/account/entry.rb b/app/models/account/entry.rb index eeada207..4e80319f 100644 --- a/app/models/account/entry.rb +++ b/app/models/account/entry.rb @@ -11,6 +11,7 @@ class Account::Entry < ApplicationRecord validates :date, :amount, :currency, presence: true validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? } + validate :trade_valid?, if: -> { account_trade? } scope :chronological, -> { order(:date, :created_at) } scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) } @@ -123,7 +124,7 @@ class Account::Entry < ApplicationRecord def income_total(currency = "USD") without_transfers.account_transactions.includes(:entryable) - .where("account_entries.amount <= 0") + .where("account_entries.amount <= 0") .map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) } .sum end @@ -191,4 +192,14 @@ class Account::Entry < ApplicationRecord previous: previous_entry&.amount_money, favorable_direction: account.favorable_direction end + + def trade_valid? + if account_trade.sell? + 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") + end + end + end end diff --git a/app/models/account/entryable.rb b/app/models/account/entryable.rb index 5a23bd81..7c3b3ef9 100644 --- a/app/models/account/entryable.rb +++ b/app/models/account/entryable.rb @@ -1,7 +1,7 @@ module Account::Entryable extend ActiveSupport::Concern - TYPES = %w[ Account::Valuation Account::Transaction ] + TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ] def self.from_type(entryable_type) entryable_type.presence_in(TYPES).constantize diff --git a/app/models/account/holding.rb b/app/models/account/holding.rb new file mode 100644 index 00000000..b5a63248 --- /dev/null +++ b/app/models/account/holding.rb @@ -0,0 +1,6 @@ +class Account::Holding < ApplicationRecord + belongs_to :account + belongs_to :security + + scope :chronological, -> { order(:date) } +end diff --git a/app/models/account/holding/syncer.rb b/app/models/account/holding/syncer.rb new file mode 100644 index 00000000..3f2af7a7 --- /dev/null +++ b/app/models/account/holding/syncer.rb @@ -0,0 +1,96 @@ +class Account::Holding::Syncer + attr_reader :warnings + + def initialize(account, start_date: nil) + @account = account + @warnings = [] + @sync_date_range = calculate_sync_start_date(start_date)..Date.current + @portfolio = {} + + load_prior_portfolio if start_date + end + + def run + holdings = [] + + sync_date_range.each do |date| + holdings += build_holdings_for_date(date) + end + + upsert_holdings holdings + end + + private + + attr_reader :account, :sync_date_range + + def sync_entries + @sync_entries ||= account.entries + .account_trades + .includes(entryable: :security) + .where("date >= ?", sync_date_range.begin) + .order(:date) + 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| + price = Security::Price.find_by!(date: date, isin: isin).price + + account.holdings.build \ + date: date, + security_id: holding[:security_id], + qty: holding[:qty], + price: price, + amount: price * holding[:qty] + end + end + + def generate_next_portfolio(prior_portfolio, trade_entries) + trade_entries.each_with_object(prior_portfolio) do |entry, new_portfolio| + trade = entry.account_trade + + price = trade.price + prior_qty = prior_portfolio.dig(trade.security.isin, :qty) || 0 + new_qty = prior_qty + trade.qty + + new_portfolio[trade.security.isin] = { + qty: new_qty, + price: price, + amount: new_qty * price, + security_id: trade.security_id + } + end + end + + def upsert_holdings(holdings) + current_time = Time.now + holdings_to_upsert = holdings.map do |holding| + holding.attributes + .slice("date", "currency", "qty", "price", "amount", "security_id") + .merge("updated_at" => current_time) + end + + account.holdings.upsert_all(holdings_to_upsert, unique_by: %i[account_id security_id date currency]) + end + + def load_prior_portfolio + prior_day_holdings = account.holdings.where(date: sync_date_range.begin - 1.day) + + prior_day_holdings.each do |holding| + @portfolio[holding.security.isin] = { + qty: holding.qty, + price: holding.price, + amount: holding.amount, + security_id: holding.security_id + } + end + end + + def calculate_sync_start_date(start_date) + start_date || account.entries.account_trades.order(:date).first.try(:date) || Date.current + end +end diff --git a/app/models/account/sync.rb b/app/models/account/sync.rb index 90e18ebc..0f8a1c9a 100644 --- a/app/models/account/sync.rb +++ b/app/models/account/sync.rb @@ -17,6 +17,7 @@ class Account::Sync < ApplicationRecord start! sync_balances + sync_holdings complete! rescue StandardError => error @@ -33,6 +34,14 @@ class Account::Sync < ApplicationRecord append_warnings(syncer.warnings) end + def sync_holdings + syncer = Account::Holding::Syncer.new(account, start_date: start_date) + + syncer.run + + append_warnings(syncer.warnings) + end + def append_warnings(new_warnings) update! warnings: warnings + new_warnings end diff --git a/app/models/account/trade.rb b/app/models/account/trade.rb new file mode 100644 index 00000000..35cafe19 --- /dev/null +++ b/app/models/account/trade.rb @@ -0,0 +1,26 @@ +class Account::Trade < ApplicationRecord + include Account::Entryable + + belongs_to :security + + validates :qty, presence: true, numericality: { other_than: 0 } + validates :price, presence: true + + class << self + def search(_params) + all + end + + def requires_search?(_params) + false + end + end + + def sell? + qty < 0 + end + + def buy? + qty > 0 + end +end diff --git a/app/models/demo/generator.rb b/app/models/demo/generator.rb index 5a1c1a9e..9df6bed4 100644 --- a/app/models/demo/generator.rb +++ b/app/models/demo/generator.rb @@ -13,31 +13,33 @@ class Demo::Generator end def reset_data! - clear_data! - create_user! + Family.transaction do + clear_data! + create_user! - puts "user reset" + puts "user reset" - create_tags! - create_categories! - create_merchants! + create_tags! + create_categories! + create_merchants! - puts "tags, categories, merchants created" + puts "tags, categories, merchants created" - create_credit_card_account! - create_checking_account! - create_savings_account! + create_credit_card_account! + create_checking_account! + create_savings_account! - create_investment_account! - create_house_and_mortgage! - create_car_and_loan! + create_investment_account! + create_house_and_mortgage! + create_car_and_loan! - puts "accounts created" + puts "accounts created" - family.sync + family.sync - puts "balances synced" - puts "Demo data loaded successfully!" + puts "balances synced" + puts "Demo data loaded successfully!" + end end private @@ -55,6 +57,8 @@ class Demo::Generator def clear_data! ExchangeRate.destroy_all + Security.destroy_all + Security::Price.destroy_all end def create_user! @@ -161,16 +165,52 @@ class Demo::Generator end end + def load_securities! + 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 } + ] + + securities.each do |security_attributes| + security = Security.create! security_attributes.except(:reference_price) + + # Load prices for last 2 years + (730.days.ago.to_date..Date.current).each do |date| + reference = security_attributes[:reference_price] + low_price = reference - 20 + high_price = reference + 20 + Security::Price.create! \ + isin: security.isin, + date: date, + price: Faker::Number.positive(from: low_price, to: high_price) + end + end + end + def create_investment_account! + load_securities! + account = family.accounts.create! \ accountable: Investment.new, name: "Robinhood", balance: 100000, institution: family.institutions.find_or_create_by(name: "Robinhood") - create_valuation!(account, 2.years.ago.to_date, 60000) - create_valuation!(account, 1.year.ago.to_date, 70000) - create_valuation!(account, 3.months.ago.to_date, 92000) + 15.times do + date = Faker::Number.positive(to: 730).days.ago.to_date + security = securities.sample + qty = Faker::Number.between(from: -10, to: 10) + price = Security::Price.find_by!(isin: security.isin, date: date).price + name_prefix = qty < 0 ? "Sell " : "Buy " + + account.entries.create! \ + date: date, + amount: qty * price, + currency: "USD", + name: name_prefix + "#{qty} shares of #{security.symbol}", + entryable: Account::Trade.new(qty: qty, price: price, security: security) + end end def create_house_and_mortgage! @@ -262,6 +302,10 @@ class Demo::Generator tag_from_merchant || tags.find { |t| t.name == "Demo Tag" } end + def securities + @securities ||= Security.all.to_a + end + def merchants @merchants ||= family.merchants end diff --git a/app/models/security.rb b/app/models/security.rb new file mode 100644 index 00000000..106925bf --- /dev/null +++ b/app/models/security.rb @@ -0,0 +1,14 @@ +class Security < ApplicationRecord + before_save :normalize_identifiers + + has_many :trades, dependent: :nullify, class_name: "Account::Trade" + + validates :isin, presence: true, uniqueness: { case_sensitive: false } + + private + + def normalize_identifiers + self.isin = isin.upcase + self.symbol = symbol.upcase + end +end diff --git a/app/models/security/price.rb b/app/models/security/price.rb new file mode 100644 index 00000000..aebbe237 --- /dev/null +++ b/app/models/security/price.rb @@ -0,0 +1,2 @@ +class Security::Price < ApplicationRecord +end diff --git a/db/migrate/20240710182529_create_securities.rb b/db/migrate/20240710182529_create_securities.rb new file mode 100644 index 00000000..5076b223 --- /dev/null +++ b/db/migrate/20240710182529_create_securities.rb @@ -0,0 +1,11 @@ +class CreateSecurities < ActiveRecord::Migration[7.2] + def change + create_table :securities, id: :uuid do |t| + t.string :isin, null: false + t.string :symbol + t.string :name + + t.timestamps + end + end +end diff --git a/db/migrate/20240710182728_create_security_prices.rb b/db/migrate/20240710182728_create_security_prices.rb new file mode 100644 index 00000000..1069d154 --- /dev/null +++ b/db/migrate/20240710182728_create_security_prices.rb @@ -0,0 +1,12 @@ +class CreateSecurityPrices < ActiveRecord::Migration[7.2] + def change + create_table :security_prices, id: :uuid do |t| + t.string :isin + t.date :date + t.decimal :price, precision: 19, scale: 4 + t.string :currency, default: "USD" + + t.timestamps + end + end +end diff --git a/db/migrate/20240710184048_create_account_trades.rb b/db/migrate/20240710184048_create_account_trades.rb new file mode 100644 index 00000000..c4b78f85 --- /dev/null +++ b/db/migrate/20240710184048_create_account_trades.rb @@ -0,0 +1,11 @@ +class CreateAccountTrades < ActiveRecord::Migration[7.2] + def change + create_table :account_trades, id: :uuid do |t| + t.references :security, null: false, foreign_key: true, type: :uuid + t.decimal :qty, precision: 19, scale: 4 + t.decimal :price, precision: 19, scale: 4 + + t.timestamps + end + end +end diff --git a/db/migrate/20240710184249_create_account_holdings.rb b/db/migrate/20240710184249_create_account_holdings.rb new file mode 100644 index 00000000..33ccb4ac --- /dev/null +++ b/db/migrate/20240710184249_create_account_holdings.rb @@ -0,0 +1,17 @@ +class CreateAccountHoldings < ActiveRecord::Migration[7.2] + def change + create_table :account_holdings, id: :uuid do |t| + t.references :account, null: false, foreign_key: true, type: :uuid + t.references :security, null: false, foreign_key: true, type: :uuid + t.date :date + t.decimal :qty, precision: 19, scale: 4 + t.decimal :price, precision: 19, scale: 4 + t.decimal :amount, precision: 19, scale: 4 + t.string :currency + + t.timestamps + end + + add_index :account_holdings, %i[account_id security_id date currency], unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 91cbbdbd..eebb6a59 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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_09_152243) do +ActiveRecord::Schema[7.2].define(version: 2024_07_10_184249) do # These are extensions that must be enabled in order to support this database enable_extension "pgcrypto" enable_extension "plpgsql" @@ -48,6 +48,21 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do t.index ["transfer_id"], name: "index_account_entries_on_transfer_id" end + create_table "account_holdings", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "account_id", null: false + t.uuid "security_id", null: false + t.date "date" + t.decimal "qty", precision: 19, scale: 4 + t.decimal "price", precision: 19, scale: 4 + t.decimal "amount", precision: 19, scale: 4 + t.string "currency" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["account_id", "security_id", "date", "currency"], name: "idx_on_account_id_security_id_date_currency_234024c8e3", unique: true + t.index ["account_id"], name: "index_account_holdings_on_account_id" + t.index ["security_id"], name: "index_account_holdings_on_security_id" + end + create_table "account_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.uuid "account_id", null: false t.string "status", default: "pending", null: false @@ -60,6 +75,15 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do t.index ["account_id"], name: "index_account_syncs_on_account_id" end + create_table "account_trades", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.uuid "security_id", null: false + t.decimal "qty", precision: 19, scale: 4 + t.decimal "price", precision: 19, scale: 4 + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["security_id"], name: "index_account_trades_on_security_id" + end + create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| t.datetime "created_at", null: false t.datetime "updated_at", null: false @@ -321,6 +345,23 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do t.datetime "updated_at", null: false end + create_table "securities", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.string "isin", null: false + t.string "symbol" + 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.date "date" + t.decimal "price", precision: 19, scale: 4 + t.string "currency", default: "USD" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + end + create_table "settings", force: :cascade do |t| t.string "var", null: false t.text "value" @@ -373,7 +414,10 @@ ActiveRecord::Schema[7.2].define(version: 2024_07_09_152243) do add_foreign_key "account_balances", "accounts", on_delete: :cascade add_foreign_key "account_entries", "account_transfers", column: "transfer_id" add_foreign_key "account_entries", "accounts" + add_foreign_key "account_holdings", "accounts" + add_foreign_key "account_holdings", "securities" add_foreign_key "account_syncs", "accounts" + add_foreign_key "account_trades", "securities" add_foreign_key "account_transactions", "categories", on_delete: :nullify add_foreign_key "account_transactions", "merchants" add_foreign_key "accounts", "families" diff --git a/test/fixtures/account/entries.yml b/test/fixtures/account/entries.yml index 315867aa..680710be 100644 --- a/test/fixtures/account/entries.yml +++ b/test/fixtures/account/entries.yml @@ -7,6 +7,15 @@ valuation: entryable_type: Account::Valuation entryable: one +trade: + name: Purchase 10 shares of AAPL + date: <%= 1.day.ago.to_date %> + amount: 2140 # 10 shares * $214 per share + currency: USD + account: investment + entryable_type: Account::Trade + entryable: one + transaction: name: Starbucks date: <%= 1.day.ago.to_date %> diff --git a/test/fixtures/account/holdings.yml b/test/fixtures/account/holdings.yml new file mode 100644 index 00000000..fabc6453 --- /dev/null +++ b/test/fixtures/account/holdings.yml @@ -0,0 +1,15 @@ +one: + account: investment + security: aapl + date: <%= Date.current %> + qty: 10 + amount: 2150 # 10 * $215 + currency: USD + +two: + account: investment + security: aapl + date: <%= 1.day.ago.to_date %> + qty: 10 + amount: 2140 # 10 * $214 + currency: USD diff --git a/test/fixtures/account/trades.yml b/test/fixtures/account/trades.yml new file mode 100644 index 00000000..b782ec63 --- /dev/null +++ b/test/fixtures/account/trades.yml @@ -0,0 +1,4 @@ +one: + security: aapl + qty: 10 + price: 214 diff --git a/test/fixtures/securities.yml b/test/fixtures/securities.yml new file mode 100644 index 00000000..790b4631 --- /dev/null +++ b/test/fixtures/securities.yml @@ -0,0 +1,9 @@ +aapl: + isin: US0378331005 + symbol: aapl + name: Apple + +msft: + isin: US5949181045 + symbol: msft + name: Microsoft diff --git a/test/fixtures/security/prices.yml b/test/fixtures/security/prices.yml new file mode 100644 index 00000000..b78fe85a --- /dev/null +++ b/test/fixtures/security/prices.yml @@ -0,0 +1,11 @@ +one: + isin: US0378331005 # AAPL + date: <%= Date.current %> + price: 215 + currency: USD + +two: + isin: US0378331005 # AAPL + date: <%= 1.day.ago.to_date %> + price: 214 + currency: USD diff --git a/test/models/account/balance/syncer_test.rb b/test/models/account/balance/syncer_test.rb index 6d1e3b10..4f548168 100644 --- a/test/models/account/balance/syncer_test.rb +++ b/test/models/account/balance/syncer_test.rb @@ -5,13 +5,13 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase setup do @account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new) + @investment_account = families(:empty).accounts.create!(name: "Test Investment", balance: 50000, currency: "USD", accountable: Investment.new) end test "syncs account with no entries" do assert_equal 0, @account.balances.count - syncer = Account::Balance::Syncer.new(@account) - syncer.run + run_sync_for @account assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) end @@ -19,8 +19,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase test "syncs account with valuations only" do create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000) - syncer = Account::Balance::Syncer.new(@account) - syncer.run + run_sync_for @account assert_equal 22000, @account.balance assert_equal [ 22000, 22000, 22000 ], @account.balances.chronological.map(&:balance) @@ -30,21 +29,28 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase create_transaction(account: @account, date: 4.days.ago.to_date, amount: 100) create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500) - syncer = Account::Balance::Syncer.new(@account) - syncer.run + run_sync_for @account assert_equal 20000, @account.balance assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance) end + test "syncs account with trades only" do + aapl = securities(:aapl) + create_trade(account: @investment_account, date: 1.day.ago.to_date, security: aapl, qty: 10, price: 200) + + run_sync_for @investment_account + + assert_equal [ 52000, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance) + end + test "syncs account with valuations and transactions" do create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000) create_transaction(account: @account, date: 3.days.ago.to_date, amount: -500) create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100) create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000) - syncer = Account::Balance::Syncer.new(@account) - syncer.run + run_sync_for(@account) assert_equal 25000, @account.balance assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @account.balances.chronological.map(&:balance) @@ -57,8 +63,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase create_transaction(account: @account, date: 2.days.ago.to_date, amount: 300, currency: "USD") create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600 - syncer = Account::Balance::Syncer.new(@account) - syncer.run + run_sync_for(@account) assert_equal 20000, @account.balance assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @account.balances.chronological.map(&:balance) @@ -73,8 +78,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase create_exchange_rate(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2) create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2) - syncer = Account::Balance::Syncer.new(@account) - syncer.run + run_sync_for(@account) usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance) eur_balances = @account.balances.where(currency: "EUR").chronological.map(&:balance) @@ -113,8 +117,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase assert_equal 2, @account.balances.size - syncer = Account::Balance::Syncer.new(@account) - syncer.run + run_sync_for(@account) assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance) end @@ -124,14 +127,18 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase transaction = create_transaction(account: @account, date: 1.day.ago.to_date, amount: 100, currency: "USD") - syncer = Account::Balance::Syncer.new(@account, start_date: 1.day.ago.to_date) - syncer.run + run_sync_for(@account, start_date: 1.day.ago.to_date) assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance) end private + def run_sync_for(account, start_date: nil) + syncer = Account::Balance::Syncer.new(account, start_date: start_date) + syncer.run + end + def create_exchange_rate(date, from:, to:, rate:) ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate end diff --git a/test/models/account/entry_test.rb b/test/models/account/entry_test.rb index 3e160524..71a49bc9 100644 --- a/test/models/account/entry_test.rb +++ b/test/models/account/entry_test.rb @@ -12,6 +12,7 @@ class Account::EntryTest < ActiveSupport::TestCase new_valuation = Account::Entry.new \ entryable: Account::Valuation.new, + account: existing_valuation.account, date: existing_valuation.date, # invalid currency: existing_valuation.currency, amount: existing_valuation.amount @@ -92,4 +93,20 @@ class Account::EntryTest < ActiveSupport::TestCase assert create_transaction(amount: -10).inflow? assert create_transaction(amount: 10).outflow? end + + test "cannot sell more shares of stock than owned" do + account = families(:empty).accounts.create! name: "Test", balance: 0, accountable: Investment.new + security = securities(:aapl) + + error = assert_raises ActiveRecord::RecordInvalid do + account.entries.create! \ + date: Date.current, + amount: 100, + currency: "USD", + name: "Sell 10 shares of AMZN", + 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 + end end diff --git a/test/models/account/holding/syncer_test.rb b/test/models/account/holding/syncer_test.rb new file mode 100644 index 00000000..8a84295b --- /dev/null +++ b/test/models/account/holding/syncer_test.rb @@ -0,0 +1,101 @@ +require "test_helper" + +class Account::Holding::SyncerTest < ActiveSupport::TestCase + include Account::EntriesTestHelper + + setup do + @account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new) + end + + test "account with no trades has no holdings" do + run_sync_for(@account) + + assert_equal [], @account.holdings + end + + test "can buy and sell securities" do + security1 = create_security("AMZN", prices: [ + { date: 2.days.ago.to_date, price: 214 }, + { date: 1.day.ago.to_date, price: 215 }, + { date: Date.current, price: 216 } + ]) + + security2 = create_security("NVDA", prices: [ + { date: 1.day.ago.to_date, price: 122 }, + { date: Date.current, price: 124 } + ]) + + create_trade(security1, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN + + create_trade(security1, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN + create_trade(security2, qty: 20, date: 1.day.ago.to_date) # buy 20 shares of NVDA + + create_trade(security1, 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 } + ] + + run_sync_for(@account) + + assert_holdings(expected) + end + + private + + 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] } + date = expected_holding[:date] + expected_price = expected_holding[:price] + expected_qty = expected_holding[:qty] + expected_amount = expected_holding[:amount] + symbol = expected_holding[:symbol] + + 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}" + end + end + + def create_security(symbol, prices:) + isin_codes = { + "AMZN" => "US0231351067", + "NVDA" => "US67066G1040" + } + + isin = isin_codes[symbol] + + prices.each do |price| + Security::Price.create! isin: isin, date: price[:date], price: price[:price] + end + + Security.create! isin: isin, symbol: symbol + end + + def create_trade(security, qty:, date:) + price = Security::Price.find_by!(isin: security.isin, date: date).price + + trade = Account::Trade.new \ + qty: qty, + security: security, + price: price + + @account.entries.create! \ + name: "Trade", + date: date, + amount: qty * price, + currency: "USD", + entryable: trade + end + + def run_sync_for(account) + Account::Holding::Syncer.new(account).run + end +end diff --git a/test/models/account/holding_test.rb b/test/models/account/holding_test.rb new file mode 100644 index 00000000..ebc93ee6 --- /dev/null +++ b/test/models/account/holding_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Account::HoldingTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/account/sync_test.rb b/test/models/account/sync_test.rb index 335dabe9..564889ce 100644 --- a/test/models/account/sync_test.rb +++ b/test/models/account/sync_test.rb @@ -5,13 +5,20 @@ class Account::SyncTest < ActiveSupport::TestCase @account = accounts(:depository) @sync = Account::Sync.for(@account) + @balance_syncer = mock("Account::Balance::Syncer") - Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once + @holding_syncer = mock("Account::Holding::Syncer") end test "runs sync" do + Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once + Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).once + @balance_syncer.expects(:run).once - @balance_syncer.expects(:warnings).returns([ "test sync warning" ]).once + @balance_syncer.expects(:warnings).returns([ "test balance sync warning" ]).once + + @holding_syncer.expects(:run).once + @holding_syncer.expects(:warnings).returns([ "test holding sync warning" ]).once assert_equal "pending", @sync.status assert_equal [], @sync.warnings @@ -20,11 +27,14 @@ class Account::SyncTest < ActiveSupport::TestCase @sync.run assert_equal "completed", @sync.status - assert_equal [ "test sync warning" ], @sync.warnings + assert_equal [ "test balance sync warning", "test holding sync warning" ], @sync.warnings assert @sync.last_ran_at end test "handles sync errors" do + Account::Balance::Syncer.expects(:new).with(@account, start_date: nil).returns(@balance_syncer).once + Account::Holding::Syncer.expects(:new).with(@account, start_date: nil).returns(@holding_syncer).never # error from balance sync halts entire sync + @balance_syncer.expects(:run).raises(StandardError.new("test sync error")) @sync.run diff --git a/test/models/account/trade_test.rb b/test/models/account/trade_test.rb new file mode 100644 index 00000000..b571a70b --- /dev/null +++ b/test/models/account/trade_test.rb @@ -0,0 +1,4 @@ +require "test_helper" + +class Account::TradeTest < ActiveSupport::TestCase +end diff --git a/test/models/account_test.rb b/test/models/account_test.rb index b0c45a7d..ddafdc78 100644 --- a/test/models/account_test.rb +++ b/test/models/account_test.rb @@ -74,4 +74,13 @@ class AccountTest < ActiveSupport::TestCase test "generates empty series if no balances and no exchange rate" do assert_equal 0, @account.series(currency: "NZD").values.count end + + test "calculates shares owned of holding for date" do + account = accounts(:investment) + security = securities(:aapl) + + assert_equal 10, account.holding_qty(security, date: Date.current) + assert_equal 10, account.holding_qty(security, date: 1.day.ago.to_date) + assert_equal 0, account.holding_qty(security, date: 2.days.ago.to_date) + end end diff --git a/test/models/security/price_test.rb b/test/models/security/price_test.rb new file mode 100644 index 00000000..a86705dc --- /dev/null +++ b/test/models/security/price_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class Security::PriceTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/models/security_test.rb b/test/models/security_test.rb new file mode 100644 index 00000000..8e82099f --- /dev/null +++ b/test/models/security_test.rb @@ -0,0 +1,7 @@ +require "test_helper" + +class SecurityTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/support/account/entries_test_helper.rb b/test/support/account/entries_test_helper.rb index 4a4dd339..6afc40b1 100644 --- a/test/support/account/entries_test_helper.rb +++ b/test/support/account/entries_test_helper.rb @@ -27,4 +27,13 @@ module Account::EntriesTestHelper Account::Entry.create! entry_defaults.merge(attributes) end + + def create_trade(account:, security:, qty:, price:, date:) + account.entries.create! \ + date: date, + amount: qty * price, + currency: "USD", + name: "Trade", + entryable: Account::Trade.new(qty: qty, price: price, security: security) + end end diff --git a/test/system/transfers_test.rb b/test/system/transfers_test.rb index 702ca81f..228c3ab2 100644 --- a/test/system/transfers_test.rb +++ b/test/system/transfers_test.rb @@ -61,7 +61,7 @@ class TransfersTest < ApplicationSystemTestCase end test "can mark a single transaction as a transfer" do - txn = @user.family.entries.reverse_chronological.first + txn = @user.family.entries.account_transactions.reverse_chronological.first within "#" + dom_id(txn) do assert_text txn.account_transaction.category.name || "Uncategorized"