188 lines
8.3 KiB
Python
188 lines
8.3 KiB
Python
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)
|
|
|
|
|
|
def split_page_panes(*, left_testid: str, right_testid: str) -> tuple[ui.column, ui.column]:
|
|
"""Render responsive page panes with a desktop 1:2 flex split and stable test hooks."""
|
|
with ui.row().classes("w-full items-start gap-6 max-lg:flex-col lg:flex-nowrap"):
|
|
left = ui.column().classes("min-w-0 w-full gap-6 lg:flex-[1_1_0%]").props(f"data-testid={left_testid}")
|
|
right = ui.column().classes("min-w-0 w-full gap-6 lg:flex-[2_1_0%]").props(f"data-testid={right_testid}")
|
|
return left, right
|
|
|
|
|
|
@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("w-full 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")
|