1
0
Fork 0
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:
Zach Gollwitzer 2024-07-16 09:26:49 -04:00 committed by GitHub
parent d0bc959bee
commit 47523f64c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 591 additions and 56 deletions

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,6 @@
class Account::Holding < ApplicationRecord
belongs_to :account
belongs_to :security
scope :chronological, -> { order(:date) }
end

View 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

View file

@ -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

View 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