from __future__ import annotations from datetime import UTC, datetime from nicegui import ui from app.components import PortfolioOverview 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") 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}", f"{symbol} live quote via {portfolio['quote_source']}", ), ( "Margin Call Price", f"${portfolio['margin_call_price']:,.2f}", "Implied trigger level from persisted portfolio settings", ), ( "Cash Buffer", f"${portfolio['cash_buffer']:,.0f}", "Base liquidity plus unrealized gain cushion vs configured baseline", ), ( "Hedge Budget", f"${portfolio['hedge_budget']:,.0f}", "Monthly budget from saved settings", ), ] for title, value, caption in summary_cards: with ui.card().classes( "rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400") 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=float(portfolio["margin_call_ltv"])) portfolio_view.update(portfolio) with ui.row().classes("w-full gap-6 max-lg:flex-col"): with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): 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 {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=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 {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." ).classes("text-sm font-medium text-amber-700 dark:text-amber-300") with ui.card().classes( "w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") for strategy in strategy_catalog(): with ui.row().classes( "w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800" ): with ui.column().classes("gap-1"): ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100") ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes( "rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/15 dark:text-sky-300" ) 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(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")