Skip to content

Commit ad8cc17

Browse files
committed
feat(portfolios): show aggregate value in the footer
Loading multiple portfolios with ``-p`` previously gave per-portfolio ``Total`` rows but no overall figure, so users splitting their holdings across work / personal / crypto files had to add the rows up by hand to answer the obvious "how much do I have right now?" question. The aggregate sits in the footer just left of ``^palette`` because that row is the natural status bar for app-wide state -- it doesn't scroll with the portfolios above it and stays visible no matter how deep the user scrolls into a portfolio table. It is computed in the first portfolio's base currency, with all foreign positions and cash converted through the same forex map ``portfolio_total`` already consumes; if any underlying price or rate is missing the label renders ``N/A`` rather than a misleading partial sum. The label is mounted in single-portfolio mode too (just hidden via a CSS class) so the footer geometry doesn't shift when the user opens a second portfolio, and the cached renderable on the Footer subclass survives Textual's post-``_bindings_ready`` recompose so the value doesn't flicker back to empty between mount and the first refresh. Signed-off-by: Igor Opaniuk <igor.opaniuk@gmail.com>
1 parent a784f14 commit ad8cc17

4 files changed

Lines changed: 291 additions & 1 deletion

File tree

src/stonks_cli/app.py

Lines changed: 105 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
Portfolio,
4343
Position,
4444
WatchlistItem,
45+
combined_portfolio_total,
4546
portfolio_total,
4647
)
4748
from stonks_cli.news_fetcher import NewsFetcher, NewsItem
@@ -408,6 +409,78 @@ def on_click(self) -> None:
408409
self.post_message(self.Selected(self._index))
409410

410411

412+
class _FooterWithTotal(Footer):
413+
"""``Footer`` subclass that injects a ``Total all portfolios`` label
414+
just left of the command-palette indicator.
415+
416+
Multiple ``dock: right`` widgets in Textual all anchor to the same
417+
right edge and overlap, so we can't simply yield a second
418+
``dock: right`` Static after the palette and expect them to stack.
419+
Instead we ``dock: right`` the label and offset it leftwards by
420+
``margin-right`` equal to the rendered palette width plus a small
421+
gap, putting it visually just to the left of ``^palette``.
422+
423+
When ``show_total`` is False the label is mounted with the
424+
``-hidden`` class so the DOM is identical between single- and
425+
multi-portfolio modes; only its ``display`` flips.
426+
427+
Footer triggers a ``recompose`` once its ``_bindings_ready`` reactive
428+
flips, which wipes any children the previous ``compose`` yielded
429+
(including ours). We cache the most recent renderable on the
430+
instance so each recompose re-creates the Static with the value the
431+
app last set via :meth:`set_total_renderable`, rather than flashing
432+
back to empty between recompose and the next price refresh.
433+
"""
434+
435+
# The command-palette FooterKey renders with ``dock: right`` and a
436+
# vertical-key border on the left. Its width is the key glyph + label
437+
# padding -- empirically ~12 cells in the default theme. We clear it
438+
# plus a 2-cell gap so the total doesn't visually collide with the
439+
# palette border.
440+
_PALETTE_GAP = 14
441+
442+
DEFAULT_CSS = f"""
443+
_FooterWithTotal Static#combined-total {{
444+
dock: right;
445+
width: auto;
446+
padding: 0 1;
447+
margin-right: {_PALETTE_GAP};
448+
color: $accent;
449+
text-style: bold;
450+
}}
451+
_FooterWithTotal Static#combined-total.-hidden {{
452+
display: none;
453+
}}
454+
"""
455+
456+
def __init__(self, *, show_total: bool, **kw: Any) -> None:
457+
super().__init__(**kw)
458+
self._show_total = show_total
459+
self._cached_renderable: Text | str = ""
460+
461+
def compose(self) -> ComposeResult:
462+
yield from super().compose()
463+
classes = "" if self._show_total else "-hidden"
464+
yield Static(self._cached_renderable, id="combined-total", classes=classes)
465+
466+
def set_total_renderable(self, renderable: Text | str) -> None:
467+
"""Update the cached label and the live Static, if mounted.
468+
469+
Called by the app each time per-portfolio totals are refreshed.
470+
Caching on the instance means the next recompose (triggered by
471+
``Footer`` itself when its bindings settle, or later if the key
472+
bindings change) re-creates the Static with the latest value.
473+
"""
474+
self._cached_renderable = renderable
475+
try:
476+
self.query_one("#combined-total", Static).update(renderable)
477+
except NoMatches:
478+
# Static isn't mounted yet (or was just removed by an
479+
# in-flight recompose); the cached value will be picked up
480+
# when compose runs again.
481+
pass
482+
483+
411484
class PortfolioApp(ThreadGuardMixin, App[None]):
412485
"""Full-screen portfolio table with periodic price refresh."""
413486

@@ -495,7 +568,14 @@ def compose(self) -> ComposeResult:
495568
yield Static("", id="status")
496569
yield Static("", id="error")
497570
yield NewsFeedWidget(id="news-panel")
498-
yield Footer()
571+
# The aggregate "Total all portfolios" label only renders when more
572+
# than one portfolio is loaded -- in single-portfolio mode it would
573+
# just duplicate the per-portfolio Total row. We always mount it
574+
# so the footer layout doesn't shift; visibility is toggled via the
575+
# ``-hidden`` class on the Static inside.
576+
yield _FooterWithTotal(
577+
show_total=len(self.portfolios) > 1,
578+
)
499579

500580
def on_mount(self) -> None:
501581
self._populate_tables()
@@ -971,6 +1051,30 @@ def _populate_tables(self) -> None:
9711051
else:
9721052
for i, portfolio in enumerate(self.portfolios):
9731053
self._populate_for(i, portfolio)
1054+
self._update_combined_total()
1055+
1056+
def _update_combined_total(self) -> None:
1057+
"""Refresh the aggregate "Total all portfolios" label in the footer.
1058+
1059+
Goes through ``_FooterWithTotal.set_total_renderable`` rather than
1060+
updating the Static directly so the value survives the Footer's
1061+
post-``_bindings_ready`` recompose -- without the cache, the
1062+
label would flash to empty between recompose and the next price
1063+
refresh.
1064+
"""
1065+
try:
1066+
footer = self.query_one(_FooterWithTotal)
1067+
except NoMatches:
1068+
return
1069+
total, base = combined_portfolio_total(
1070+
self.portfolios, self._snap.prices, self._snap.forex_rates
1071+
)
1072+
label = Text(f"Total ({base}) ")
1073+
if total is None:
1074+
renderable: Text = label.append("N/A", style="bold")
1075+
else:
1076+
renderable = label.append(f"{total:,.2f}", style="bold")
1077+
footer.set_total_renderable(renderable)
9741078

9751079
def _populate_single(self) -> None:
9761080
try:

src/stonks_cli/models.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,3 +299,39 @@ def portfolio_total(
299299
return None
300300
total += cash_pos.amount * rate
301301
return total
302+
303+
304+
def combined_portfolio_total(
305+
portfolios: "list[Portfolio]",
306+
prices: dict[str, float],
307+
forex_rates: dict[str, dict[str, float]],
308+
) -> tuple[float | None, str]:
309+
"""Return ``(total, base_currency)`` summed across all *portfolios*.
310+
311+
The aggregate is computed in the *first* portfolio's ``base_currency`` --
312+
every position and cash balance across every portfolio is converted into
313+
that currency using ``forex_rates[base]``. ``base`` is returned alongside
314+
the total so callers can render the label without having to look it up
315+
themselves.
316+
317+
Returns ``(None, base)`` when any underlying portfolio's value cannot be
318+
computed (missing price or forex rate) so the UI can render "N/A" rather
319+
than a misleading partial sum. Returns ``(None, "USD")`` when the list
320+
is empty.
321+
322+
Args:
323+
portfolios: Loaded portfolios to aggregate.
324+
prices: Last prices keyed by symbol (shared across all portfolios).
325+
forex_rates: Nested forex map ``{base_currency: {position_currency: rate}}``.
326+
"""
327+
if not portfolios:
328+
return None, "USD"
329+
base = portfolios[0].base_currency
330+
rates = forex_rates.get(base, {})
331+
total = 0.0
332+
for p in portfolios:
333+
sub = portfolio_total(p, prices, rates)
334+
if sub is None:
335+
return None, base
336+
total += sub
337+
return total, base

tests/test_app.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2590,6 +2590,86 @@ async def test_action_chat_no_op_when_already_open(portfolio: Portfolio) -> None
25902590
mock_push.assert_not_called()
25912591

25922592

2593+
def _static_content(widget: Static) -> str:
2594+
"""Return the plain-text content currently displayed by a Static widget."""
2595+
# ``Static`` stores content in a name-mangled ``__content`` attribute.
2596+
# Going through the name-mangled name is the simplest way to inspect
2597+
# what was last passed to ``widget.update()`` in tests.
2598+
content = getattr(widget, "_Static__content", "")
2599+
return str(content)
2600+
2601+
2602+
@pytest.mark.asyncio
2603+
async def test_combined_total_widget_hidden_in_single_portfolio_mode(
2604+
portfolio: Portfolio,
2605+
) -> None:
2606+
"""The aggregate label is always mounted (so the footer layout stays
2607+
stable across single-/multi-portfolio modes) but is hidden via the
2608+
``-hidden`` class when only one portfolio is loaded -- otherwise it
2609+
would just duplicate the per-portfolio Total row."""
2610+
app = PortfolioApp(portfolios=[portfolio], prices={}, forex_rates=USD_RATES)
2611+
async with app.run_test() as pilot:
2612+
await pilot.pause()
2613+
widget = app.query_one("#combined-total", Static)
2614+
assert "-hidden" in widget.classes
2615+
2616+
multi = PortfolioApp(
2617+
portfolios=[portfolio, Portfolio(name="Second")],
2618+
prices={},
2619+
forex_rates=USD_RATES,
2620+
)
2621+
async with multi.run_test() as pilot:
2622+
await pilot.pause()
2623+
widget = multi.query_one("#combined-total", Static)
2624+
assert "-hidden" not in widget.classes
2625+
2626+
2627+
@pytest.mark.asyncio
2628+
async def test_combined_total_widget_renders_value(portfolio: Portfolio) -> None:
2629+
"""When prices and forex rates are available, the aggregate footer
2630+
must display the summed value across all portfolios in the first
2631+
portfolio's base currency."""
2632+
second = Portfolio(
2633+
name="Second",
2634+
positions=[Position(symbol="MSFT", quantity=10, avg_cost=200.0)],
2635+
)
2636+
app = PortfolioApp(
2637+
portfolios=[portfolio, second],
2638+
prices={"AAPL": 200.0, "NVDA": 150.0, "MSFT": 300.0},
2639+
forex_rates=USD_RATES,
2640+
)
2641+
async with app.run_test() as pilot:
2642+
await pilot.pause()
2643+
widget = app.query_one("#combined-total", Static)
2644+
text = _static_content(widget)
2645+
# portfolio fixture: AAPL 100 @ 200 + NVDA 200 @ 150 = 50_000;
2646+
# second: MSFT 10 @ 300 = 3_000; total 53_000
2647+
assert "Total (USD)" in text
2648+
assert "53,000.00" in text
2649+
2650+
2651+
@pytest.mark.asyncio
2652+
async def test_combined_total_widget_renders_na_on_missing_price(
2653+
portfolio: Portfolio,
2654+
) -> None:
2655+
"""When any price is missing the aggregate must render ``N/A`` rather
2656+
than a misleading partial sum."""
2657+
second = Portfolio(
2658+
name="Second",
2659+
positions=[Position(symbol="MISSING", quantity=10, avg_cost=200.0)],
2660+
)
2661+
app = PortfolioApp(
2662+
portfolios=[portfolio, second],
2663+
prices={"AAPL": 200.0, "NVDA": 150.0}, # MISSING absent
2664+
forex_rates=USD_RATES,
2665+
)
2666+
async with app.run_test() as pilot:
2667+
await pilot.pause()
2668+
widget = app.query_one("#combined-total", Static)
2669+
text = _static_content(widget)
2670+
assert "N/A" in text
2671+
2672+
25932673
@pytest.mark.asyncio
25942674
async def test_history_updated_message_syncs_to_app(portfolio: Portfolio) -> None:
25952675
"""HistoryUpdated message from ChatScreen updates app._chat_history."""

tests/test_models.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
Portfolio,
88
Position,
99
WatchlistItem,
10+
combined_portfolio_total,
1011
daily_change_pct,
1112
portfolio_total,
1213
)
@@ -344,3 +345,72 @@ def test_forex_conversion(self):
344345
prices = {"VOW3": 120.0}
345346
rates = {"EUR": 1.1} # EUR -> USD
346347
assert portfolio_total(p, prices, rates) == pytest.approx(5 * 120.0 * 1.1)
348+
349+
350+
class TestCombinedPortfolioTotal:
351+
def test_empty_list_returns_none_usd(self):
352+
total, base = combined_portfolio_total([], {}, {})
353+
assert total is None
354+
assert base == "USD"
355+
356+
def test_sums_across_portfolios_same_base(self):
357+
p1 = Portfolio(
358+
positions=[Position("AAPL", 10, 150.0)],
359+
cash=[CashPosition("USD", 500.0)],
360+
base_currency="USD",
361+
)
362+
p2 = Portfolio(
363+
positions=[Position("NVDA", 5, 100.0)],
364+
base_currency="USD",
365+
)
366+
prices = {"AAPL": 200.0, "NVDA": 120.0}
367+
forex = {"USD": {"USD": 1.0}}
368+
total, base = combined_portfolio_total([p1, p2], prices, forex)
369+
# p1: 10*200 + 500 = 2500; p2: 5*120 = 600; combined = 3100
370+
assert total == pytest.approx(3100.0)
371+
assert base == "USD"
372+
373+
def test_uses_first_portfolio_base_currency(self):
374+
# p1 base = EUR, p2 base = USD -- the aggregate is reported in EUR.
375+
p1 = Portfolio(
376+
positions=[Position("VOW3", 1, 100.0, currency="EUR")],
377+
base_currency="EUR",
378+
)
379+
p2 = Portfolio(
380+
positions=[Position("AAPL", 1, 100.0, currency="USD")],
381+
base_currency="USD",
382+
)
383+
prices = {"VOW3": 200.0, "AAPL": 200.0}
384+
# rates keyed by aggregate base ("EUR") -> {pos_currency: multiplier}
385+
forex = {"EUR": {"EUR": 1.0, "USD": 0.9}}
386+
total, base = combined_portfolio_total([p1, p2], prices, forex)
387+
# p1 in EUR: 1*200*1.0 = 200; p2 USD->EUR: 1*200*0.9 = 180; total 380
388+
assert total == pytest.approx(380.0)
389+
assert base == "EUR"
390+
391+
def test_returns_none_when_any_portfolio_incomplete(self):
392+
p1 = Portfolio(positions=[Position("AAPL", 10, 150.0)])
393+
p2 = Portfolio(positions=[Position("MISSING", 1, 50.0)])
394+
prices = {"AAPL": 200.0} # MISSING has no price
395+
forex = {"USD": {"USD": 1.0}}
396+
total, base = combined_portfolio_total([p1, p2], prices, forex)
397+
assert total is None
398+
assert base == "USD"
399+
400+
def test_returns_none_when_base_rates_absent(self):
401+
# forex_rates has no entry for the first portfolio's base -> the
402+
# nested rates dict is empty, so any non-empty portfolio fails.
403+
p1 = Portfolio(positions=[Position("AAPL", 10, 150.0)])
404+
total, base = combined_portfolio_total([p1], {"AAPL": 200.0}, {})
405+
assert total is None
406+
assert base == "USD"
407+
408+
def test_single_portfolio_matches_portfolio_total(self):
409+
p = Portfolio(
410+
positions=[Position("AAPL", 10, 150.0)],
411+
cash=[CashPosition("USD", 500.0)],
412+
)
413+
prices = {"AAPL": 200.0}
414+
forex = {"USD": {"USD": 1.0}}
415+
combined, _ = combined_portfolio_total([p], prices, forex)
416+
assert combined == pytest.approx(portfolio_total(p, prices, {"USD": 1.0}))

0 commit comments

Comments
 (0)