from __future__ import annotations import logging from datetime import datetime, timezone from fastapi import Request from fastapi.responses import RedirectResponse from nicegui import ui from app.components import PortfolioOverview from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.models.ltv_history import LtvHistoryRepository from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository from app.pages.common import ( dashboard_page, quick_recommendations, recommendation_style, split_page_panes, strategy_catalog, ) from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.ltv_history import LtvHistoryChartModel, LtvHistoryService from app.services.runtime import get_data_service from app.services.turnstile import load_turnstile_settings logger = logging.getLogger(__name__) _DEFAULT_CASH_BUFFER = 18_500.0 def _resolve_overview_spot( config, quote: dict[str, object], *, fallback_symbol: str | None = None ) -> tuple[float, str, str]: return resolve_portfolio_spot_from_quote(config, quote, fallback_symbol=fallback_symbol) 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(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") def _alert_badge_classes(severity: str) -> str: return { "critical": "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", "warning": "rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-700 dark:bg-amber-500/15 dark:text-amber-300", "ok": "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", }.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700") def _ltv_chart_options(model: LtvHistoryChartModel) -> dict: return { "tooltip": {"trigger": "axis", "valueFormatter": "function (value) { return value + '%'; }"}, "legend": {"data": ["LTV", "Margin threshold"]}, "xAxis": {"type": "category", "data": list(model.labels)}, "yAxis": {"type": "value", "name": "LTV %", "axisLabel": {"formatter": "{value}%"}}, "series": [ { "name": "LTV", "type": "line", "smooth": True, "data": list(model.ltv_values), "lineStyle": {"width": 3}, }, { "name": "Margin threshold", "type": "line", "data": list(model.threshold_values), "lineStyle": {"type": "dashed", "width": 2}, "symbol": "none", }, ], } def _render_workspace_recovery(title: str, message: str) -> None: with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"): ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400") ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label(message).classes("text-base text-slate-500 dark:text-slate-400") with ui.row().classes("mx-auto gap-3"): ui.link("Get started", "/").classes( "rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900" ) ui.link("Go to welcome page", "/").classes( "rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200" ) @ui.page("/") def welcome_page(request: Request): repo = get_workspace_repository() workspace_id = request.cookies.get(WORKSPACE_COOKIE, "") if workspace_id and repo.workspace_exists(workspace_id): return RedirectResponse(url=f"/{workspace_id}", status_code=307) captcha_error = request.query_params.get("captcha_error") == "1" with ui.column().classes("mx-auto mt-24 w-full max-w-3xl gap-8 px-6"): with ui.card().classes( "w-full rounded-3xl border border-slate-200 bg-white p-8 shadow-sm dark:border-slate-800 dark:bg-slate-900" ): ui.label("Vault Dashboard").classes("text-sm font-semibold uppercase tracking-[0.2em] text-sky-600") ui.label("Create a private workspace URL").classes("text-4xl font-bold text-slate-900 dark:text-slate-50") ui.label( "Start with a workspace-scoped overview and settings area. Your portfolio defaults are stored server-side and your browser keeps a workspace cookie for quick return visits." ).classes("text-base text-slate-500 dark:text-slate-400") if captcha_error: ui.label("CAPTCHA verification failed. Please retry the Turnstile challenge.").classes( "rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm font-medium text-rose-700 dark:border-rose-900/60 dark:bg-rose-950/30 dark:text-rose-300" ) with ui.row().classes("items-center gap-4 pt-4"): turnstile = load_turnstile_settings() ui.add_body_html( '' ) hidden_token = ( '' if turnstile.uses_test_keys else "" ) ui.html(f"""
""") ui.label("You can always create a fresh workspace later if a link is lost.").classes( "text-sm text-slate-500 dark:text-slate-400" ) @ui.page("/{workspace_id}") @ui.page("/{workspace_id}/overview") async def overview_page(workspace_id: str) -> None: repo = get_workspace_repository() if not repo.workspace_exists(workspace_id): return RedirectResponse(url="/", status_code=307) config = repo.load_portfolio_config(workspace_id) data_service = get_data_service() symbol = data_service.default_symbol quote = await data_service.get_quote(symbol) overview_spot_price, overview_source, overview_updated_at = _resolve_overview_spot( config, quote, fallback_symbol=symbol ) portfolio = build_portfolio_alert_context( config, spot_price=overview_spot_price, source=overview_source, updated_at=overview_updated_at, ) configured_gold_value = float(config.gold_value or 0.0) portfolio["cash_buffer"] = max(float(portfolio["gold_value"]) - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER portfolio["hedge_budget"] = float(config.monthly_budget) alert_status = AlertService().evaluate(config, portfolio) ltv_history_service = LtvHistoryService(repository=LtvHistoryRepository(base_path=repo.base_path)) ltv_history_notice: str | None = None try: ltv_history = ltv_history_service.record_workspace_snapshot(workspace_id, portfolio) ltv_chart_models = tuple( ltv_history_service.chart_model( ltv_history, days=days, current_margin_threshold=config.margin_threshold, ) for days in (7, 30, 90) ) ltv_history_csv = ltv_history_service.export_csv(ltv_history) if ltv_history else "" except Exception: logger.exception("Failed to prepare LTV history for workspace %s", workspace_id) ltv_history = [] ltv_chart_models = () ltv_history_csv = "" ltv_history_notice = "Historical LTV is temporarily unavailable due to a storage error." if portfolio["quote_source"] == "configured_entry_price": quote_status = "Live quote source: configured entry price fallback · Last updated Unavailable" else: quote_status = ( f"Live quote source: {portfolio['quote_source']} · " f"GLD share quote converted to ozt-equivalent spot · " f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" ) spot_caption = ( f"{symbol} share quote converted to USD/ozt via {portfolio['quote_source']}" if portfolio["quote_source"] != "configured_entry_price" else "Configured entry price fallback in USD/ozt" ) with dashboard_page( "Overview", "Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.", "overview", workspace_id=workspace_id, ): 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") left_pane, right_pane = split_page_panes( left_testid="overview-left-pane", right_testid="overview-right-pane", ) with left_pane: 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 gap-3"): ui.label("Alert Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label(alert_status.severity.upper()).classes(_alert_badge_classes(alert_status.severity)) ui.label(alert_status.message).classes("text-sm text-slate-600 dark:text-slate-300") ui.label( f"Warning at {alert_status.warning_threshold:.0%} · Critical at {alert_status.critical_threshold:.0%} · " f"Email alerts {'enabled' if alert_status.email_alerts_enabled else 'disabled'}" ).classes("text-sm text-slate-500 dark:text-slate-400") if alert_status.history_notice: ui.label(alert_status.history_notice).classes("text-sm text-amber-700 dark:text-amber-300") if alert_status.history: latest = alert_status.history[0] ui.label( f"Latest alert logged {_format_timestamp(latest.updated_at)} at collateral spot ${latest.spot_price:,.2f}" ).classes("text-xs text-slate-500 dark:text-slate-400") 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("Portfolio Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): summary_cards = [ ( "Collateral Spot Price", f"${portfolio['spot_price']:,.2f}", spot_caption, ), ( "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-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" ): ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400") ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-50") ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400") with ui.card().classes(f"w-full rounded-2xl border shadow-sm {recommendation_style('info')}"): ui.label("Quick Strategy Recommendations").classes( "text-lg font-semibold text-slate-900 dark:text-slate-100" ) for rec in quick_recommendations(portfolio): with ui.card().classes(f"rounded-xl border shadow-none {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") with right_pane: portfolio_view = PortfolioOverview(margin_call_ltv=float(portfolio["margin_call_ltv"])) portfolio_view.update(portfolio) with ui.row().classes("w-full gap-6 max-xl: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" ): with ui.row().classes( "w-full items-center justify-between gap-3 max-sm:flex-col max-sm:items-start" ): with ui.column().classes("gap-1"): ui.label("Historical LTV").classes( "text-lg font-semibold text-slate-900 dark:text-slate-100" ) ui.label( "Stored workspace snapshots show how LTV trended against the current margin threshold over 7, 30, and 90 day windows." ).classes("text-sm text-slate-500 dark:text-slate-400") if ltv_history: ui.button( "Export CSV", icon="download", on_click=lambda: ui.download.content( ltv_history_csv, filename=f"{workspace_id}-ltv-history.csv", media_type="text/csv", ), ).props("outline color=primary") if ltv_history_notice: ui.label(ltv_history_notice).classes("text-sm text-amber-700 dark:text-amber-300") elif ltv_history: with ui.grid(columns=1).classes("w-full gap-4 xl:grid-cols-3"): for chart_model, chart_testid in zip( ltv_chart_models, ("ltv-history-chart-7d", "ltv-history-chart-30d", "ltv-history-chart-90d"), strict=True, ): with ui.card().classes( "rounded-xl border border-slate-200 bg-slate-50 p-4 shadow-none dark:border-slate-800 dark:bg-slate-950" ): ui.label(chart_model.title).classes( "text-base font-semibold text-slate-900 dark:text-slate-100" ) ui.echart(_ltv_chart_options(chart_model)).props( f"data-testid={chart_testid}" ).classes("h-56 w-full") else: ui.label("No LTV snapshots recorded yet.").classes("text-sm text-slate-500 dark:text-slate-400") 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("Recent Alert History").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") if alert_status.history: for event in alert_status.history[:5]: 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(event.message).classes( "text-sm font-medium text-slate-900 dark:text-slate-100" ) ui.label( f"Logged {_format_timestamp(event.updated_at)} · Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}" ).classes("text-xs text-slate-500 dark:text-slate-400") ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity)) elif alert_status.history_notice: ui.label(alert_status.history_notice).classes("text-sm text-amber-700 dark:text-amber-300") else: ui.label( "No alert history yet. Alerts will be logged once the warning threshold is crossed." ).classes("text-sm text-slate-500 dark:text-slate-400") 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" )