1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-07-19 13:19:39 +02:00

Basic Portfolio Views (#1000)

* Add holdings tab to account view

* Basic portfolio UI

* Cleanup

* Handle missing holding data

* Remove synced at (implemented in separate pr)

* translations

* Tweak post sync streams

* Remove stale methods from merge conflict
This commit is contained in:
Zach Gollwitzer 2024-07-25 16:46:04 -04:00 committed by GitHub
parent ef4be7948a
commit 7c2091b343
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
37 changed files with 582 additions and 86 deletions

View file

@ -12,6 +12,10 @@ class Account::EntriesController < ApplicationController
@valuation_entries = @account.entries.account_valuations.reverse_chronological
end
def trades
@trades = @account.entries.account_trades.reverse_chronological
end
def new
@entry = @account.entries.build.tap do |entry|
if params[:entryable_type]

View file

@ -0,0 +1,23 @@
class Account::HoldingsController < ApplicationController
layout "with_sidebar"
before_action :set_account
before_action :set_holding, only: :show
def index
@holdings = @account.holdings.current
end
def show
end
private
def set_account
@account = Current.family.accounts.find(params[:account_id])
end
def set_holding
@holding = @account.holdings.current.find(params[:id])
end
end

View file

@ -33,7 +33,7 @@ module Account::EntriesHelper
private
def permitted_entryable_key(entry)
permitted_entryable_paths = %w[transaction valuation]
permitted_entryable_paths = %w[transaction valuation trade]
entry.entryable_name_short.presence_in(permitted_entryable_paths)
end
end

View file

@ -23,18 +23,37 @@ module AccountsHelper
class_mapping(accountable_type)[:hex]
end
def account_tabs(account)
holdings_tab = { key: "holdings", label: t("accounts.show.holdings"), path: account_path(account, tab: "holdings"), content_path: account_holdings_path(account) }
value_tab = { key: "valuations", label: t("accounts.show.value"), path: account_path(account, tab: "valuations"), content_path: valuation_account_entries_path(account) }
transactions_tab = { key: "transactions", label: t("accounts.show.transactions"), path: account_path(account, tab: "transactions"), content_path: transaction_account_entries_path(account) }
trades_tab = { key: "trades", label: t("accounts.show.trades"), path: account_path(account, tab: "trades"), content_path: trade_account_entries_path(account) }
return [ holdings_tab, trades_tab ] if account.investment?
[ value_tab, transactions_tab ]
end
def selected_account_tab(account)
available_tabs = account_tabs(account)
tab = available_tabs.find { |tab| tab[:key] == params[:tab] }
tab || available_tabs.first
end
private
def class_mapping(accountable_type)
{
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
end
def class_mapping(accountable_type)
{
"CreditCard" => { text: "text-red-500", bg: "bg-red-500", bg_transparent: "bg-red-500/10", fill: "fill-red-500", hex: "#F13636" },
"Loan" => { text: "text-fuchsia-500", bg: "bg-fuchsia-500", bg_transparent: "bg-fuchsia-500/10", fill: "fill-fuchsia-500", hex: "#D444F1" },
"OtherLiability" => { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" },
"Depository" => { text: "text-violet-500", bg: "bg-violet-500", bg_transparent: "bg-violet-500/10", fill: "fill-violet-500", hex: "#875BF7" },
"Investment" => { text: "text-blue-600", bg: "bg-blue-600", bg_transparent: "bg-blue-600/10", fill: "fill-blue-600", hex: "#1570EF" },
"OtherAsset" => { text: "text-green-500", bg: "bg-green-500", bg_transparent: "bg-green-500/10", fill: "fill-green-500", hex: "#12B76A" },
"Property" => { text: "text-cyan-500", bg: "bg-cyan-500", bg_transparent: "bg-cyan-500/10", fill: "fill-cyan-500", hex: "#06AED4" },
"Vehicle" => { text: "text-pink-500", bg: "bg-pink-500", bg_transparent: "bg-pink-500/10", fill: "fill-pink-500", hex: "#F23E94" }
}.fetch(accountable_type, { text: "text-gray-500", bg: "bg-gray-500", bg_transparent: "bg-gray-500/10", fill: "fill-gray-500", hex: "#737373" })
end
end

View file

@ -17,6 +17,10 @@ module ApplicationHelper
turbo_stream_from [ Current.family, :notifications ] if Current.family
end
def family_stream
turbo_stream_from Current.family if Current.family
end
def render_flash_notifications
notifications = flash.flat_map do |type, message_or_messages|
Array(message_or_messages).map do |message|

View file

@ -59,6 +59,7 @@ export default class extends Controller {
deselectAll() {
this.selectedIdsValue = []
this.element.querySelectorAll('input[type="checkbox"]').forEach(el => el.checked = false)
}
selectedIdsValueChanged() {

View file

@ -1,6 +1,46 @@
class Account::Holding < ApplicationRecord
include Monetizable
monetize :amount
belongs_to :account
belongs_to :security
validates :qty, :currency, presence: true
scope :chronological, -> { order(:date) }
scope :current, -> { where(date: Date.current).order(amount: :desc) }
scope :for, ->(security) { where(security_id: security).order(:date) }
delegate :name, to: :security
delegate :symbol, to: :security
def weight
return nil unless amount
portfolio_value = account.holdings.current.where.not(amount: nil).sum(&:amount)
portfolio_value.zero? ? 1 : amount / portfolio_value * 100
end
# Basic approximation of cost-basis
def avg_cost
avg_cost = account.holdings.for(security).where("date <= ?", date).average(:price)
Money.new(avg_cost, currency)
end
def trend
@trend ||= calculate_trend
end
private
def calculate_trend
return nil unless amount_money
start_amount = qty * avg_cost
TimeSeries::Trend.new \
current: amount_money,
previous: start_amount
end
end

View file

@ -38,14 +38,18 @@ class Account::Holding::Syncer
@portfolio = generate_next_portfolio(@portfolio, trades)
@portfolio.map do |isin, holding|
price = Security::Price.find_by!(date: date, isin: isin).price
trade = trades.find { |trade| trade.account_trade.security_id == holding[:security_id] }
trade_price = trade&.account_trade&.price
price = Security::Price.find_by(date: date, isin: isin)&.price || trade_price
account.holdings.build \
date: date,
security_id: holding[:security_id],
qty: holding[:qty],
price: price,
amount: price * holding[:qty]
amount: price ? (price * holding[:qty]) : nil,
currency: holding[:currency]
end
end
@ -61,6 +65,7 @@ class Account::Holding::Syncer
qty: new_qty,
price: price,
amount: new_qty * price,
currency: entry.currency,
security_id: trade.security_id
}
end
@ -85,6 +90,7 @@ class Account::Holding::Syncer
qty: holding.qty,
price: holding.price,
amount: holding.amount,
currency: holding.currency,
security_id: holding.security_id
}
end

View file

@ -78,6 +78,6 @@ class Account::Sync < ApplicationRecord
partial: "shared/notification",
locals: { type: type, message: message }
)
broadcast_refresh_to account
account.family.broadcast_refresh
end
end

View file

@ -165,6 +165,9 @@ class Demo::Generator
end
def load_securities!
# Create an unknown security to simulate edge cases
Security.create! isin: "unknown", symbol: "UNKNOWN", name: "Unknown Demo Stock"
securities = [
{ isin: "US0378331005", symbol: "AAPL", name: "Apple Inc.", reference_price: 210 },
{ isin: "JP3633400001", symbol: "TM", name: "Toyota Motor Corporation", reference_price: 202 },
@ -200,6 +203,10 @@ class Demo::Generator
aapl = Security.find_by(symbol: "AAPL")
tm = Security.find_by(symbol: "TM")
msft = Security.find_by(symbol: "MSFT")
unknown = Security.find_by(symbol: "UNKNOWN")
# Buy 20 shares of the unknown stock to simulate a stock where we can't fetch security prices
account.entries.create! date: 10.days.ago.to_date, amount: 100, currency: "USD", name: "Buy unknown stock", entryable: Account::Trade.new(qty: 20, price: 5, security: unknown)
trades = [
{ security: aapl, qty: 20 }, { security: msft, qty: 10 }, { security: aapl, qty: -5 },
@ -212,7 +219,7 @@ class Demo::Generator
date = Faker::Number.positive(to: 730).days.ago.to_date
security = trade[:security]
qty = trade[:qty]
price = Security::Price.find_by!(isin: security.isin, date: date).price
price = Security::Price.find_by(isin: security.isin, date: date)&.price || 1
name_prefix = qty < 0 ? "Sell " : "Buy "
account.entries.create! \

View file

@ -44,7 +44,7 @@ class TimeSeries::Trend
end
def percent
if previous.nil?
if previous.nil? || (previous.zero? && current.zero?)
0.0
elsif previous.zero?
Float::INFINITY

View file

@ -0,0 +1,15 @@
<div class="fixed bottom-6 z-10 flex items-center justify-between rounded-xl bg-gray-900 px-4 text-sm text-white w-[420px] py-1.5">
<div class="flex items-center gap-2">
<%= check_box_tag "entry_selection", 1, true, class: "maybe-checkbox maybe-checkbox--dark", data: { action: "bulk-select#deselectAll" } %>
<p data-bulk-select-target="selectionBarText"></p>
</div>
<div class="flex items-center gap-1 text-gray-500">
<%= form_with url: bulk_delete_transactions_path, data: { turbo_confirm: true, turbo_frame: "_top" } do %>
<button type="button" data-bulk-select-scope-param="bulk_delete" data-action="bulk-select#submitBulkRequest" class="p-1.5 group hover:bg-gray-700 flex items-center justify-center rounded-md" title="Delete">
<%= lucide_icon "trash-2", class: "w-5 group-hover:text-white" %>
</button>
<% end %>
</div>
</div>

View file

@ -0,0 +1,31 @@
<%# locals: (entry:) %>
<% trade, account = entry.account_trade, entry.account %>
<%= drawer do %>
<div>
<header class="mb-4 space-y-1">
<div class="flex items-center gap-4">
<h3 class="font-medium">
<span class="text-2xl"><%= format_money -entry.amount_money %></span>
<span class="text-lg text-gray-500"><%= entry.currency %></span>
</h3>
</div>
<span class="text-sm text-gray-500"><%= entry.date.strftime("%A %d %B") %></span>
</header>
<div class="space-y-2">
<details class="group space-y-2" open>
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div class="pb-6 pl-4 text-gray-500">
<p>Details coming soon...</p>
</div>
</details>
</div>
</div>
<% end %>

View file

@ -0,0 +1,41 @@
<%# locals: (entry:, selectable: true, **opts) %>
<% trade, account = entry.account_trade, entry.account %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="pr-10 flex items-center gap-4 col-span-6">
<% if selectable %>
<%= check_box_tag dom_id(entry, "selection"),
class: "maybe-checkbox maybe-checkbox--light",
data: { id: entry.id, "bulk-select-target": "row", action: "bulk-select#toggleRowSelection" } %>
<% end %>
<div class="max-w-full">
<%= tag.div class: ["flex items-center gap-2"] do %>
<div class="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-600/5 text-gray-600">
<%= entry.name[0].upcase %>
</div>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to entry.name,
account_entry_path(account, entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
<% end %>
</div>
<% end %>
</div>
</div>
<div class="flex items-center justify-end gap-1 col-span-3">
<%= tag.p trade.buy? ? t(".buy") : t(".sell") %>
</div>
<div class="col-span-3 flex items-center justify-end">
<%= tag.p format_money(entry.amount_money * -1), class: { "text-green-500": trade.sell? } %>
</div>
</div>

View file

@ -0,0 +1,42 @@
<%= turbo_frame_tag dom_id(@account, "trades") do %>
<div id="trades" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".trade") %>" class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex justify-between items-center">
<h3 class="font-medium text-lg"><%= t(".trades") %></h3>
<%= link_to new_account_entry_path(@account),
disabled: true,
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg",
data: { turbo_frame: :modal } do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<span class="text-sm"><%= t(".new") %></span>
<% end %>
</div>
<div class="bg-gray-25 rounded-xl grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-5 py-3">
<div class="pl-0.5 col-span-6 flex items-center gap-4">
<%= check_box_tag "selection_entry",
class: "maybe-checkbox maybe-checkbox--light",
data: { action: "bulk-select#togglePageSelection" } %>
<%= tag.p t(".trade") %>
</div>
<%= tag.p t(".type"), class: "col-span-3 justify-self-end" %>
<%= tag.p t(".amount"), class: "col-span-3 justify-self-end" %>
</div>
<div>
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/entryables/trade/selection_bar" %>
</div>
<% if @trades.empty? %>
<p class="text-gray-500 py-4"><%= t(".no_trades") %></p>
<% else %>
<div class="space-y-6">
<% @trades.group_by(&:date).each do |date, entries| %>
<%= render "entry_group", date:, entries: entries %>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>

View file

@ -12,7 +12,7 @@
<div id="transactions" data-controller="bulk-select" data-bulk-select-resource-value="<%= t(".transaction") %>">
<div hidden id="transaction-selection-bar" data-bulk-select-target="selectionBar">
<%= render "selection_bar" %>
<%= render "account/entries/entryables/transaction/selection_bar" %>
</div>
<% if @transaction_entries.empty? %>

View file

@ -0,0 +1,45 @@
<%# locals: (holding:) %>
<%= turbo_frame_tag dom_id(holding) do %>
<div class="grid grid-cols-12 items-center text-gray-900 text-sm font-medium p-4">
<div class="col-span-4 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name %>
<div>
<%= link_to holding.name, account_holding_path(holding.account, holding), data: { turbo_frame: :drawer }, class: "hover:underline" %>
<%= tag.p holding.symbol, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-2 flex justify-end items-center gap-2">
<% if holding.weight %>
<%= render "shared/progress_circle", progress: holding.weight, text_class: "text-blue-500" %>
<%= tag.p number_to_percentage(holding.weight, precision: 1) %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
<div class="col-span-2 text-right">
<%= tag.p format_money holding.avg_cost %>
<%= tag.p t(".per_share"), class: "font-normal text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
<%= tag.p t(".shares", qty: number_with_precision(holding.qty, precision: 1)), class: "font-normal text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<% if holding.trend %>
<%= tag.p format_money(holding.trend.value), style: "color: #{holding.trend.color};" %>
<%= tag.p "(#{number_to_percentage(holding.trend.percent, precision: 1)})", style: "color: #{holding.trend.color};" %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
</div>
<% end %>

View file

@ -0,0 +1 @@
<div class="h-px bg-alpha-black-50 ml-16 mr-4"></div>

View file

@ -0,0 +1,37 @@
<%= turbo_frame_tag dom_id(@account, "holdings") do %>
<div class="bg-white space-y-4 p-5 border border-alpha-black-25 rounded-xl shadow-xs">
<div class="flex items-center justify-between">
<%= tag.h2 t(".holdings"), class: "font-medium text-lg" %>
<%= link_to new_account_holding_path(@account),
disabled: true,
data: { turbo_frame: :modal },
class: "cursor-not-allowed flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
<%= lucide_icon("plus", class: "w-5 h-5 text-gray-900") %>
<%= tag.span t(".new_holding"), class: "text-sm" %>
<% end %>
</div>
<div class="rounded-xl bg-gray-25 p-1">
<div class="grid grid-cols-12 items-center uppercase text-xs font-medium text-gray-500 px-4 py-2">
<%= tag.p t(".name"), class: "col-span-4" %>
<%= tag.p t(".weight"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".cost"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".holdings"), class: "col-span-2 justify-self-end" %>
<%= tag.p t(".return"), class: "col-span-2 justify-self-end" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<% if @holdings.any? %>
<%= render partial: "account/holdings/holding", collection: @holdings, spacer_template: "ruler" %>
<% elsif @account.needs_sync? || true %>
<div class="flex flex-col justify-center items-center pt-4 pb-8">
<p class="text-gray-500 p-4"><%= t(".needs_sync") %></p>
<%= button_to "Sync holding prices", sync_account_path(@account), class: "bg-gray-900 text-white text-sm rounded-lg px-3 py-2" %>
</div>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>
</div>
</div>
</div>
<% end %>

View file

@ -0,0 +1 @@
<p>Coming soon...</p>

View file

@ -0,0 +1,45 @@
<%= drawer do %>
<div class="space-y-4">
<header class="flex justify-between">
<div>
<%= tag.h3 @holding.name, class: "text-2xl font-medium text-gray-900" %>
<%= tag.p @holding.symbol.upcase, class: "text-sm text-gray-500" %>
</div>
<%= render "shared/circle_logo", name: @holding.name %>
</header>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".overview") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".history") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
<details class="group space-y-2">
<summary class="flex list-none items-center justify-between rounded-xl px-3 py-2 text-xs font-medium uppercase text-gray-500 bg-gray-25 focus-visible:outline-none">
<h4><%= t(".settings") %></h4>
<%= lucide_icon "chevron-down", class: "group-open:transform group-open:rotate-180 text-gray-500 w-5" %>
</summary>
<div>
<p class="pl-4 text-gray-500">Coming soon...</p>
</div>
</details>
</div>
<% end %>

View file

@ -1,6 +1,6 @@
<%= turbo_stream_from @account %>
<div class="space-y-4">
<%= tag.div id: dom_id(@account), class: "space-y-4" do %>
<div class="flex justify-between items-center">
<div class="flex items-center gap-3">
<%= image_tag account_logo_url(@account), class: "w-8 h-8" %>
@ -74,22 +74,17 @@
</div>
</div>
<% selected_tab = params[:tab] || "value" %>
<% selected_tab_key, selected_tab_content_path = selected_account_tab(@account).values_at(:key, :content_path) %>
<div class="flex gap-2 text-sm text-gray-900 font-medium mb-4">
<%= link_to t(".value"), account_path(tab: "value"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "value"] %>
<%= link_to t(".transactions"), account_path(tab: "transactions"), class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab == "transactions"] %>
<% account_tabs(@account).each do |tab| %>
<%= link_to tab[:label], tab[:path], class: ["px-2 py-1.5 rounded-md border border-transparent", "bg-white shadow-xs border-alpha-black-50": selected_tab_key == tab[:key]] %>
<% end %>
</div>
<div class="min-h-[800px]">
<% if selected_tab == "transactions" %>
<%= turbo_frame_tag dom_id(@account, "transactions"), src: transaction_account_entries_path(@account) do %>
<%= render "account/entries/loading" %>
<% end %>
<% else %>
<%= turbo_frame_tag dom_id(@account, "valuations"), src: valuation_account_entries_path(@account) do %>
<%= render "account/entries/loading" %>
<% end %>
<%= turbo_frame_tag dom_id(@account, selected_tab_key), src: selected_tab_content_path do %>
<%= render "account/entries/loading" %>
<% end %>
</div>
</div>
<% end %>

View file

@ -32,6 +32,7 @@
</div>
<%= family_notifications_stream %>
<%= family_stream %>
<%= content_for?(:content) ? yield(:content) : yield %>

View file

@ -1,8 +1,8 @@
<%= turbo_frame_tag "drawer" do %>
<dialog class="bg-white border border-alpha-black-25 rounded-2xl max-h-[calc(100vh-32px)] max-w-[480px] w-full shadow-xs h-full mt-4 mr-4 focus-visible:outline-none" data-controller="modal" data-action="click->modal#clickOutside">
<div class="flex flex-col h-full p-4">
<div class="flex justify-end items-center h-9">
<div data-action="click->modal#close" class="cursor-pointer">
<div class="flex justify-end items-center pb-4">
<div data-action="click->modal#close" class="cursor-pointer p-2">
<%= lucide_icon("x", class: "w-5 h-5 shrink-0") %>
</div>
</div>

View file

@ -11,7 +11,7 @@
<% if @transaction_entries.present? %>
<div hidden id="entry-selection-bar" data-bulk-select-target="selectionBar">
<%= render "account/entries/selection_bar" %>
<%= render "account/entries/entryables/transaction/selection_bar" %>
</div>
<div class="grow overflow-y-auto">
<div class="grid grid-cols-12 bg-gray-25 rounded-xl px-5 py-3 text-xs uppercase font-medium text-gray-500 items-center mb-4">

View file

@ -10,7 +10,18 @@ en:
description: Try adding an entry, editing filters or refining your search
title: No entries found
entryables:
trade:
show:
overview: Overview
trade:
buy: Buy
sell: Sell
transaction:
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will
no longer be included in income or spending calculations.
show:
account_label: Account
account_placeholder: Select an account
@ -57,11 +68,13 @@ en:
value_update: Value update
loading:
loading: Loading entries...
selection_bar:
mark_transfers: Mark as transfers?
mark_transfers_confirm: Mark as transfers
mark_transfers_message: By marking transactions as transfers, they will no
longer be included in income or spending calculations.
trades:
amount: Amount
new: New trade
no_trades: No trades for this account yet.
trade: trade
trades: Trades
type: Type
transactions:
new: New transaction
no_transactions: No transactions for this account yet.

View file

@ -0,0 +1,21 @@
---
en:
account:
holdings:
holding:
per_share: per share
shares: "%{qty} shares"
index:
cost: cost
holdings: Holdings
name: name
needs_sync: Your account needs to sync the latest prices to calculate this
portfolio
new_holding: New holding
no_holdings: No holdings to show.
return: total return
weight: weight
show:
history: History
overview: Overview
settings: Settings

View file

@ -56,12 +56,14 @@ en:
information because you'll need to add it as a new account.</p>"
confirm_title: Delete account?
edit: Edit
holdings: Holdings
import: Import transactions
no_change: No change
sync_message_missing_rates: Since exchange rates haven't been synced, balance
graphs may not reflect accurate values.
sync_message_unknown_error: An error has occurred during the sync.
total_value: Total Value
trades: Trades
transactions: Transactions
value: Value
summary:

View file

@ -78,10 +78,13 @@ Rails.application.routes.draw do
scope module: :account do
resource :logo, only: :show
resources :holdings, only: %i[ index new show ]
resources :entries, except: :index do
collection do
get "transactions", as: :transaction
get "valuations", as: :valuation
get "trades", as: :trade
end
end
end

View file

@ -0,0 +1,20 @@
require "test_helper"
class Account::HoldingsControllerTest < ActionDispatch::IntegrationTest
setup do
sign_in users(:family_admin)
@account = accounts(:investment)
@holding = @account.holdings.current.first
end
test "gets holdings" do
get account_holdings_url(@account)
assert_response :success
end
test "gets holding" do
get account_holding_path(@account, @holding)
assert_response :success
end
end

View file

@ -3,6 +3,7 @@ one:
security: aapl
date: <%= Date.current %>
qty: 10
price: 215
amount: 2150 # 10 * $215
currency: USD
@ -11,5 +12,6 @@ two:
security: aapl
date: <%= 1.day.ago.to_date %>
qty: 10
price: 214
amount: 2140 # 10 * $214
currency: USD

View file

@ -37,11 +37,11 @@ class Account::Balance::SyncerTest < ActiveSupport::TestCase
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)
create_trade(aapl, account: @investment_account, date: 1.day.ago.to_date, qty: 10)
run_sync_for @investment_account
assert_equal [ 52000, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
assert_equal [ 52140, 50000, 50000 ], @investment_account.balances.chronological.map(&:balance)
end
test "syncs account with valuations and transactions" do

View file

@ -1,7 +1,7 @@
require "test_helper"
class Account::Holding::SyncerTest < ActiveSupport::TestCase
include Account::EntriesTestHelper
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
@ -25,12 +25,12 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
{ date: Date.current, price: 124 }
])
create_trade(security1, qty: 10, date: 2.days.ago.to_date) # buy 10 shares of AMZN
create_trade(security1, account: @account, 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, account: @account, qty: 2, date: 1.day.ago.to_date) # buy 2 shares of AMZN
create_trade(security2, account: @account, 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
create_trade(security1, account: @account, qty: -10, date: Date.current) # sell 10 shares of AMZN
expected = [
{ symbol: "AMZN", qty: 10, price: 214, amount: 10 * 214, date: 2.days.ago.to_date },
@ -45,6 +45,27 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
assert_holdings(expected)
end
test "generates all holdings even when missing security prices" do
aapl = create_security("AMZN", prices: [
{ date: 1.day.ago.to_date, price: 215 }
])
create_trade(aapl, account: @account, qty: 10, date: 2.days.ago.to_date, price: 210)
# 2 days ago — no daily price found, but since this is day of entry, we fall back to entry price
# 1 day ago — finds daily price, uses it
# Today — no daily price, no entry, so price and amount are `nil`
expected = [
{ symbol: "AMZN", qty: 10, price: 210, amount: 10 * 210, date: 2.days.ago.to_date },
{ symbol: "AMZN", qty: 10, price: 215, amount: 10 * 215, date: 1.day.ago.to_date },
{ symbol: "AMZN", qty: 10, price: nil, amount: nil, date: Date.current }
]
run_sync_for(@account)
assert_holdings(expected)
end
private
def assert_holdings(expected_holdings)
@ -64,37 +85,6 @@ class Account::Holding::SyncerTest < ActiveSupport::TestCase
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

View file

@ -1,7 +1,71 @@
require "test_helper"
require "ostruct"
class Account::HoldingTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
include Account::EntriesTestHelper, SecuritiesTestHelper
setup do
@account = families(:empty).accounts.create!(name: "Test Brokerage", balance: 20000, currency: "USD", accountable: Investment.new)
# Current day holding instances
@amzn, @nvda = load_holdings
end
test "calculates portfolio weight" do
expected_portfolio_value = 6960.0
expected_amzn_weight = 3240.0 / expected_portfolio_value * 100
expected_nvda_weight = 3720.0 / expected_portfolio_value * 100
assert_in_delta expected_amzn_weight, @amzn.weight, 0.001
assert_in_delta expected_nvda_weight, @nvda.weight, 0.001
end
test "calculates simple average cost basis" do
assert_equal Money.new((212.0 + 216.0) / 2), @amzn.avg_cost
assert_equal Money.new((128.0 + 124.0) / 2), @nvda.avg_cost
end
test "calculates total return trend" do
# Gained $30, or 0.93%
assert_equal Money.new(30), @amzn.trend.value
assert_in_delta 0.9, @amzn.trend.percent, 0.001
# Lost $60, or -1.59%
assert_equal Money.new(-60), @nvda.trend.value
assert_in_delta -1.6, @nvda.trend.percent, 0.001
end
private
def load_holdings
security1 = create_security("AMZN", prices: [
{ date: 1.day.ago.to_date, price: 212.00 },
{ date: Date.current, price: 216.00 }
])
security2 = create_security("NVDA", prices: [
{ date: 1.day.ago.to_date, price: 128.00 },
{ date: Date.current, price: 124.00 }
])
create_holding(security1, 1.day.ago.to_date, 10)
amzn = create_holding(security1, Date.current, 15)
create_holding(security2, 1.day.ago.to_date, 5)
nvda = create_holding(security2, Date.current, 30)
[ amzn, nvda ]
end
def create_holding(security, date, qty)
price = Security::Price.find_by(date: date, isin: security.isin).price
@account.holdings.create! \
date: date,
security: security,
qty: qty,
price: price,
amount: qty * price,
currency: "USD"
end
end

View file

@ -28,12 +28,19 @@ module Account::EntriesTestHelper
Account::Entry.create! entry_defaults.merge(attributes)
end
def create_trade(account:, security:, qty:, price:, date:)
def create_trade(security, account:, qty:, date:, price: nil)
trade_price = price || Security::Price.find_by!(isin: security.isin, date: date).price
trade = Account::Trade.new \
qty: qty,
security: security,
price: trade_price
account.entries.create! \
date: date,
amount: qty * price,
currency: "USD",
name: "Trade",
entryable: Account::Trade.new(qty: qty, price: price, security: security)
date: date,
amount: qty * trade_price,
currency: "USD",
entryable: trade
end
end

View file

@ -0,0 +1,16 @@
module SecuritiesTestHelper
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
end