Aggregate value for all portfolios#104
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a combined portfolio total widget in the footer of the CLI application, showing aggregate values across multiple portfolios in the first portfolio's base currency. It also implements an inverse forex pair fallback mechanism in the fetcher to handle minor currencies where direct pairs are missing from Yahoo Finance. The review comments suggest optimizing the forex fetching logic by batching both direct and inverse pairs into a single yfinance API call, which avoids sequential network requests and reduces latency, along with updating the corresponding unit tests.
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
ad8cc17 to
84c0a82
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a combined portfolio total calculation and displays it in a custom footer widget (_FooterWithTotal) when multiple portfolios are loaded. It also optimizes forex rate fetching by batching direct and inverse currency pairs in a single yfinance request to handle minor currencies without extra network round-trips. The review feedback highlights two important improvements: ensuring exchange rates are strictly positive to prevent division-by-zero or negative rate anomalies, and avoiding the use of name-mangled private attributes in tests by using the public render() method of the Static widget.
84c0a82 to
611313d
Compare
…rse pair Cash positions in less-traded currencies (UAH, in the reported case) were silently dropping out of every total when the portfolio used a non-USD base. Yahoo only publishes the major pairs in one direction -- ``EURUAH=X`` exists but ``UAHEUR=X`` doesn't -- so a portfolio with UAH cash and an EUR base couldn't be valued at all for that row, which rendered as ``N/A`` in the table and made the freshly-added aggregate footer ``N/A`` too. Falling back to the inverse ticker when the direct one is missing recovers the rate for every minor currency Yahoo carries against the base in either direction, without forcing every user to add USD as an intermediate hop in their YAML. The fallback only fires when the direct pass leaves something unresolved, so the common case still costs a single yfinance call. Signed-off-by: Igor Opaniuk <igor.opaniuk@gmail.com>
611313d to
fe84239
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a combined portfolio total display in the application footer when multiple portfolios are loaded, along with an optimized forex rate fetching mechanism that batches direct and inverse currency pairs in a single network call. Comprehensive unit tests have been added to verify these new features. The reviewer identified a potential robustness issue in the _FooterWithTotal.set_total_renderable method, where calling query_one during Textual's asynchronous recompose process could raise a TooManyMatches exception, and suggested iterating over self.query instead.
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>
fe84239 to
c314356
Compare
|
/gemini review |
There was a problem hiding this comment.
Code Review
This pull request introduces a combined portfolio total display in the application footer when multiple portfolios are loaded, and optimizes forex rate fetching by batching direct and inverse currency pairs in a single yfinance call. The reviewer recommends simplifying the UI implementation by replacing the custom _FooterWithTotal widget with a standard Static widget using absolute positioning, which eliminates complex caching and recomposition logic. Detailed code suggestions are provided to apply this simplification across the application and its test suite.
| class _FooterWithTotal(Footer): | ||
| """``Footer`` subclass that injects a ``Total all portfolios`` label | ||
| just left of the command-palette indicator. | ||
|
|
||
| Multiple ``dock: right`` widgets in Textual all anchor to the same | ||
| right edge and overlap, so we can't simply yield a second | ||
| ``dock: right`` Static after the palette and expect them to stack. | ||
| Instead we ``dock: right`` the label and offset it leftwards by | ||
| ``margin-right`` equal to the rendered palette width plus a small | ||
| gap, putting it visually just to the left of ``^palette``. | ||
|
|
||
| When ``show_total`` is False the label is mounted with the | ||
| ``-hidden`` class so the DOM is identical between single- and | ||
| multi-portfolio modes; only its ``display`` flips. | ||
|
|
||
| Footer triggers a ``recompose`` once its ``_bindings_ready`` reactive | ||
| flips, which wipes any children the previous ``compose`` yielded | ||
| (including ours). We cache the most recent renderable on the | ||
| instance so each recompose re-creates the Static with the value the | ||
| app last set via :meth:`set_total_renderable`, rather than flashing | ||
| back to empty between recompose and the next price refresh. | ||
| """ | ||
|
|
||
| # The command-palette FooterKey renders with ``dock: right`` and a | ||
| # vertical-key border on the left. Its width is the key glyph + label | ||
| # padding -- empirically ~12 cells in the default theme. We clear it | ||
| # plus a 2-cell gap so the total doesn't visually collide with the | ||
| # palette border. | ||
| _PALETTE_GAP = 14 | ||
|
|
||
| # A CSS class is used rather than a fixed ``id`` because Footer's | ||
| # ``_bindings_ready`` reactive triggers a ``recompose`` (and pushing | ||
| # another screen on top does too). Textual's recompose asynchronously | ||
| # removes children before mounting fresh ones, but on Windows the | ||
| # removal hasn't always settled by the time ``mount_all`` runs, so a | ||
| # widget with a fixed ID gets reported as a duplicate and the whole | ||
| # screen push aborts. Querying by class is collision-free. | ||
| _TOTAL_CLASS = "combined-total" | ||
|
|
||
| DEFAULT_CSS = f""" | ||
| _FooterWithTotal Static.{_TOTAL_CLASS} {{ | ||
| dock: right; | ||
| width: auto; | ||
| padding: 0 1; | ||
| margin-right: {_PALETTE_GAP}; | ||
| color: $accent; | ||
| text-style: bold; | ||
| }} | ||
| _FooterWithTotal Static.{_TOTAL_CLASS}.-hidden {{ | ||
| display: none; | ||
| }} | ||
| """ | ||
|
|
||
| def __init__(self, *, show_total: bool, **kw: Any) -> None: | ||
| super().__init__(**kw) | ||
| self._show_total = show_total | ||
| self._cached_renderable: Text | str = "" | ||
|
|
||
| def compose(self) -> ComposeResult: | ||
| yield from super().compose() | ||
| classes = self._TOTAL_CLASS | ||
| if not self._show_total: | ||
| classes += " -hidden" | ||
| yield Static(self._cached_renderable, classes=classes) | ||
|
|
||
| def set_total_renderable(self, renderable: Text | str) -> None: | ||
| """Update the cached label and any live Static instance. | ||
|
|
||
| Called by the app each time per-portfolio totals are refreshed. | ||
| Caching on the instance means the next recompose (triggered by | ||
| ``Footer`` itself when its bindings settle, or by a screen push | ||
| on top of the portfolio screen) re-creates the Static with the | ||
| latest value. | ||
|
|
||
| Iterates over ``query`` rather than calling ``query_one`` because | ||
| during recompose the old Static is briefly still in the DOM while | ||
| the new one mounts: ``query_one`` would raise ``TooManyMatches`` | ||
| in that window. Iteration covers 0, 1, or N matches without an | ||
| exception; if the Static isn't mounted yet the cached value is | ||
| picked up by the next ``compose``. | ||
| """ | ||
| self._cached_renderable = renderable | ||
| for widget in self.query(f".{self._TOTAL_CLASS}").results(Static): | ||
| widget.update(renderable) | ||
|
|
There was a problem hiding this comment.
This custom _FooterWithTotal subclass introduces significant complexity and fragility (such as caching renderables to survive recomposition, querying by class to avoid TooManyMatches, and handling asynchronous recompose races on Windows). We can completely eliminate this class by using a standard Static widget positioned absolutely over the standard Footer.
| class _FooterWithTotal(Footer): | |
| """``Footer`` subclass that injects a ``Total all portfolios`` label | |
| just left of the command-palette indicator. | |
| Multiple ``dock: right`` widgets in Textual all anchor to the same | |
| right edge and overlap, so we can't simply yield a second | |
| ``dock: right`` Static after the palette and expect them to stack. | |
| Instead we ``dock: right`` the label and offset it leftwards by | |
| ``margin-right`` equal to the rendered palette width plus a small | |
| gap, putting it visually just to the left of ``^palette``. | |
| When ``show_total`` is False the label is mounted with the | |
| ``-hidden`` class so the DOM is identical between single- and | |
| multi-portfolio modes; only its ``display`` flips. | |
| Footer triggers a ``recompose`` once its ``_bindings_ready`` reactive | |
| flips, which wipes any children the previous ``compose`` yielded | |
| (including ours). We cache the most recent renderable on the | |
| instance so each recompose re-creates the Static with the value the | |
| app last set via :meth:`set_total_renderable`, rather than flashing | |
| back to empty between recompose and the next price refresh. | |
| """ | |
| # The command-palette FooterKey renders with ``dock: right`` and a | |
| # vertical-key border on the left. Its width is the key glyph + label | |
| # padding -- empirically ~12 cells in the default theme. We clear it | |
| # plus a 2-cell gap so the total doesn't visually collide with the | |
| # palette border. | |
| _PALETTE_GAP = 14 | |
| # A CSS class is used rather than a fixed ``id`` because Footer's | |
| # ``_bindings_ready`` reactive triggers a ``recompose`` (and pushing | |
| # another screen on top does too). Textual's recompose asynchronously | |
| # removes children before mounting fresh ones, but on Windows the | |
| # removal hasn't always settled by the time ``mount_all`` runs, so a | |
| # widget with a fixed ID gets reported as a duplicate and the whole | |
| # screen push aborts. Querying by class is collision-free. | |
| _TOTAL_CLASS = "combined-total" | |
| DEFAULT_CSS = f""" | |
| _FooterWithTotal Static.{_TOTAL_CLASS} {{ | |
| dock: right; | |
| width: auto; | |
| padding: 0 1; | |
| margin-right: {_PALETTE_GAP}; | |
| color: $accent; | |
| text-style: bold; | |
| }} | |
| _FooterWithTotal Static.{_TOTAL_CLASS}.-hidden {{ | |
| display: none; | |
| }} | |
| """ | |
| def __init__(self, *, show_total: bool, **kw: Any) -> None: | |
| super().__init__(**kw) | |
| self._show_total = show_total | |
| self._cached_renderable: Text | str = "" | |
| def compose(self) -> ComposeResult: | |
| yield from super().compose() | |
| classes = self._TOTAL_CLASS | |
| if not self._show_total: | |
| classes += " -hidden" | |
| yield Static(self._cached_renderable, classes=classes) | |
| def set_total_renderable(self, renderable: Text | str) -> None: | |
| """Update the cached label and any live Static instance. | |
| Called by the app each time per-portfolio totals are refreshed. | |
| Caching on the instance means the next recompose (triggered by | |
| ``Footer`` itself when its bindings settle, or by a screen push | |
| on top of the portfolio screen) re-creates the Static with the | |
| latest value. | |
| Iterates over ``query`` rather than calling ``query_one`` because | |
| during recompose the old Static is briefly still in the DOM while | |
| the new one mounts: ``query_one`` would raise ``TooManyMatches`` | |
| in that window. Iteration covers 0, 1, or N matches without an | |
| exception; if the Static isn't mounted yet the cached value is | |
| picked up by the next ``compose``. | |
| """ | |
| self._cached_renderable = renderable | |
| for widget in self.query(f".{self._TOTAL_CLASS}").results(Static): | |
| widget.update(renderable) | |
| # _FooterWithTotal removed in favor of absolute positioning |
| # The aggregate "Total all portfolios" label only renders when more | ||
| # than one portfolio is loaded -- in single-portfolio mode it would | ||
| # just duplicate the per-portfolio Total row. We always mount it | ||
| # so the footer layout doesn't shift; visibility is toggled via the | ||
| # ``-hidden`` class on the Static inside. | ||
| yield _FooterWithTotal( | ||
| show_total=len(self.portfolios) > 1, | ||
| ) |
There was a problem hiding this comment.
Instead of using the custom _FooterWithTotal widget, we can yield a standard Footer and a standard Static widget positioned absolutely. This is cleaner, standard, and avoids any recompose races or Windows-specific layout bugs.
| # The aggregate "Total all portfolios" label only renders when more | |
| # than one portfolio is loaded -- in single-portfolio mode it would | |
| # just duplicate the per-portfolio Total row. We always mount it | |
| # so the footer layout doesn't shift; visibility is toggled via the | |
| # ``-hidden`` class on the Static inside. | |
| yield _FooterWithTotal( | |
| show_total=len(self.portfolios) > 1, | |
| ) | |
| yield Footer() | |
| total_widget = Static("", id="combined-total") | |
| total_widget.styles.position = "absolute" | |
| total_widget.styles.bottom = 0 | |
| total_widget.styles.right = 14 | |
| total_widget.styles.width = "auto" | |
| total_widget.styles.height = 1 | |
| total_widget.styles.color = "$accent" | |
| total_widget.styles.text_style = "bold" | |
| total_widget.styles.display = "none" if len(self.portfolios) <= 1 else "block" | |
| yield total_widget |
| def _update_combined_total(self) -> None: | ||
| """Refresh the aggregate "Total all portfolios" label in the footer. | ||
|
|
||
| Goes through ``_FooterWithTotal.set_total_renderable`` rather than | ||
| updating the Static directly so the value survives the Footer's | ||
| post-``_bindings_ready`` recompose -- without the cache, the | ||
| label would flash to empty between recompose and the next price | ||
| refresh. | ||
| """ | ||
| try: | ||
| footer = self.query_one(_FooterWithTotal) | ||
| except NoMatches: | ||
| return | ||
| total, base = combined_portfolio_total( | ||
| self.portfolios, self._snap.prices, self._snap.forex_rates | ||
| ) | ||
| label = Text(f"Total ({base}) ") | ||
| if total is None: | ||
| renderable: Text = label.append("N/A", style="bold") | ||
| else: | ||
| renderable = label.append(f"{total:,.2f}", style="bold") | ||
| footer.set_total_renderable(renderable) |
There was a problem hiding this comment.
Update _update_combined_total to query the standard #combined-total Static widget directly and update its content. This eliminates the need for set_total_renderable and the associated caching logic.
def _update_combined_total(self) -> None:
"""Refresh the aggregate "Total all portfolios" label in the footer."""
try:
total_widget = self.query_one("#combined-total", Static)
except NoMatches:
return
total, base = combined_portfolio_total(
self.portfolios, self._snap.prices, self._snap.forex_rates
)
label = Text(f"Total ({base}) ")
if total is None:
renderable: Text = label.append("N/A", style="bold")
else:
renderable = label.append(f"{total:,.2f}", style="bold")
total_widget.update(renderable)|
|
||
| from stonks_cli import app_actions | ||
| from stonks_cli.app import PortfolioApp, PortfolioTableWidget | ||
| from stonks_cli.app import PortfolioApp, PortfolioTableWidget, _FooterWithTotal |
| @pytest.mark.asyncio | ||
| async def test_combined_total_widget_hidden_in_single_portfolio_mode( | ||
| portfolio: Portfolio, | ||
| ) -> None: | ||
| """The aggregate label is always mounted (so the footer layout stays | ||
| stable across single-/multi-portfolio modes) but is hidden via the | ||
| ``-hidden`` class when only one portfolio is loaded -- otherwise it | ||
| would just duplicate the per-portfolio Total row.""" | ||
| app = PortfolioApp(portfolios=[portfolio], prices={}, forex_rates=USD_RATES) | ||
| async with app.run_test() as pilot: | ||
| await pilot.pause() | ||
| widget = app.query_one(_FooterWithTotal).query_one(".combined-total", Static) | ||
| assert "-hidden" in widget.classes | ||
|
|
||
| multi = PortfolioApp( | ||
| portfolios=[portfolio, Portfolio(name="Second")], | ||
| prices={}, | ||
| forex_rates=USD_RATES, | ||
| ) | ||
| async with multi.run_test() as pilot: | ||
| await pilot.pause() | ||
| widget = multi.query_one(_FooterWithTotal).query_one(".combined-total", Static) | ||
| assert "-hidden" not in widget.classes | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_combined_total_widget_renders_value(portfolio: Portfolio) -> None: | ||
| """When prices and forex rates are available, the aggregate footer | ||
| must display the summed value across all portfolios in the first | ||
| portfolio's base currency.""" | ||
| second = Portfolio( | ||
| name="Second", | ||
| positions=[Position(symbol="MSFT", quantity=10, avg_cost=200.0)], | ||
| ) | ||
| app = PortfolioApp( | ||
| portfolios=[portfolio, second], | ||
| prices={"AAPL": 200.0, "NVDA": 150.0, "MSFT": 300.0}, | ||
| forex_rates=USD_RATES, | ||
| ) | ||
| async with app.run_test() as pilot: | ||
| await pilot.pause() | ||
| widget = app.query_one(_FooterWithTotal).query_one(".combined-total", Static) | ||
| text = _static_content(widget) | ||
| # portfolio fixture: AAPL 100 @ 200 + NVDA 200 @ 150 = 50_000; | ||
| # second: MSFT 10 @ 300 = 3_000; total 53_000 | ||
| assert "Total (USD)" in text | ||
| assert "53,000.00" in text | ||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_combined_total_widget_renders_na_on_missing_price( | ||
| portfolio: Portfolio, | ||
| ) -> None: | ||
| """When any price is missing the aggregate must render ``N/A`` rather | ||
| than a misleading partial sum.""" | ||
| second = Portfolio( | ||
| name="Second", | ||
| positions=[Position(symbol="MISSING", quantity=10, avg_cost=200.0)], | ||
| ) | ||
| app = PortfolioApp( | ||
| portfolios=[portfolio, second], | ||
| prices={"AAPL": 200.0, "NVDA": 150.0}, # MISSING absent | ||
| forex_rates=USD_RATES, | ||
| ) | ||
| async with app.run_test() as pilot: | ||
| await pilot.pause() | ||
| widget = app.query_one(_FooterWithTotal).query_one(".combined-total", Static) | ||
| text = _static_content(widget) | ||
| assert "N/A" in text |
There was a problem hiding this comment.
Update the tests to query #combined-total directly and assert on its styles.display property instead of the -hidden class.
@pytest.mark.asyncio
async def test_combined_total_widget_hidden_in_single_portfolio_mode(
portfolio: Portfolio,
) -> None:
"""The aggregate label is always mounted but is hidden (display: none)
when only one portfolio is loaded."""
app = PortfolioApp(portfolios=[portfolio], prices={}, forex_rates=USD_RATES)
async with app.run_test() as pilot:
await pilot.pause()
widget = app.query_one("#combined-total", Static)
assert widget.styles.display == "none"
multi = PortfolioApp(
portfolios=[portfolio, Portfolio(name="Second")],
prices={},
forex_rates=USD_RATES,
)
async with multi.run_test() as pilot:
await pilot.pause()
widget = multi.query_one("#combined-total", Static)
assert widget.styles.display == "block"
@pytest.mark.asyncio
async def test_combined_total_widget_renders_value(portfolio: Portfolio) -> None:
"""When prices and forex rates are available, the aggregate footer
must display the summed value across all portfolios in the first
portfolio's base currency."""
second = Portfolio(
name="Second",
positions=[Position(symbol="MSFT", quantity=10, avg_cost=200.0)],
)
app = PortfolioApp(
portfolios=[portfolio, second],
prices={"AAPL": 200.0, "NVDA": 150.0, "MSFT": 300.0},
forex_rates=USD_RATES,
)
async with app.run_test() as pilot:
await pilot.pause()
widget = app.query_one("#combined-total", Static)
text = _static_content(widget)
# portfolio fixture: AAPL 100 @ 200 + NVDA 200 @ 150 = 50_000;
# second: MSFT 10 @ 300 = 3_000; total 53_000
assert "Total (USD)" in text
assert "53,000.00" in text
@pytest.mark.asyncio
async def test_combined_total_widget_renders_na_on_missing_price(
portfolio: Portfolio,
) -> None:
"""When any price is missing the aggregate must render ``N/A`` rather
than a misleading partial sum."""
second = Portfolio(
name="Second",
positions=[Position(symbol="MISSING", quantity=10, avg_cost=200.0)],
)
app = PortfolioApp(
portfolios=[portfolio, second],
prices={"AAPL": 200.0, "NVDA": 150.0},
forex_rates=USD_RATES,
)
async with app.run_test() as pilot:
await pilot.pause()
widget = app.query_one("#combined-total", Static)
text = _static_content(widget)
assert "N/A" in text
Adds a Total () X,XXX.XX label in the footer, just left of ^palette, summing every loaded portfolio's current market value in the first portfolio's base currency. The footer was chosen because it's the natural status-bar slot - it stays visible no matter how deep the user scrolls into a portfolio table. The label stays mounted (hidden via CSS) in single-portfolio mode so the footer geometry doesn't shift between modes.
Also make fetch_forex_rates fall back to the inverse {base}{currency}=X ticker (and reciprocate) when the direct pair is missing. Yahoo only publishes major forex pairs in one direction, so a portfolio with UAH cash and an EUR base previously couldn't be valued for that row and rendered N/A. The fallback only runs when the direct pass leaves something unresolved, so the common case stays at one yfinance call.