from __future__ import annotations from collections.abc import Iterator from contextlib import contextmanager from typing import Any from nicegui import ui from app.domain.portfolio_math import portfolio_snapshot_from_config, strategy_metrics_from_snapshot from app.models.portfolio import PortfolioConfig from app.services.strategy_templates import StrategyTemplateService NAV_ITEMS: list[tuple[str, str, str]] = [ ("welcome", "/", "Welcome"), ("options", "/options", "Options Chain"), ] def nav_items(workspace_id: str | None = None) -> list[tuple[str, str, str]]: if not workspace_id: return NAV_ITEMS return [ ("overview", f"/{workspace_id}", "Overview"), ("hedge", f"/{workspace_id}/hedge", "Hedge Analysis"), ("options", "/options", "Options Chain"), ("backtests", f"/{workspace_id}/backtests", "Backtests"), ("event-comparison", f"/{workspace_id}/event-comparison", "Event Comparison"), ("settings", f"/{workspace_id}/settings", "Settings"), ] def demo_spot_price() -> float: return 215.0 def portfolio_snapshot( config: PortfolioConfig | None = None, *, runtime_spot_price: float | None = None, ) -> dict[str, float]: return portfolio_snapshot_from_config(config, runtime_spot_price=runtime_spot_price) def strategy_catalog() -> list[dict[str, Any]]: return StrategyTemplateService().catalog_items() def quick_recommendations(portfolio: dict[str, Any] | None = None) -> list[dict[str, str]]: portfolio = portfolio or portfolio_snapshot() ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100 return [ { "title": "Balanced hedge favored", "summary": "A 95% protective put balances margin-call protection with a lower upfront hedge cost.", "tone": "positive", }, { "title": f"{ltv_gap:.1f} pts LTV headroom", "summary": "You still have room before a margin trigger, so prefer cost-efficient protection over maximum convexity.", "tone": "info", }, { "title": "Roll window approaching", "summary": "Stage long-dated puts now and keep a near-dated layer for event risk over the next quarter.", "tone": "warning", }, ] def option_chain() -> list[dict[str, Any]]: spot = demo_spot_price() expiries = ["2026-04-17", "2026-06-19", "2026-09-18"] strikes = [190.0, 200.0, 210.0, 215.0, 220.0, 230.0] rows: list[dict[str, Any]] = [] for expiry in expiries: for strike in strikes: distance = (strike - spot) / spot for option_type in ("put", "call"): premium_base = 8.2 if option_type == "put" else 7.1 premium = round( max( 1.1, premium_base - abs(distance) * 18 + (0.8 if expiry == "2026-09-18" else 0.0), ), 2, ) delta = round((0.5 - distance * 1.8) * (-1 if option_type == "put" else 1), 3) rows.append( { "symbol": f"GLD {expiry} {option_type.upper()} {strike:.0f}", "expiry": expiry, "type": option_type, "strike": strike, "premium": premium, "bid": round(max(premium - 0.18, 0.5), 2), "ask": round(premium + 0.18, 2), "open_interest": int(200 + abs(spot - strike) * 14), "volume": int(75 + abs(spot - strike) * 8), "delta": max(-0.95, min(0.95, delta)), "gamma": round(max(0.012, 0.065 - abs(distance) * 0.12), 3), "theta": round(-0.014 - abs(distance) * 0.025, 3), "vega": round(0.09 + max(0.0, 0.24 - abs(distance) * 0.6), 3), "rho": round( (0.04 + abs(distance) * 0.09) * (-1 if option_type == "put" else 1), 3, ), } ) return rows def strategy_metrics( strategy_name: str, scenario_pct: int, *, portfolio: dict[str, Any] | None = None, ) -> dict[str, Any]: strategy = next( (item for item in strategy_catalog() if item["name"] == strategy_name), strategy_catalog()[0], ) portfolio = portfolio or portfolio_snapshot() return strategy_metrics_from_snapshot(strategy, scenario_pct, portfolio) @contextmanager def dashboard_page(title: str, subtitle: str, current: str, workspace_id: str | None = None) -> Iterator[ui.column]: ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9") # Header must be at page level, not inside container with ui.header(elevated=False).classes( "items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90" ): with ui.row().classes("items-center gap-3"): ui.icon("shield").classes("text-2xl text-sky-500") with ui.column().classes("gap-0"): ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50") ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400") with ui.row().classes("items-center gap-2 max-sm:flex-wrap"): for key, href, label in nav_items(workspace_id): active = key == current link_classes = "rounded-lg px-4 py-2 text-sm font-medium no-underline transition " + ( "bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900" if active else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800" ) ui.link(label, href).classes(link_classes) with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container: with ui.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"): with ui.column().classes("gap-1"): ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label(subtitle).classes("text-slate-500 dark:text-slate-400") yield container def render_workspace_recovery(title: str = "Workspace not found", message: str | None = None) -> None: resolved_message = ( message or "The requested workspace is unavailable. Start a new workspace or return to the welcome page." ) 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(resolved_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" ) def recommendation_style(tone: str) -> str: return { "positive": "border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/30", "warning": "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30", "info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30", }.get(tone, "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900")