1
0
Fork 0
mirror of https://github.com/maybe-finance/maybe.git synced 2025-08-02 20:15:22 +02:00

Plaid portfolio sync algorithm and calculation improvements (#1526)

* Start tests rework

* Cash balance on schema

* Add reverse syncer

* Reverse balance sync with holdings

* Reverse holdings sync

* Reverse holdings sync should work with only trade entries

* Consolidate brokerage cash

* Add forward sync option

* Update new balance info after syncs

* Intraday balance calculator and sync fixes

* Show only balance for trade entries

* Tests passing

* Update Gemfile.lock

* Cleanup, performance improvements

* Remove account reloads for reliable sync outputs

* Simplify valuation view logic

* Special handling for Plaid cash holding
This commit is contained in:
Zach Gollwitzer 2024-12-10 17:41:20 -05:00 committed by GitHub
parent a59ca5b7c6
commit 49c353e10c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
72 changed files with 1152 additions and 1046 deletions

View file

@ -1,21 +0,0 @@
<%# 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-9 flex items-center gap-4">
<%= render "shared/circle_logo", name: holding.name %>
<div>
<%= tag.p holding.name %>
<%= tag.p holding.ticker, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-3 text-right">
<% if holding.amount_money %>
<%= tag.p format_money holding.amount_money %>
<% else %>
<%= tag.p "?", class: "text-gray-500" %>
<% end %>
</div>
</div>
<% end %>

View file

@ -1,18 +0,0 @@
<%= turbo_frame_tag dom_id(@account, "cash") 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(".cash"), class: "font-medium text-lg" %>
</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-9" %>
<%= tag.p t(".value"), class: "col-span-3 justify-self-end" %>
</div>
<div class="rounded-lg bg-white border-alpha-black-25 shadow-xs">
<%= render partial: "account/cashes/cash", collection: [brokerage_cash(@account)], as: :holding %>
</div>
</div>
</div>
<% end %>

View file

@ -1,5 +1,5 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<%= turbo_frame_tag dom_id(entry) do %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, show_balance: } %>
<%= render partial: entry.entryable.to_partial_path, locals: { entry:, selectable:, balance_trend: } %>
<% end %>

View file

@ -73,13 +73,14 @@
<div>
<div class="rounded-tl-lg rounded-tr-lg bg-white border-alpha-black-25 shadow-xs">
<div class="space-y-4">
<% calculator = Account::BalanceTrendCalculator.for(@entries) %>
<%= entries_by_date(@entries) do |entries| %>
<%= render entries, show_balance: true %>
<% entries.each do |entry| %>
<%= render entry, balance_trend: calculator&.trend_for(entry) %>
<% end %>
<% end %>
</div>
</div>
<div class="p-4 bg-white rounded-bl-lg rounded-br-lg">

View file

@ -0,0 +1,32 @@
<%# locals: (account:) %>
<% currency = Money::Currency.new(account.currency) %>
<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: currency.iso_code %>
<div class="space-y-0.5">
<%= tag.p t(".brokerage_cash"), class: "text-gray-900" %>
<%= tag.p account.currency, class: "text-gray-500 text-xs uppercase" %>
</div>
</div>
<div class="col-span-2 flex justify-end items-center gap-2">
<% cash_weight = account.balance.zero? ? 0 : account.cash_balance / account.balance * 100 %>
<%= render "shared/progress_circle", progress: cash_weight, text_class: "text-blue-500" %>
<%= tag.p number_to_percentage(cash_weight, precision: 1) %>
</div>
<div class="col-span-2 text-right">
<%= tag.p "--", class: "text-gray-500" %>
</div>
<div class="col-span-2 text-right">
<%= tag.p format_money account.cash_balance %>
</div>
<div class="col-span-2 text-right">
<%= tag.p "--", class: "text-gray-500" %>
</div>
</div>

View file

@ -2,7 +2,7 @@
<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_trade_path(@account),
<%= link_to new_account_trade_path(account_id: @account.id),
id: dom_id(@account, "new_trade"),
data: { turbo_frame: :modal },
class: "flex gap-1 font-medium items-center bg-gray-50 text-gray-900 p-2 rounded-lg" do %>
@ -21,8 +21,10 @@
</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" %>
<% if @account.holdings.current.any? %>
<%= render "account/holdings/cash", account: @account %>
<%= render "account/holdings/ruler" %>
<%= render partial: "account/holdings/holding", collection: @account.holdings.current, spacer_template: "ruler" %>
<% else %>
<p class="text-gray-500 text-sm p-4"><%= t(".no_holdings") %></p>
<% end %>

View file

@ -32,7 +32,7 @@
</div>
<% end %>
<%= form.date_field :date, label: true, value: Date.today, required: true %>
<%= form.date_field :date, label: true, value: Date.current, required: true %>
<% unless %w[buy sell].include?(type) %>
<%= form.money_field :amount, label: t(".amount"), required: true %>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% trade, account = entry.account_trade, entry.account %>
@ -37,6 +37,12 @@
</div>
<div class="col-span-2 justify-self-end">
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% if balance_trend&.trend %>
<div class="flex items-center gap-2">
<%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %>
</div>
<% else %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% end %>
</div>
</div>

View file

@ -13,7 +13,7 @@
data: { controller: "auto-submit-form" } do |f| %>
<%= f.date_field :date,
label: t(".date_label"),
max: Date.today,
max: Date.current,
"data-auto-submit-form-target": "auto" %>
<div class="flex items-center gap-2">

View file

@ -28,7 +28,7 @@
<%= f.fields_for :entryable do |ef| %>
<%= ef.collection_select :category_id, Current.family.categories.alphabetically, :id, :name, { prompt: t(".category_prompt"), label: t(".category") } %>
<% end %>
<%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.today, value: Date.today %>
<%= f.date_field :date, label: t(".date"), required: true, min: Account::Entry.min_supported_date, max: Date.current, value: Date.current %>
</section>
<section>

View file

@ -1,4 +1,4 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% transaction, account = entry.account_transaction, entry.account %>
<div class="grid grid-cols-12 items-center <%= entry.excluded ? "text-gray-400 bg-gray-25" : "text-gray-900" %> text-sm font-medium p-4">
@ -34,7 +34,7 @@
</div>
<% if entry.transfer.present? %>
<% unless show_balance %>
<% unless balance_trend %>
<div class="col-span-2"></div>
<% end %>
@ -46,7 +46,7 @@
<%= render "categories/menu", transaction: transaction %>
</div>
<% unless show_balance %>
<% unless balance_trend %>
<%= tag.div class: "col-span-2 overflow-hidden truncate" do %>
<% if entry.new_record? %>
<%= tag.p account.name %>
@ -66,12 +66,12 @@
class: ["text-green-600": entry.inflow?] %>
</div>
<% if show_balance %>
<% if balance_trend %>
<div class="col-span-2 justify-self-end">
<% if entry.account.investment? %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% if balance_trend.trend %>
<%= tag.p format_money(balance_trend.trend.current), class: "font-medium text-sm text-gray-900" %>
<% else %>
<%= tag.p format_money(entry.trend.current), class: "font-medium text-sm text-gray-900" %>
<%= tag.p "--", class: "font-medium text-sm text-gray-400" %>
<% end %>
</div>
<% end %>

View file

@ -29,7 +29,7 @@
<%= f.collection_select :from_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".from") }, required: true %>
<%= f.collection_select :to_account_id, Current.family.accounts.manual.alphabetically, :id, :name, { prompt: t(".select_account"), label: t(".to") }, required: true %>
<%= f.number_field :amount, label: t(".amount"), required: true, min: 0, placeholder: "100", step: 0.00000001 %>
<%= f.date_field :date, value: transfer.date || Date.today, label: t(".date"), required: true, max: Date.current %>
<%= f.date_field :date, value: transfer.date || Date.current, label: t(".date"), required: true, max: Date.current %>
</section>
<section>

View file

@ -8,7 +8,7 @@
<% end %>
<div class="space-y-3">
<%= form.date_field :date, label: true, required: true, value: Date.today, min: Account::Entry.min_supported_date, max: Date.today %>
<%= form.date_field :date, label: true, required: true, value: Date.current, min: Account::Entry.min_supported_date, max: Date.current %>
<%= form.money_field :amount, label: t(".amount"), required: true %>
</div>

View file

@ -1,7 +1,7 @@
<%# locals: (entry:, selectable: true, show_balance: false) %>
<%# locals: (entry:, selectable: true, balance_trend: nil) %>
<% account = entry.account %>
<% valuation = entry.account_valuation %>
<% color = balance_trend&.trend&.color || "#D444F1" %>
<% icon = balance_trend&.trend&.icon || "plus" %>
<div class="p-4 grid grid-cols-12 items-center text-gray-900 text-sm font-medium">
<div class="col-span-8 flex items-center gap-4">
@ -12,15 +12,15 @@
<% end %>
<div class="flex items-center gap-3">
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(valuation.color) do %>
<%= lucide_icon valuation.icon, class: "w-4 h-4" %>
<%= tag.div class: "w-8 h-8 rounded-full p-1.5 flex items-center justify-center", style: mixed_hex_styles(color) do %>
<%= lucide_icon icon, class: "w-4 h-4" %>
<% end %>
<div class="truncate text-gray-900">
<% if entry.new_record? %>
<%= content_tag :p, entry.name %>
<% else %>
<%= link_to valuation.name,
<%= link_to entry.name || t(".balance_update"),
account_entry_path(entry),
data: { turbo_frame: "drawer", turbo_prefetch: false },
class: "hover:underline hover:text-gray-800" %>
@ -29,8 +29,12 @@
</div>
</div>
<div class="col-span-2 justify-self-end font-medium text-sm" style="color: <%= valuation.color %>">
<%= tag.span format_money(entry.trend.value) %>
<div class="col-span-2 justify-self-end font-medium text-sm">
<% if balance_trend&.trend %>
<%= tag.span format_money(balance_trend.trend.value), style: "color: #{balance_trend.trend.color}" %>
<% else %>
<%= tag.span "--", class: "text-gray-400" %>
<% end %>
</div>
<div class="col-span-2 justify-self-end">

View file

@ -11,7 +11,7 @@
<%= tooltip %>
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
<%= tag.p format_money(account.balance_money), class: "text-gray-900 text-3xl font-medium" %>
</div>
<%= form_with url: request.path, method: :get, data: { controller: "auto-submit-form" } do |form| %>

View file

@ -1,5 +0,0 @@
<%# locals: (account:) %>
<%= turbo_frame_tag dom_id(account, :cash), src: account_cashes_path(account_id: account.id) do %>
<%= render "account/entries/loading" %>
<% end %>

View file

@ -1,21 +0,0 @@
<%# locals: (account:, **args) %>
<div id="<%= dom_id(account, :chart) %>" class="bg-white shadow-xs rounded-xl border border-alpha-black-25 rounded-lg">
<div class="p-4 flex justify-between">
<div class="space-y-2">
<div class="flex items-center gap-1">
<%= tag.p t(".value"), class: "text-sm font-medium text-gray-500" %>
</div>
<%= tag.p format_money(account.value), class: "text-gray-900 text-3xl font-medium" %>
</div>
</div>
<div class="relative h-64 flex items-center justify-center">
<%= image_tag "placeholder-graph.svg", class: "w-full h-full object-cover rounded-bl-lg rounded-br-lg opacity-50" %>
<div class="absolute top-0 left-0 w-full h-full flex flex-col items-center justify-center space-y-1">
<p class="text-gray-900 text-sm">Historical investment data coming soon.</p>
<p class="text-gray-500 text-sm">We're working to bring you the full picture.</p>
</div>
</div>
</div>

View file

@ -1,4 +1,4 @@
<%# locals: (value:, cash:) %>
<%# locals: (balance:, holdings:, cash:) %>
<div data-controller="tooltip" data-tooltip-placement-value="right" data-tooltip-offset-value=10 data-tooltip-cross-axis-value=50>
<%= lucide_icon("info", class: "w-4 h-4 shrink-0 text-gray-500") %>
@ -7,14 +7,6 @@
<%= t(".total_value_tooltip") %>
</div>
<div class="flex pt-3">
<div class="text-gray-300">
<%= t(".holdings") %>
</div>
<div class="text-white ml-auto">
<%= tag.p format_money(value, precision: 0) %>
</div>
</div>
<div class="flex">
<div class="text-gray-300">
<%= t(".cash") %>
</div>
@ -22,5 +14,24 @@
<%= tag.p format_money(cash, precision: 0) %>
</div>
</div>
<div class="flex">
<div class="text-gray-300">
<%= t(".holdings") %>
</div>
<div class="text-white ml-auto">
<%= tag.p format_money(holdings, precision: 0) %>
</div>
</div>
<hr class="my-2 border-gray-500">
<div class="flex">
<div class="text-gray-300">
<%= t(".total") %>
</div>
<div class="text-white font-bold ml-auto">
<%= tag.p format_money(balance, precision: 0) %>
</div>
</div>
</div>
</div>

View file

@ -4,24 +4,20 @@
<%= tag.div class: "space-y-4" do %>
<%= render "accounts/show/header", account: @account %>
<% if @account.plaid_account_id.present? %>
<%= render "investments/chart", account: @account %>
<% else %>
<%= render "accounts/show/chart",
<%= render "accounts/show/chart",
account: @account,
title: t(".chart_title"),
tooltip: render(
"investments/value_tooltip",
value: @account.value,
cash: @account.balance_money
balance: @account.balance_money,
holdings: @account.balance - @account.cash_balance,
cash: @account.cash_balance
) %>
<% end %>
<div class="min-h-[800px]">
<%= render "accounts/show/tabs", account: @account, tabs: [
{ key: "activity", contents: render("accounts/show/activity", account: @account) },
{ key: "holdings", contents: render("investments/holdings_tab", account: @account) },
{ key: "cash", contents: render("investments/cash_tab", account: @account) }
{ key: "activity", contents: render("accounts/show/activity", account: @account) },
] %>
</div>
<% end %>

View file

@ -20,6 +20,11 @@
{ label: t(".language") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :timezone,
timezone_options,
{ label: t(".timezone") },
{ data: { auto_submit_form_target: "auto" } } %>
<%= family_form.select :date_format,
date_format_options,
{ label: t(".date_format") },

View file

@ -1,10 +1,31 @@
<%# locals: (progress:, radius: 7, stroke: 2, text_class: "text-green-500") %>
<% circumference = Math::PI * 2 * radius %>
<% progress_percent = progress.clamp(0, 100) %>
<% stroke_dashoffset = ((100 - progress_percent) * circumference) / 100 %>
<%
circumference = Math::PI * 2 * radius
progress_percent = progress.clamp(0, 100)
stroke_dashoffset = ((100 - progress_percent) * circumference) / 100
center = radius + stroke / 2
%>
<svg width="<%= radius * 2 + stroke %>" height="<%= radius * 2 + stroke %>">
<!-- Background Circle -->
<circle class="fill-transparent stroke-current text-gray-300" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" />
<circle
class="fill-transparent stroke-current text-gray-300"
r="<%= radius %>"
cx="<%= center %>"
cy="<%= center %>"
stroke-width="<%= stroke %>"
></circle>
<!-- Foreground Circle -->
<circle class="fill-transparent stroke-current <%= text_class %>" r="<%= radius %>" cx="<%= radius + stroke / 2 %>" cy="<%= radius + stroke / 2 %>" stroke-width="<%= stroke %>" stroke-dasharray="<%= circumference %>" stroke-dashoffset="<%= stroke_dashoffset %>" transform="rotate(-90, <%= radius + stroke / 2 %>, <%= radius + stroke / 2 %>)" />
</svg>
<circle
class="fill-transparent stroke-current <%= text_class %>"
r="<%= radius %>"
cx="<%= center %>"
cy="<%= center %>"
stroke-width="<%= stroke %>"
stroke-dasharray="<%= circumference %>"
stroke-dashoffset="<%= stroke_dashoffset %>"
transform="rotate(-90, <%= center %>, <%= center %>)"
></circle>
</svg>