Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 119 additions & 1 deletion src/stonks_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
Portfolio,
Position,
WatchlistItem,
combined_portfolio_total,
portfolio_total,
)
from stonks_cli.news_fetcher import NewsFetcher, NewsItem
Expand Down Expand Up @@ -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)

Comment on lines +412 to +496
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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


class PortfolioApp(ThreadGuardMixin, App[None]):
"""Full-screen portfolio table with periodic price refresh."""

Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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
# 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 on_mount(self) -> None:
self._populate_tables()
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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:
Expand Down
46 changes: 35 additions & 11 deletions src/stonks_cli/fetcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -378,9 +378,14 @@ def fetch_forex_rates(
) -> dict[str, float]:
"""Return exchange rates: 1 unit of currency -> how many base units.

Uses yfinance forex pairs (e.g. EURUSD=X for EUR->USD).
The base currency is always included as 1.0. Currencies for which
no rate can be fetched are omitted from the result.
Uses yfinance forex pairs (e.g. ``EURUSD=X`` for EUR->USD). When
a direct ``{currency}{base}=X`` ticker isn't published by Yahoo
(common for minor / less-traded currencies like ``UAH`` against
non-USD bases), the inverse pair ``{base}{currency}=X`` is used
and the rate reciprocated. Both directions are requested in a
single batched yfinance call to avoid sequential network
round-trips. The base currency is always included as 1.0.
Currencies for which neither pair resolves are omitted.

Args:
currencies: ISO 4217 currency codes (e.g. ['EUR', 'GBP']).
Expand All @@ -396,17 +401,36 @@ def fetch_forex_rates(
if not non_base:
return rates

symbols = [f"{c}{base}=X" for c in non_base]
# Request direct and inverse pairs together so Yahoo answers in a
# single round-trip even when minor currencies are present. The
# inverse symbols are cheap to add to the batch and ``dict.fromkeys``
# de-dupes if any direct ticker happens to equal an inverse one
# (it never does for distinct currencies, but the guard is free).
direct_syms = [f"{c}{base}=X" for c in non_base]
inverse_syms = [f"{base}{c}=X" for c in non_base]
all_syms = list(dict.fromkeys(direct_syms + inverse_syms))

close = _yf_download_close(
symbols, period="1d", description="forex", auto_adjust=False
all_syms, period="1d", description="forex", auto_adjust=False
)
downloaded = (
_last_close_per_symbol(close, all_syms) if close is not None else {}
)
if close is None:
return rates

last = _last_close_per_symbol(close, symbols)
for currency, symbol in zip(non_base, symbols):
if symbol in last:
rates[currency] = last[symbol]
for currency in non_base:
# Forex rates are always strictly positive in reality; checking
# ``> 0`` also drops the NaN values yfinance sometimes returns
# for unavailable symbols (NaN comparisons all evaluate False)
# without an explicit ``math.isnan`` guard.
direct_sym = f"{currency}{base}=X"
direct_rate = downloaded.get(direct_sym)
if direct_rate is not None and direct_rate > 0:
rates[currency] = direct_rate
continue
inverse_sym = f"{base}{currency}=X"
inv_rate = downloaded.get(inverse_sym)
if inv_rate is not None and inv_rate > 0:
rates[currency] = 1.0 / inv_rate
Comment thread
igoropaniuk marked this conversation as resolved.
return rates

def fetch_stock_detail(self, symbol: str) -> StockDetail:
Expand Down
36 changes: 36 additions & 0 deletions src/stonks_cli/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -299,3 +299,39 @@ def portfolio_total(
return None
total += cash_pos.amount * rate
return total


def combined_portfolio_total(
portfolios: "list[Portfolio]",
prices: dict[str, float],
forex_rates: dict[str, dict[str, float]],
) -> tuple[float | None, str]:
"""Return ``(total, base_currency)`` summed across all *portfolios*.

The aggregate is computed in the *first* portfolio's ``base_currency`` --
every position and cash balance across every portfolio is converted into
that currency using ``forex_rates[base]``. ``base`` is returned alongside
the total so callers can render the label without having to look it up
themselves.

Returns ``(None, base)`` when any underlying portfolio's value cannot be
computed (missing price or forex rate) so the UI can render "N/A" rather
than a misleading partial sum. Returns ``(None, "USD")`` when the list
is empty.

Args:
portfolios: Loaded portfolios to aggregate.
prices: Last prices keyed by symbol (shared across all portfolios).
forex_rates: Nested forex map ``{base_currency: {position_currency: rate}}``.
"""
if not portfolios:
return None, "USD"
base = portfolios[0].base_currency
rates = forex_rates.get(base, {})
total = 0.0
for p in portfolios:
sub = portfolio_total(p, prices, rates)
if sub is None:
return None, base
total += sub
return total, base
83 changes: 82 additions & 1 deletion tests/test_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Remove the import of the deleted _FooterWithTotal class.

Suggested change
from stonks_cli.app import PortfolioApp, PortfolioTableWidget, _FooterWithTotal
from stonks_cli.app import PortfolioApp, PortfolioTableWidget

from stonks_cli.forms import (
CashFormScreen,
ConfirmScreen,
Expand Down Expand Up @@ -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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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."""
Expand Down
Loading
Loading