mirror of
https://github.com/maybe-finance/maybe.git
synced 2025-08-02 20:15:22 +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 :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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
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!
|
||||
|
||||
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
|
||||
|
|
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
|
||||
|
||||
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
|
||||
|
|
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
|
Loading…
Add table
Add a link
Reference in a new issue