- 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
515 lines
28 KiB
Python
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"
|
|
)
|