From 133908dd36b85fd8951a60b76b4fd87ffdcbf337 Mon Sep 17 00:00:00 2001 From: Bu5hm4nn Date: Mon, 23 Mar 2026 23:23:59 +0100 Subject: [PATCH] feat: prioritize lazy options loading and live overview wiring - queue OPS-001 Caddy route for vd1.uncloud.vpn - lazy-load options expirations/chains per expiry - wire overview to live quote data and persisted portfolio config - extend browser test to verify live quote metadata --- app/pages/common.py | 4 +- app/pages/options.py | 125 ++++++++++++++---------- app/pages/overview.py | 86 ++++++++++++---- app/services/data_service.py | 184 ++++++++++++++++++++++++++--------- docs/ROADMAP.md | 41 +++++++- tests/test_e2e_playwright.py | 1 + 6 files changed, 320 insertions(+), 121 deletions(-) diff --git a/app/pages/common.py b/app/pages/common.py index 2e7989e..deccfd4 100644 --- a/app/pages/common.py +++ b/app/pages/common.py @@ -67,8 +67,8 @@ def strategy_catalog() -> list[dict[str, Any]]: ] -def quick_recommendations() -> list[dict[str, str]]: - portfolio = portfolio_snapshot() +def quick_recommendations(portfolio: dict[str, Any] | None = None) -> list[dict[str, str]]: + portfolio = portfolio or portfolio_snapshot() ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100 return [ { diff --git a/app/pages/options.py b/app/pages/options.py index 5683f37..b3fef59 100644 --- a/app/pages/options.py +++ b/app/pages/options.py @@ -11,19 +11,29 @@ from app.services.runtime import get_data_service @ui.page("/options") async def options_page() -> None: - chain_data = await get_data_service().get_options_chain("GLD") - chain = list(chain_data.get("rows") or [*chain_data.get("calls", []), *chain_data.get("puts", [])]) - expiries = list(chain_data.get("expirations") or sorted({row["expiry"] for row in chain})) - strike_values = sorted({float(row["strike"]) for row in chain}) + data_service = get_data_service() + expirations_data = await data_service.get_option_expirations("GLD") + expiries = list(expirations_data.get("expirations") or []) + default_expiry = expiries[0] if expiries else None + chain_data = await data_service.get_options_chain_for_expiry("GLD", default_expiry) - selected_expiry = {"value": expiries[0] if expiries else None} - strike_range = { - "min": strike_values[0] if strike_values else 0.0, - "max": strike_values[-1] if strike_values else 0.0, + chain_state = { + "data": chain_data, + "rows": list(chain_data.get("rows") or [*chain_data.get("calls", []), *chain_data.get("puts", [])]), } + selected_expiry = {"value": chain_data.get("selected_expiry") or default_expiry} selected_strategy = {"value": strategy_catalog()[0]["label"]} chosen_contracts: list[dict[str, Any]] = [] + def strike_bounds(rows: list[dict[str, Any]]) -> tuple[float, float]: + strike_values = sorted({float(row["strike"]) for row in rows}) + if not strike_values: + return 0.0, 0.0 + return strike_values[0], strike_values[-1] + + initial_min_strike, initial_max_strike = strike_bounds(chain_state["rows"]) + strike_range = {"min": initial_min_strike, "max": initial_max_strike} + with dashboard_page( "Options Chain", "Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.", @@ -35,34 +45,17 @@ async def options_page() -> None: ): ui.label("Filters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") expiry_select = ui.select(expiries, value=selected_expiry["value"], label="Expiry").classes("w-full") - min_strike = ui.number( - "Min strike", - value=strike_range["min"], - min=strike_values[0] if strike_values else 0.0, - max=strike_values[-1] if strike_values else 0.0, - step=5, - ).classes("w-full") - max_strike = ui.number( - "Max strike", - value=strike_range["max"], - min=strike_values[0] if strike_values else 0.0, - max=strike_values[-1] if strike_values else 0.0, - step=5, - ).classes("w-full") + min_strike = ui.number("Min strike", value=strike_range["min"], step=5).classes("w-full") + max_strike = ui.number("Max strike", value=strike_range["max"], step=5).classes("w-full") strategy_select = ui.select( [item["label"] for item in strategy_catalog()], value=selected_strategy["value"], label="Add to hedge strategy", ).classes("w-full") - source_label = f"Source: {chain_data.get('source', 'unknown')}" - if chain_data.get("updated_at"): - source_label += f" · Updated {chain_data['updated_at']}" - ui.label(source_label).classes("text-xs text-slate-500 dark:text-slate-400") - if chain_data.get("error"): - ui.label(f"Options data unavailable: {chain_data['error']}").classes( - "text-xs text-amber-700 dark:text-amber-300" - ) + source_html = ui.html("").classes("text-xs text-slate-500 dark:text-slate-400") + error_html = ui.html("").classes("text-xs text-amber-700 dark:text-amber-300") + loading_html = ui.html("").classes("text-xs text-sky-700 dark:text-sky-300") selection_card = ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" @@ -70,15 +63,27 @@ async def options_page() -> None: chain_table = ui.html("").classes("w-full") greeks = GreeksTable([]) + quick_add = ui.card().classes( + "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" + ) + + def sync_status() -> None: + current_data = chain_state["data"] + source_label = f"Source: {current_data.get('source', 'unknown')}" + if current_data.get("updated_at"): + source_label += f" · Updated {current_data['updated_at']}" + source_html.content = source_label + source_html.update() + + error_message = current_data.get("error") or expirations_data.get("error") + error_html.content = f"Options data unavailable: {error_message}" if error_message else "" + error_html.update() def filtered_rows() -> list[dict[str, Any]]: - if not selected_expiry["value"]: - return [] return [ row - for row in chain - if row["expiry"] == selected_expiry["value"] - and strike_range["min"] <= float(row["strike"]) <= strike_range["max"] + for row in chain_state["rows"] + if strike_range["min"] <= float(row["strike"]) <= strike_range["max"] ] def render_selection() -> None: @@ -100,10 +105,7 @@ async def options_page() -> None: chosen_contracts.append(contract) render_selection() greeks.set_options(chosen_contracts[-6:]) - ui.notify( - f"Added {contract['symbol']} to {selected_strategy['value']}", - color="positive", - ) + ui.notify(f"Added {contract['symbol']} to {selected_strategy['value']}", color="positive") def render_chain() -> None: rows = filtered_rows() @@ -125,7 +127,8 @@ async def options_page() -> None: """ - + "".join(f""" + + "".join( + f""" {row['symbol']} {row['type'].upper()} @@ -136,7 +139,9 @@ async def options_page() -> None: Δ {float(row.get('delta', 0.0)):+.3f} · Γ {float(row.get('gamma', 0.0)):.3f} · Θ {float(row.get('theta', 0.0)):+.3f} Use quick-add buttons below - """ for row in rows) + """ + for row in rows + ) + ( "" if rows @@ -162,32 +167,48 @@ async def options_page() -> None: ).props("outline color=primary") greeks.set_options(rows[:6]) - quick_add = ui.card().classes( - "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" - ) + async def load_expiry_chain(expiry: str | None) -> None: + selected_expiry["value"] = expiry + loading_html.content = "Loading selected expiry…" if expiry else "" + loading_html.update() + + next_chain = await data_service.get_options_chain_for_expiry("GLD", expiry) + chain_state["data"] = next_chain + chain_state["rows"] = list(next_chain.get("rows") or [*next_chain.get("calls", []), *next_chain.get("puts", [])]) + + min_value, max_value = strike_bounds(chain_state["rows"]) + strike_range["min"] = min_value + strike_range["max"] = max_value + min_strike.value = min_value + max_strike.value = max_value + + loading_html.content = "" + loading_html.update() + sync_status() + render_chain() def update_filters() -> None: - selected_expiry["value"] = expiry_select.value strike_range["min"] = float(min_strike.value or 0.0) strike_range["max"] = float(max_strike.value or 0.0) if strike_range["min"] > strike_range["max"]: - strike_range["min"], strike_range["max"] = ( - strike_range["max"], - strike_range["min"], - ) + strike_range["min"], strike_range["max"] = (strike_range["max"], strike_range["min"]) min_strike.value = strike_range["min"] max_strike.value = strike_range["max"] render_chain() - expiry_select.on_value_change(lambda _: update_filters()) + async def on_expiry_change(event: Any) -> None: + await load_expiry_chain(event.value) + + expiry_select.on_value_change(on_expiry_change) min_strike.on_value_change(lambda _: update_filters()) max_strike.on_value_change(lambda _: update_filters()) - def on_strategy_change(event) -> None: - selected_strategy["value"] = event.value # type: ignore[assignment] + def on_strategy_change(event: Any) -> None: + selected_strategy["value"] = event.value render_selection() strategy_select.on_value_change(on_strategy_change) + sync_status() render_selection() render_chain() diff --git a/app/pages/overview.py b/app/pages/overview.py index 186c71c..229bc3e 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -1,48 +1,98 @@ from __future__ import annotations +from datetime import UTC, datetime + from nicegui import ui from app.components import PortfolioOverview -from app.pages.common import ( - dashboard_page, - portfolio_snapshot, - quick_recommendations, - recommendation_style, - strategy_catalog, -) +from app.models.portfolio import PortfolioConfig, get_portfolio_repository +from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog +from app.services.runtime import get_data_service + +_REFERENCE_SPOT_PRICE = 215.0 +_DEFAULT_CASH_BUFFER = 18_500.0 + + +def _format_timestamp(value: str | None) -> str: + if not value: + return "Unavailable" + try: + timestamp = datetime.fromisoformat(value.replace("Z", "+00:00")) + except ValueError: + return value + return timestamp.astimezone(UTC).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def _build_live_portfolio(config: PortfolioConfig, quote: dict[str, object]) -> dict[str, float | str]: + spot_price = float(quote.get("price", _REFERENCE_SPOT_PRICE)) + configured_gold_value = float(config.gold_value) + estimated_units = configured_gold_value / _REFERENCE_SPOT_PRICE if _REFERENCE_SPOT_PRICE > 0 else 0.0 + live_gold_value = estimated_units * spot_price + loan_amount = float(config.loan_amount) + margin_call_ltv = float(config.margin_threshold) + ltv_ratio = loan_amount / live_gold_value if live_gold_value > 0 else 0.0 + + return { + "spot_price": spot_price, + "gold_units": estimated_units, + "gold_value": live_gold_value, + "loan_amount": loan_amount, + "ltv_ratio": ltv_ratio, + "net_equity": live_gold_value - loan_amount, + "margin_call_ltv": margin_call_ltv, + "margin_call_price": loan_amount / (margin_call_ltv * estimated_units) if estimated_units > 0 else 0.0, + "cash_buffer": max(live_gold_value - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER, + "hedge_budget": float(config.monthly_budget), + "quote_source": str(quote.get("source", "unknown")), + "quote_updated_at": str(quote.get("updated_at", "")), + } @ui.page("/") @ui.page("/overview") -def overview_page() -> None: - portfolio = portfolio_snapshot() +async def overview_page() -> None: + config = get_portfolio_repository().load() + data_service = get_data_service() + symbol = data_service.default_symbol + quote = await data_service.get_quote(symbol) + portfolio = _build_live_portfolio(config, quote) + quote_status = ( + f"Live quote source: {portfolio['quote_source']} · " + f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" + ) with dashboard_page( "Overview", "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", "overview", ): + with ui.row().classes("w-full items-center justify-between gap-4 max-md:flex-col max-md:items-start"): + ui.label(quote_status).classes("text-sm text-slate-500 dark:text-slate-400") + ui.label( + f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}" + ).classes("text-sm text-slate-500 dark:text-slate-400") + with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"): summary_cards = [ ( "Spot Price", f"${portfolio['spot_price']:,.2f}", - "GLD reference price", + f"{symbol} live quote via {portfolio['quote_source']}", ), ( "Margin Call Price", f"${portfolio['margin_call_price']:,.2f}", - "Implied trigger level", + "Implied trigger level from persisted portfolio settings", ), ( "Cash Buffer", f"${portfolio['cash_buffer']:,.0f}", - "Available liquidity", + "Base liquidity plus unrealized gain cushion vs configured baseline", ), ( "Hedge Budget", f"${portfolio['hedge_budget']:,.0f}", - "Approved premium budget", + "Monthly budget from saved settings", ), ] for title, value, caption in summary_cards: @@ -53,7 +103,7 @@ def overview_page() -> None: ui.label(value).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400") - portfolio_view = PortfolioOverview(margin_call_ltv=portfolio["margin_call_ltv"]) + portfolio_view = PortfolioOverview(margin_call_ltv=float(portfolio["margin_call_ltv"])) portfolio_view.update(portfolio) with ui.row().classes("w-full gap-6 max-lg:flex-col"): @@ -62,15 +112,15 @@ def overview_page() -> None: ): with ui.row().classes("w-full items-center justify-between"): ui.label("Current LTV Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") - ui.label(f"Threshold {portfolio['margin_call_ltv'] * 100:.0f}%").classes( + ui.label(f"Threshold {float(portfolio['margin_call_ltv']) * 100:.0f}%").classes( "rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300" ) ui.linear_progress( - value=portfolio["ltv_ratio"] / portfolio["margin_call_ltv"], + value=float(portfolio["ltv_ratio"]) / max(float(portfolio["margin_call_ltv"]), 0.01), show_value=False, ).props("color=warning track-color=grey-3 rounded") ui.label( - f"Current LTV is {portfolio['ltv_ratio'] * 100:.1f}% with a margin buffer of {(portfolio['margin_call_ltv'] - portfolio['ltv_ratio']) * 100:.1f} percentage points." + f"Current LTV is {float(portfolio['ltv_ratio']) * 100:.1f}% with a margin buffer of {(float(portfolio['margin_call_ltv']) - float(portfolio['ltv_ratio'])) * 100:.1f} percentage points." ).classes("text-sm text-slate-600 dark:text-slate-300") ui.label( "Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required." @@ -93,7 +143,7 @@ def overview_page() -> None: ui.label("Quick Strategy Recommendations").classes("text-xl font-semibold text-slate-900 dark:text-slate-100") with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"): - for rec in quick_recommendations(): + for rec in quick_recommendations(portfolio): with ui.card().classes(f"rounded-2xl border shadow-sm {recommendation_style(rec['tone'])}"): ui.label(rec["title"]).classes("text-base font-semibold text-slate-900 dark:text-slate-100") ui.label(rec["summary"]).classes("text-sm text-slate-600 dark:text-slate-300") diff --git a/app/services/data_service.py b/app/services/data_service.py index c181102..1996eea 100644 --- a/app/services/data_service.py +++ b/app/services/data_service.py @@ -57,9 +57,9 @@ class DataService: await self.cache.set_json(cache_key, quote) return quote - async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]: + async def get_option_expirations(self, symbol: str | None = None) -> dict[str, Any]: ticker_symbol = (symbol or self.default_symbol).upper() - cache_key = f"options:{ticker_symbol}" + cache_key = f"options:{ticker_symbol}:expirations" cached = await self.cache.get_json(cache_key) if cached and isinstance(cached, dict): @@ -67,71 +67,141 @@ class DataService: quote = await self.get_quote(ticker_symbol) if yf is None: - options_chain = self._fallback_options_chain(ticker_symbol, quote, source="fallback") - await self.cache.set_json(cache_key, options_chain) - return options_chain + payload = self._fallback_option_expirations( + ticker_symbol, + quote, + source="fallback", + error="yfinance is not installed", + ) + await self.cache.set_json(cache_key, payload) + return payload try: ticker = yf.Ticker(ticker_symbol) expirations = await asyncio.to_thread(lambda: list(ticker.options or [])) if not expirations: - options_chain = self._fallback_options_chain( + payload = self._fallback_option_expirations( ticker_symbol, quote, source="fallback", error="No option expirations returned by yfinance", ) - await self.cache.set_json(cache_key, options_chain) - return options_chain + await self.cache.set_json(cache_key, payload) + return payload - # Limit initial load to the nearest expirations so the page can render quickly. - expirations = expirations[:3] - - calls: list[dict[str, Any]] = [] - puts: list[dict[str, Any]] = [] - - for expiry in expirations: - try: - chain = await asyncio.to_thread(ticker.option_chain, expiry) - except Exception as exc: # pragma: no cover - network dependent - logger.warning("Failed to fetch option chain for %s %s: %s", ticker_symbol, expiry, exc) - continue - - calls.extend(self._normalize_option_rows(chain.calls, ticker_symbol, expiry, "call")) - puts.extend(self._normalize_option_rows(chain.puts, ticker_symbol, expiry, "put")) - - if not calls and not puts: - options_chain = self._fallback_options_chain( - ticker_symbol, - quote, - source="fallback", - error="No option contracts returned by yfinance", - ) - await self.cache.set_json(cache_key, options_chain) - return options_chain - - options_chain = { + payload = { "symbol": ticker_symbol, "updated_at": datetime.now(UTC).isoformat(), "expirations": expirations, - "calls": calls, - "puts": puts, - "rows": sorted(calls + puts, key=lambda row: (row["expiry"], row["strike"], row["type"])), "underlying_price": quote["price"], "source": "yfinance", } - await self.cache.set_json(cache_key, options_chain) - return options_chain + await self.cache.set_json(cache_key, payload) + return payload except Exception as exc: # pragma: no cover - network dependent - logger.warning("Failed to fetch options chain for %s from yfinance: %s", ticker_symbol, exc) - options_chain = self._fallback_options_chain( + logger.warning("Failed to fetch option expirations for %s from yfinance: %s", ticker_symbol, exc) + payload = self._fallback_option_expirations( ticker_symbol, quote, source="fallback", error=str(exc), ) - await self.cache.set_json(cache_key, options_chain) - return options_chain + await self.cache.set_json(cache_key, payload) + return payload + + async def get_options_chain_for_expiry(self, symbol: str | None = None, expiry: str | None = None) -> dict[str, Any]: + ticker_symbol = (symbol or self.default_symbol).upper() + expirations_data = await self.get_option_expirations(ticker_symbol) + expirations = list(expirations_data.get("expirations") or []) + target_expiry = expiry or (expirations[0] if expirations else None) + quote = await self.get_quote(ticker_symbol) + + if not target_expiry: + return self._fallback_options_chain( + ticker_symbol, + quote, + expirations=expirations, + selected_expiry=None, + source=expirations_data.get("source", quote.get("source", "fallback")), + error=expirations_data.get("error"), + ) + + cache_key = f"options:{ticker_symbol}:{target_expiry}" + cached = await self.cache.get_json(cache_key) + if cached and isinstance(cached, dict): + return cached + + if yf is None: + payload = self._fallback_options_chain( + ticker_symbol, + quote, + expirations=expirations, + selected_expiry=target_expiry, + source="fallback", + error="yfinance is not installed", + ) + await self.cache.set_json(cache_key, payload) + return payload + + try: + ticker = yf.Ticker(ticker_symbol) + chain = await asyncio.to_thread(ticker.option_chain, target_expiry) + calls = self._normalize_option_rows(chain.calls, ticker_symbol, target_expiry, "call") + puts = self._normalize_option_rows(chain.puts, ticker_symbol, target_expiry, "put") + + if not calls and not puts: + payload = self._fallback_options_chain( + ticker_symbol, + quote, + expirations=expirations, + selected_expiry=target_expiry, + source="fallback", + error="No option contracts returned by yfinance", + ) + await self.cache.set_json(cache_key, payload) + return payload + + payload = { + "symbol": ticker_symbol, + "selected_expiry": target_expiry, + "updated_at": datetime.now(UTC).isoformat(), + "expirations": expirations, + "calls": calls, + "puts": puts, + "rows": sorted(calls + puts, key=lambda row: (row["strike"], row["type"])), + "underlying_price": quote["price"], + "source": "yfinance", + } + await self.cache.set_json(cache_key, payload) + return payload + except Exception as exc: # pragma: no cover - network dependent + logger.warning("Failed to fetch options chain for %s %s from yfinance: %s", ticker_symbol, target_expiry, exc) + payload = self._fallback_options_chain( + ticker_symbol, + quote, + expirations=expirations, + selected_expiry=target_expiry, + source="fallback", + error=str(exc), + ) + await self.cache.set_json(cache_key, payload) + return payload + + async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]: + ticker_symbol = (symbol or self.default_symbol).upper() + expirations_data = await self.get_option_expirations(ticker_symbol) + expirations = list(expirations_data.get("expirations") or []) + if not expirations: + quote = await self.get_quote(ticker_symbol) + return self._fallback_options_chain( + ticker_symbol, + quote, + expirations=[], + selected_expiry=None, + source=expirations_data.get("source", quote.get("source", "fallback")), + error=expirations_data.get("error"), + ) + return await self.get_options_chain_for_expiry(ticker_symbol, expirations[0]) async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]: ticker = (symbol or self.default_symbol).upper() @@ -184,7 +254,7 @@ class DataService: logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc) return self._fallback_quote(symbol, source="fallback") - def _fallback_options_chain( + def _fallback_option_expirations( self, symbol: str, quote: dict[str, Any], @@ -192,10 +262,32 @@ class DataService: source: str, error: str | None = None, ) -> dict[str, Any]: - options_chain = { + payload = { "symbol": symbol, "updated_at": datetime.now(UTC).isoformat(), "expirations": [], + "underlying_price": quote["price"], + "source": source, + } + if error: + payload["error"] = error + return payload + + def _fallback_options_chain( + self, + symbol: str, + quote: dict[str, Any], + *, + expirations: list[str], + selected_expiry: str | None, + source: str, + error: str | None = None, + ) -> dict[str, Any]: + options_chain = { + "symbol": symbol, + "selected_expiry": selected_expiry, + "updated_at": datetime.now(UTC).isoformat(), + "expirations": expirations, "calls": [], "puts": [], "rows": [], diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 9949915..b3e24b1 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -237,11 +237,46 @@ DATA-001 (Price Feed) --- +## Fast Follow-up Backlog + +### DATA-002A: Lazy Options Loading [P0, S] **[depends: DATA-002]** +**As a** trader, **I want** the options page to render immediately **so that** it feels responsive and usable. + +**Acceptance Criteria:** +- Initial page load fetches only expirations plus one default expiry chain +- Changing expiry fetches that expiry on demand +- Browser test verifies `/options` becomes visible quickly +- No visible 500/runtime error during page load + +**Dependencies:** DATA-002 + +### DATA-001A: Live Overview Price Wiring [P0, S] **[depends: DATA-001, PORT-001]** +**As a** portfolio manager, **I want** the overview cards to use live quote data **so that** the displayed spot/LTV values are trustworthy. + +**Acceptance Criteria:** +- Overview page uses live quote from service instead of hardcoded `215.0` +- Display source and last updated timestamp +- Margin call / LTV calculations use configured portfolio values +- Browser test verifies overview renders with live data metadata + +**Dependencies:** DATA-001, PORT-001 + +### OPS-001: Caddy Route for Production Dashboard [P1, S] **[depends: deploy-stable]** +**As a** VPN user, **I want** to reach the deployed dashboard at `vd1.uncloud.vpn` **so that** I can access it without SSH port forwarding. + +**Acceptance Criteria:** +- Caddy route proxies `vd1.uncloud.vpn` to local deployment container +- Route works over the VPN only +- Health check succeeds through Caddy +- Deployment docs include the route and where it lives + +**Dependencies:** stable deployed app on VPS + ## Implementation Priority Queue -1. **DATA-001** - Unblock all other features -2. **PORT-001** - Enable user-specific calculations -3. **DATA-002** - Core options data +1. **DATA-002A** - Fix options UX/performance regression +2. **DATA-001A** - Remove misleading mock overview price +3. **OPS-001** - Add Caddy route once app behavior is stable 4. **DATA-003** - Risk metrics 5. **PORT-002** - Risk management safety 6. **EXEC-001** - Core user workflow diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 6aa7b87..bc82f74 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -18,6 +18,7 @@ def test_homepage_and_options_page_render() -> None: expect(page).to_have_title("NiceGUI") expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000) expect(page.locator("text=Overview").first).to_be_visible(timeout=10000) + expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000) page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True) page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)