-
Notifications
You must be signed in to change notification settings - Fork 3
Aggregate value for all portfolios #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -42,6 +42,7 @@ | |||||||||||||||||||||||||||||||||||||||
| Portfolio, | ||||||||||||||||||||||||||||||||||||||||
| Position, | ||||||||||||||||||||||||||||||||||||||||
| WatchlistItem, | ||||||||||||||||||||||||||||||||||||||||
| combined_portfolio_total, | ||||||||||||||||||||||||||||||||||||||||
| portfolio_total, | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
| from stonks_cli.news_fetcher import NewsFetcher, NewsItem | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -408,6 +409,92 @@ def on_click(self) -> None: | |||||||||||||||||||||||||||||||||||||||
| self.post_message(self.Selected(self._index)) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| class PortfolioApp(ThreadGuardMixin, App[None]): | ||||||||||||||||||||||||||||||||||||||||
| """Full-screen portfolio table with periodic price refresh.""" | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
|
|
@@ -495,7 +582,14 @@ def compose(self) -> ComposeResult: | |||||||||||||||||||||||||||||||||||||||
| yield Static("", id="status") | ||||||||||||||||||||||||||||||||||||||||
| yield Static("", id="error") | ||||||||||||||||||||||||||||||||||||||||
| yield NewsFeedWidget(id="news-panel") | ||||||||||||||||||||||||||||||||||||||||
| yield Footer() | ||||||||||||||||||||||||||||||||||||||||
| # 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, | ||||||||||||||||||||||||||||||||||||||||
| ) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+585
to
+592
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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.
Suggested change
|
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| def on_mount(self) -> None: | ||||||||||||||||||||||||||||||||||||||||
| self._populate_tables() | ||||||||||||||||||||||||||||||||||||||||
|
|
@@ -971,6 +1065,30 @@ def _populate_tables(self) -> None: | |||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||
| for i, portfolio in enumerate(self.portfolios): | ||||||||||||||||||||||||||||||||||||||||
| self._populate_for(i, portfolio) | ||||||||||||||||||||||||||||||||||||||||
| self._update_combined_total() | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| 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) | ||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1070
to
+1091
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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) |
||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| def _populate_single(self) -> None: | ||||||||||||||||||||||||||||||||||||||||
| try: | ||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,7 +9,7 @@ | |
| from textual.widgets import Button, DataTable, Input, Label, Static | ||
|
|
||
| from stonks_cli import app_actions | ||
| from stonks_cli.app import PortfolioApp, PortfolioTableWidget | ||
| from stonks_cli.app import PortfolioApp, PortfolioTableWidget, _FooterWithTotal | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| from stonks_cli.forms import ( | ||
| CashFormScreen, | ||
| ConfirmScreen, | ||
|
|
@@ -2590,6 +2590,87 @@ async def test_action_chat_no_op_when_already_open(portfolio: Portfolio) -> None | |
| mock_push.assert_not_called() | ||
|
|
||
|
|
||
| def _static_content(widget: Static) -> str: | ||
| """Return the plain-text content currently displayed by a Static widget. | ||
|
|
||
| Uses the public ``render()`` API rather than poking the | ||
| name-mangled ``_Static__content`` attribute so the helper survives | ||
| Textual internal renames. | ||
| """ | ||
| return str(widget.render()) | ||
|
|
||
|
|
||
| @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 | ||
|
Comment on lines
+2603
to
+2671
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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 |
||
|
|
||
|
|
||
| @pytest.mark.asyncio | ||
| async def test_history_updated_message_syncs_to_app(portfolio: Portfolio) -> None: | ||
| """HistoryUpdated message from ChatScreen updates app._chat_history.""" | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.