mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-07-19 13:19:39 +02:00
Investment Portfolio Sync (#974)
* Add investment portfolio models * Add portfolio to demo data * Setup initial tests * Rough sketch of sync logic * Clean up trade sync logic * Add trade validation * Integrate trades into sync process
This commit is contained in:
parent
d0bc959bee
commit
47523f64c2
32 changed files with 591 additions and 56 deletions
|
@ -12,6 +12,8 @@ class Account < ApplicationRecord
|
||||||
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
has_many :entries, dependent: :destroy, class_name: "Account::Entry"
|
||||||
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
has_many :transactions, through: :entries, source: :entryable, source_type: "Account::Transaction"
|
||||||
has_many :valuations, through: :entries, source: :entryable, source_type: "Account::Valuation"
|
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 :balances, dependent: :destroy
|
||||||
has_many :imports, dependent: :destroy
|
has_many :imports, dependent: :destroy
|
||||||
has_many :syncs, dependent: :destroy
|
has_many :syncs, dependent: :destroy
|
||||||
|
@ -107,4 +109,12 @@ class Account < ApplicationRecord
|
||||||
entryable: Account::Valuation.new
|
entryable: Account::Valuation.new
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
@ -27,13 +27,9 @@ class Account::Balance::Syncer
|
||||||
attr_reader :sync_start_date, :account
|
attr_reader :sync_start_date, :account
|
||||||
|
|
||||||
def upsert_balances!(balances)
|
def upsert_balances!(balances)
|
||||||
|
current_time = Time.now
|
||||||
balances_to_upsert = balances.map do |balance|
|
balances_to_upsert = balances.map do |balance|
|
||||||
{
|
balance.attributes.slice("date", "balance", "currency").merge("updated_at" => current_time)
|
||||||
date: balance.date,
|
|
||||||
balance: balance.balance,
|
|
||||||
currency: balance.currency,
|
|
||||||
updated_at: Time.now
|
|
||||||
}
|
|
||||||
end
|
end
|
||||||
|
|
||||||
account.balances.upsert_all(balances_to_upsert, unique_by: %i[account_id date currency])
|
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 valuation.amount if valuation
|
||||||
return derived_sync_start_balance(entries) unless prior_balance
|
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
|
end
|
||||||
|
|
||||||
def calculate_daily_balances
|
def calculate_daily_balances
|
||||||
|
@ -95,19 +91,19 @@ class Account::Balance::Syncer
|
||||||
end
|
end
|
||||||
|
|
||||||
def derived_sync_start_balance(entries)
|
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
|
end
|
||||||
|
|
||||||
def find_prior_balance
|
def find_prior_balance
|
||||||
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
account.balances.where("date < ?", sync_start_date).order(date: :desc).first&.balance
|
||||||
end
|
end
|
||||||
|
|
||||||
def net_transaction_flows(transactions, target_currency = account.currency)
|
def net_entry_flows(entries, target_currency = account.currency)
|
||||||
converted_transaction_amounts = transactions.map { |t| t.amount_money.exchange_to(target_currency, date: t.date) }
|
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
|
account.liability? ? flows * -1 : flows
|
||||||
end
|
end
|
||||||
|
|
|
@ -11,6 +11,7 @@ class Account::Entry < ApplicationRecord
|
||||||
|
|
||||||
validates :date, :amount, :currency, presence: true
|
validates :date, :amount, :currency, presence: true
|
||||||
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
validates :date, uniqueness: { scope: [ :account_id, :entryable_type ] }, if: -> { account_valuation? }
|
||||||
|
validate :trade_valid?, if: -> { account_trade? }
|
||||||
|
|
||||||
scope :chronological, -> { order(:date, :created_at) }
|
scope :chronological, -> { order(:date, :created_at) }
|
||||||
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
scope :reverse_chronological, -> { order(date: :desc, created_at: :desc) }
|
||||||
|
@ -123,7 +124,7 @@ class Account::Entry < ApplicationRecord
|
||||||
|
|
||||||
def income_total(currency = "USD")
|
def income_total(currency = "USD")
|
||||||
without_transfers.account_transactions.includes(:entryable)
|
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) }
|
.map { |e| e.amount_money.exchange_to(currency, date: e.date, fallback_rate: 0) }
|
||||||
.sum
|
.sum
|
||||||
end
|
end
|
||||||
|
@ -191,4 +192,14 @@ class Account::Entry < ApplicationRecord
|
||||||
previous: previous_entry&.amount_money,
|
previous: previous_entry&.amount_money,
|
||||||
favorable_direction: account.favorable_direction
|
favorable_direction: account.favorable_direction
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
module Account::Entryable
|
module Account::Entryable
|
||||||
extend ActiveSupport::Concern
|
extend ActiveSupport::Concern
|
||||||
|
|
||||||
TYPES = %w[ Account::Valuation Account::Transaction ]
|
TYPES = %w[ Account::Valuation Account::Transaction Account::Trade ]
|
||||||
|
|
||||||
def self.from_type(entryable_type)
|
def self.from_type(entryable_type)
|
||||||
entryable_type.presence_in(TYPES).constantize
|
entryable_type.presence_in(TYPES).constantize
|
||||||
|
|
6
app/models/account/holding.rb
Normal file
6
app/models/account/holding.rb
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
class Account::Holding < ApplicationRecord
|
||||||
|
belongs_to :account
|
||||||
|
belongs_to :security
|
||||||
|
|
||||||
|
scope :chronological, -> { order(:date) }
|
||||||
|
end
|
96
app/models/account/holding/syncer.rb
Normal file
96
app/models/account/holding/syncer.rb
Normal file
|
@ -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
|
|
@ -17,6 +17,7 @@ class Account::Sync < ApplicationRecord
|
||||||
start!
|
start!
|
||||||
|
|
||||||
sync_balances
|
sync_balances
|
||||||
|
sync_holdings
|
||||||
|
|
||||||
complete!
|
complete!
|
||||||
rescue StandardError => error
|
rescue StandardError => error
|
||||||
|
@ -33,6 +34,14 @@ class Account::Sync < ApplicationRecord
|
||||||
append_warnings(syncer.warnings)
|
append_warnings(syncer.warnings)
|
||||||
end
|
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)
|
def append_warnings(new_warnings)
|
||||||
update! warnings: warnings + new_warnings
|
update! warnings: warnings + new_warnings
|
||||||
end
|
end
|
||||||
|
|
26
app/models/account/trade.rb
Normal file
26
app/models/account/trade.rb
Normal file
|
@ -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
|
|
@ -13,31 +13,33 @@ class Demo::Generator
|
||||||
end
|
end
|
||||||
|
|
||||||
def reset_data!
|
def reset_data!
|
||||||
clear_data!
|
Family.transaction do
|
||||||
create_user!
|
clear_data!
|
||||||
|
create_user!
|
||||||
|
|
||||||
puts "user reset"
|
puts "user reset"
|
||||||
|
|
||||||
create_tags!
|
create_tags!
|
||||||
create_categories!
|
create_categories!
|
||||||
create_merchants!
|
create_merchants!
|
||||||
|
|
||||||
puts "tags, categories, merchants created"
|
puts "tags, categories, merchants created"
|
||||||
|
|
||||||
create_credit_card_account!
|
create_credit_card_account!
|
||||||
create_checking_account!
|
create_checking_account!
|
||||||
create_savings_account!
|
create_savings_account!
|
||||||
|
|
||||||
create_investment_account!
|
create_investment_account!
|
||||||
create_house_and_mortgage!
|
create_house_and_mortgage!
|
||||||
create_car_and_loan!
|
create_car_and_loan!
|
||||||
|
|
||||||
puts "accounts created"
|
puts "accounts created"
|
||||||
|
|
||||||
family.sync
|
family.sync
|
||||||
|
|
||||||
puts "balances synced"
|
puts "balances synced"
|
||||||
puts "Demo data loaded successfully!"
|
puts "Demo data loaded successfully!"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
@ -55,6 +57,8 @@ class Demo::Generator
|
||||||
|
|
||||||
def clear_data!
|
def clear_data!
|
||||||
ExchangeRate.destroy_all
|
ExchangeRate.destroy_all
|
||||||
|
Security.destroy_all
|
||||||
|
Security::Price.destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
def create_user!
|
def create_user!
|
||||||
|
@ -161,16 +165,52 @@ class Demo::Generator
|
||||||
end
|
end
|
||||||
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!
|
def create_investment_account!
|
||||||
|
load_securities!
|
||||||
|
|
||||||
account = family.accounts.create! \
|
account = family.accounts.create! \
|
||||||
accountable: Investment.new,
|
accountable: Investment.new,
|
||||||
name: "Robinhood",
|
name: "Robinhood",
|
||||||
balance: 100000,
|
balance: 100000,
|
||||||
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
institution: family.institutions.find_or_create_by(name: "Robinhood")
|
||||||
|
|
||||||
create_valuation!(account, 2.years.ago.to_date, 60000)
|
15.times do
|
||||||
create_valuation!(account, 1.year.ago.to_date, 70000)
|
date = Faker::Number.positive(to: 730).days.ago.to_date
|
||||||
create_valuation!(account, 3.months.ago.to_date, 92000)
|
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
|
end
|
||||||
|
|
||||||
def create_house_and_mortgage!
|
def create_house_and_mortgage!
|
||||||
|
@ -262,6 +302,10 @@ class Demo::Generator
|
||||||
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
|
tag_from_merchant || tags.find { |t| t.name == "Demo Tag" }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def securities
|
||||||
|
@securities ||= Security.all.to_a
|
||||||
|
end
|
||||||
|
|
||||||
def merchants
|
def merchants
|
||||||
@merchants ||= family.merchants
|
@merchants ||= family.merchants
|
||||||
end
|
end
|
||||||
|
|
14
app/models/security.rb
Normal file
14
app/models/security.rb
Normal file
|
@ -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
|
2
app/models/security/price.rb
Normal file
2
app/models/security/price.rb
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
class Security::Price < ApplicationRecord
|
||||||
|
end
|
11
db/migrate/20240710182529_create_securities.rb
Normal file
11
db/migrate/20240710182529_create_securities.rb
Normal file
|
@ -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
|
12
db/migrate/20240710182728_create_security_prices.rb
Normal file
12
db/migrate/20240710182728_create_security_prices.rb
Normal file
|
@ -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
|
11
db/migrate/20240710184048_create_account_trades.rb
Normal file
11
db/migrate/20240710184048_create_account_trades.rb
Normal file
|
@ -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
|
17
db/migrate/20240710184249_create_account_holdings.rb
Normal file
17
db/migrate/20240710184249_create_account_holdings.rb
Normal file
|
@ -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
|
46
db/schema.rb
generated
46
db/schema.rb
generated
|
@ -10,7 +10,7 @@
|
||||||
#
|
#
|
||||||
# It's strongly recommended that you check this file into your version control system.
|
# It's strongly recommended that you check this file into your version control system.
|
||||||
|
|
||||||
ActiveRecord::Schema[7.2].define(version: 2024_07_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
|
# These are extensions that must be enabled in order to support this database
|
||||||
enable_extension "pgcrypto"
|
enable_extension "pgcrypto"
|
||||||
enable_extension "plpgsql"
|
enable_extension "plpgsql"
|
||||||
|
@ -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"
|
t.index ["transfer_id"], name: "index_account_entries_on_transfer_id"
|
||||||
end
|
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|
|
create_table "account_syncs", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.uuid "account_id", null: false
|
t.uuid "account_id", null: false
|
||||||
t.string "status", default: "pending", 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"
|
t.index ["account_id"], name: "index_account_syncs_on_account_id"
|
||||||
end
|
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|
|
create_table "account_transactions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.datetime "updated_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
|
t.datetime "updated_at", null: false
|
||||||
end
|
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|
|
create_table "settings", force: :cascade do |t|
|
||||||
t.string "var", null: false
|
t.string "var", null: false
|
||||||
t.text "value"
|
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_balances", "accounts", on_delete: :cascade
|
||||||
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
|
add_foreign_key "account_entries", "account_transfers", column: "transfer_id"
|
||||||
add_foreign_key "account_entries", "accounts"
|
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_syncs", "accounts"
|
||||||
|
add_foreign_key "account_trades", "securities"
|
||||||
add_foreign_key "account_transactions", "categories", on_delete: :nullify
|
add_foreign_key "account_transactions", "categories", on_delete: :nullify
|
||||||
add_foreign_key "account_transactions", "merchants"
|
add_foreign_key "account_transactions", "merchants"
|
||||||
add_foreign_key "accounts", "families"
|
add_foreign_key "accounts", "families"
|
||||||
|
|
9
test/fixtures/account/entries.yml
vendored
9
test/fixtures/account/entries.yml
vendored
|
@ -7,6 +7,15 @@ valuation:
|
||||||
entryable_type: Account::Valuation
|
entryable_type: Account::Valuation
|
||||||
entryable: one
|
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:
|
transaction:
|
||||||
name: Starbucks
|
name: Starbucks
|
||||||
date: <%= 1.day.ago.to_date %>
|
date: <%= 1.day.ago.to_date %>
|
||||||
|
|
15
test/fixtures/account/holdings.yml
vendored
Normal file
15
test/fixtures/account/holdings.yml
vendored
Normal file
|
@ -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
|
4
test/fixtures/account/trades.yml
vendored
Normal file
4
test/fixtures/account/trades.yml
vendored
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
one:
|
||||||
|
security: aapl
|
||||||
|
qty: 10
|
||||||
|
price: 214
|
9
test/fixtures/securities.yml
vendored
Normal file
9
test/fixtures/securities.yml
vendored
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
aapl:
|
||||||
|
isin: US0378331005
|
||||||
|
symbol: aapl
|
||||||
|
name: Apple
|
||||||
|
|
||||||
|
msft:
|
||||||
|
isin: US5949181045
|
||||||
|
symbol: msft
|
||||||
|
name: Microsoft
|
11
test/fixtures/security/prices.yml
vendored
Normal file
11
test/fixtures/security/prices.yml
vendored
Normal file
|
@ -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
|
|
@ -5,13 +5,13 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
@account = families(:empty).accounts.create!(name: "Test", balance: 20000, currency: "USD", accountable: Depository.new)
|
@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
|
end
|
||||||
|
|
||||||
test "syncs account with no entries" do
|
test "syncs account with no entries" do
|
||||||
assert_equal 0, @account.balances.count
|
assert_equal 0, @account.balances.count
|
||||||
|
|
||||||
syncer = Account::Balance::Syncer.new(@account)
|
run_sync_for @account
|
||||||
syncer.run
|
|
||||||
|
|
||||||
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
|
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
|
||||||
end
|
end
|
||||||
|
@ -19,8 +19,7 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
|
||||||
test "syncs account with valuations only" do
|
test "syncs account with valuations only" do
|
||||||
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000)
|
create_valuation(account: @account, date: 2.days.ago.to_date, amount: 22000)
|
||||||
|
|
||||||
syncer = Account::Balance::Syncer.new(@account)
|
run_sync_for @account
|
||||||
syncer.run
|
|
||||||
|
|
||||||
assert_equal 22000, @account.balance
|
assert_equal 22000, @account.balance
|
||||||
assert_equal [ 22000, 22000, 22000 ], @account.balances.chronological.map(&: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: 4.days.ago.to_date, amount: 100)
|
||||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500)
|
create_transaction(account: @account, date: 2.days.ago.to_date, amount: -500)
|
||||||
|
|
||||||
syncer = Account::Balance::Syncer.new(@account)
|
run_sync_for @account
|
||||||
syncer.run
|
|
||||||
|
|
||||||
assert_equal 20000, @account.balance
|
assert_equal 20000, @account.balance
|
||||||
assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance)
|
assert_equal [ 19600, 19500, 19500, 20000, 20000, 20000 ], @account.balances.chronological.map(&:balance)
|
||||||
end
|
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
|
test "syncs account with valuations and transactions" do
|
||||||
create_valuation(account: @account, date: 5.days.ago.to_date, amount: 20000)
|
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: 3.days.ago.to_date, amount: -500)
|
||||||
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100)
|
create_transaction(account: @account, date: 2.days.ago.to_date, amount: 100)
|
||||||
create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000)
|
create_valuation(account: @account, date: 1.day.ago.to_date, amount: 25000)
|
||||||
|
|
||||||
syncer = Account::Balance::Syncer.new(@account)
|
run_sync_for(@account)
|
||||||
syncer.run
|
|
||||||
|
|
||||||
assert_equal 25000, @account.balance
|
assert_equal 25000, @account.balance
|
||||||
assert_equal [ 20000, 20000, 20500, 20400, 25000, 25000 ], @account.balances.chronological.map(&: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: 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
|
create_transaction(account: @account, date: 1.day.ago.to_date, amount: 500, currency: "EUR") # €500 * 1.2 = $600
|
||||||
|
|
||||||
syncer = Account::Balance::Syncer.new(@account)
|
run_sync_for(@account)
|
||||||
syncer.run
|
|
||||||
|
|
||||||
assert_equal 20000, @account.balance
|
assert_equal 20000, @account.balance
|
||||||
assert_equal [ 21000, 20900, 20600, 20000, 20000 ], @account.balances.chronological.map(&: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(1.day.ago.to_date, from: "EUR", to: "USD", rate: 2)
|
||||||
create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2)
|
create_exchange_rate(Date.current, from: "EUR", to: "USD", rate: 2)
|
||||||
|
|
||||||
syncer = Account::Balance::Syncer.new(@account)
|
run_sync_for(@account)
|
||||||
syncer.run
|
|
||||||
|
|
||||||
usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance)
|
usd_balances = @account.balances.where(currency: "USD").chronological.map(&:balance)
|
||||||
eur_balances = @account.balances.where(currency: "EUR").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
|
assert_equal 2, @account.balances.size
|
||||||
|
|
||||||
syncer = Account::Balance::Syncer.new(@account)
|
run_sync_for(@account)
|
||||||
syncer.run
|
|
||||||
|
|
||||||
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
|
assert_equal [ @account.balance ], @account.balances.chronological.map(&:balance)
|
||||||
end
|
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")
|
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)
|
run_sync_for(@account, start_date: 1.day.ago.to_date)
|
||||||
syncer.run
|
|
||||||
|
|
||||||
assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance)
|
assert_equal [ existing_balance.balance, existing_balance.balance - transaction.amount, @account.balance ], @account.balances.chronological.map(&:balance)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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:)
|
def create_exchange_rate(date, from:, to:, rate:)
|
||||||
ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate
|
ExchangeRate.create! date: date, from_currency: from, to_currency: to, rate: rate
|
||||||
end
|
end
|
||||||
|
|
|
@ -12,6 +12,7 @@ class Account::EntryTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
new_valuation = Account::Entry.new \
|
new_valuation = Account::Entry.new \
|
||||||
entryable: Account::Valuation.new,
|
entryable: Account::Valuation.new,
|
||||||
|
account: existing_valuation.account,
|
||||||
date: existing_valuation.date, # invalid
|
date: existing_valuation.date, # invalid
|
||||||
currency: existing_valuation.currency,
|
currency: existing_valuation.currency,
|
||||||
amount: existing_valuation.amount
|
amount: existing_valuation.amount
|
||||||
|
@ -92,4 +93,20 @@ class Account::EntryTest < ActiveSupport::TestCase
|
||||||
assert create_transaction(amount: -10).inflow?
|
assert create_transaction(amount: -10).inflow?
|
||||||
assert create_transaction(amount: 10).outflow?
|
assert create_transaction(amount: 10).outflow?
|
||||||
end
|
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
|
end
|
||||||
|
|
101
test/models/account/holding/syncer_test.rb
Normal file
101
test/models/account/holding/syncer_test.rb
Normal file
|
@ -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
|
7
test/models/account/holding_test.rb
Normal file
7
test/models/account/holding_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::HoldingTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
|
@ -5,13 +5,20 @@ class Account::SyncTest < ActiveSupport::TestCase
|
||||||
@account = accounts(:depository)
|
@account = accounts(:depository)
|
||||||
|
|
||||||
@sync = Account::Sync.for(@account)
|
@sync = Account::Sync.for(@account)
|
||||||
|
|
||||||
@balance_syncer = mock("Account::Balance::Syncer")
|
@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
|
end
|
||||||
|
|
||||||
test "runs sync" do
|
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(: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 "pending", @sync.status
|
||||||
assert_equal [], @sync.warnings
|
assert_equal [], @sync.warnings
|
||||||
|
@ -20,11 +27,14 @@ class Account::SyncTest < ActiveSupport::TestCase
|
||||||
@sync.run
|
@sync.run
|
||||||
|
|
||||||
assert_equal "completed", @sync.status
|
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
|
assert @sync.last_ran_at
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles sync errors" do
|
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"))
|
@balance_syncer.expects(:run).raises(StandardError.new("test sync error"))
|
||||||
|
|
||||||
@sync.run
|
@sync.run
|
||||||
|
|
4
test/models/account/trade_test.rb
Normal file
4
test/models/account/trade_test.rb
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Account::TradeTest < ActiveSupport::TestCase
|
||||||
|
end
|
|
@ -74,4 +74,13 @@ class AccountTest < ActiveSupport::TestCase
|
||||||
test "generates empty series if no balances and no exchange rate" do
|
test "generates empty series if no balances and no exchange rate" do
|
||||||
assert_equal 0, @account.series(currency: "NZD").values.count
|
assert_equal 0, @account.series(currency: "NZD").values.count
|
||||||
end
|
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
|
end
|
||||||
|
|
7
test/models/security/price_test.rb
Normal file
7
test/models/security/price_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class Security::PriceTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
7
test/models/security_test.rb
Normal file
7
test/models/security_test.rb
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
require "test_helper"
|
||||||
|
|
||||||
|
class SecurityTest < ActiveSupport::TestCase
|
||||||
|
# test "the truth" do
|
||||||
|
# assert true
|
||||||
|
# end
|
||||||
|
end
|
|
@ -27,4 +27,13 @@ module Account::EntriesTestHelper
|
||||||
|
|
||||||
Account::Entry.create! entry_defaults.merge(attributes)
|
Account::Entry.create! entry_defaults.merge(attributes)
|
||||||
end
|
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
|
end
|
||||||
|
|
|
@ -61,7 +61,7 @@ class TransfersTest < ApplicationSystemTestCase
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can mark a single transaction as a transfer" do
|
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
|
within "#" + dom_id(txn) do
|
||||||
assert_text txn.account_transaction.category.name || "Uncategorized"
|
assert_text txn.account_transaction.category.name || "Uncategorized"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue