Files
vault-dash/app/pages/overview.py
Bu5hm4nn 6bcf78e5df style: format UI files and remove lint excludes
- Remove app/components/ and app/pages/ from ruff/black excludes
- Pre-commit reformatted multi-line strings for consistency
- All files now follow the same code style
2026-04-01 13:55:55 +02:00

515 lines
28 KiB
Python

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()
ui.add_body_html(
'<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script>'
)
hidden_token = (
'<input type="hidden" name="cf-turnstile-response" value="test-token" />'
if turnstile.uses_test_keys
else ""
)
ui.html(
f"""<form method="post" action="/workspaces/bootstrap" class="flex items-center gap-4">
{hidden_token}
<div class="cf-turnstile" data-sitekey="{turnstile.site_key}"></div>
<button type="submit" class="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">Get started</button>
</form>"""
)
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"
)