from __future__ import annotations import logging from datetime import datetime, timezone from decimal import Decimal 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.storage_costs import calculate_total_storage_cost 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() if turnstile.uses_test_keys: ui.html("""
""") else: ui.add_body_html( '' ) 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() underlying = config.underlying or "GLD" symbol = underlying 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, ) # Fetch basis data for GLD/GC=F comparison try: basis_data = await data_service.get_basis_data() except Exception: logger.exception("Failed to fetch basis data") basis_data = None 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) # Calculate storage costs for positions positions = config.positions current_values: dict[str, Decimal] = {} for pos in positions: # Use entry value as proxy for current value (would need live prices for accurate calc) current_values[str(pos.id)] = pos.entry_value total_annual_storage_cost = calculate_total_storage_cost(positions, current_values) portfolio["annual_storage_cost"] = float(total_annual_storage_cost) portfolio["storage_cost_pct"] = ( (float(total_annual_storage_cost) / float(portfolio["gold_value"]) * 100) if portfolio["gold_value"] > 0 else 0.0 ) 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." display_mode = portfolio.get("display_mode", "XAU") if portfolio["quote_source"] == "configured_entry_price": if display_mode == "GLD": quote_status = "Live quote source: configured entry price fallback (GLD shares) · Last updated Unavailable" else: quote_status = "Live quote source: configured entry price fallback · Last updated Unavailable" else: if display_mode == "GLD": quote_status = ( f"Live quote source: {portfolio['quote_source']} (GLD share price) · " f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}" ) 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']))}" ) if display_mode == "GLD": spot_caption = ( f"{symbol} share price via {portfolio['quote_source']}" if portfolio["quote_source"] != "configured_entry_price" else "Configured GLD share entry price" ) else: 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", f"Portfolio health, LTV risk, and quick strategy guidance for the current {underlying}-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"Active underlying: {underlying} · 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: # GLD/GC=F Basis Card 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("GLD/GC=F Basis").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") if basis_data: basis_badge_class = { "green": "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", "yellow": "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", "red": "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", }.get( basis_data["basis_status"], "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700", ) ui.label(f"{basis_data['basis_label']} ({basis_data['basis_bps']:+.1f} bps)").classes( basis_badge_class ) if basis_data: with ui.grid(columns=2).classes("w-full gap-4 mt-4"): # GLD Implied Spot 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("GLD Implied Spot").classes( "text-sm font-medium text-slate-500 dark:text-slate-400" ) ui.label(f"${basis_data['gld_implied_spot']:,.2f}/oz").classes( "text-2xl font-bold text-slate-900 dark:text-slate-50" ) ui.label( f"GLD ${basis_data['gld_price']:.2f} ÷ {basis_data['gld_ounces_per_share']:.4f} oz/share" ).classes("text-xs text-slate-500 dark:text-slate-400") # GC=F Adjusted 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("GC=F Adjusted").classes("text-sm font-medium text-slate-500 dark:text-slate-400") ui.label(f"${basis_data['gc_f_adjusted']:,.2f}/oz").classes( "text-2xl font-bold text-slate-900 dark:text-slate-50" ) ui.label( f"GC=F ${basis_data['gc_f_price']:.2f} - ${basis_data['contango_estimate']:.0f} contango" ).classes("text-xs text-slate-500 dark:text-slate-400") # Basis explanation and after-hours notice with ui.row().classes("w-full items-start gap-2 mt-4"): ui.icon("info", size="xs").classes("text-slate-400 mt-0.5") ui.label( "Basis shows the premium/discount between GLD-implied gold and futures-adjusted spot. " "Green < 25 bps (normal), Yellow 25-50 bps (elevated), Red > 50 bps (unusual)." ).classes("text-xs text-slate-500 dark:text-slate-400") if basis_data["after_hours"]: with ui.row().classes("w-full items-start gap-2 mt-2"): ui.icon("schedule", size="xs").classes("text-amber-500 mt-0.5") ui.label( f"{basis_data['after_hours_note']} · GLD: {_format_timestamp(basis_data['gld_updated_at'])} · " f"GC=F: {_format_timestamp(basis_data['gc_f_updated_at'])}" ).classes("text-xs text-amber-700 dark:text-amber-300") # Warning for elevated basis if basis_data["basis_status"] == "red": ui.label( f"⚠️ Elevated basis detected: {basis_data['basis_bps']:+.1f} bps. " "This may indicate after-hours pricing gaps, physical stress, or arbitrage disruption." ).classes("text-sm font-medium text-rose-700 dark:text-rose-300 mt-3") else: ui.label("Basis data temporarily unavailable").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" ): 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") # Display mode-aware labels if display_mode == "GLD": spot_label = "GLD Share Price" spot_unit = "/share" margin_label = "Margin Call Share Price" else: spot_label = "Collateral Spot Price" spot_unit = "/oz" margin_label = "Margin Call Price" with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"): summary_cards = [ ( spot_label, f"${portfolio['spot_price']:,.2f}{spot_unit}", spot_caption, ), ( margin_label, 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", ), ( "Storage Costs", f"${portfolio['annual_storage_cost']:,.2f}/yr ({portfolio['storage_cost_pct']:.2f}%)", "Annual vault storage for physical positions (GLD expense ratio baked into share price)", ), ] 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" )