Initial commit: Vault Dashboard for options hedging
- FastAPI + NiceGUI web application - QuantLib-based Black-Scholes pricing with Greeks - Protective put, laddered, and LEAPS strategies - Real-time WebSocket updates - TradingView-style charts via Lightweight-Charts - Docker containerization - GitLab CI/CD pipeline for VPS deployment - VPN-only access configuration
This commit is contained in:
203
app/pages/common.py
Normal file
203
app/pages/common.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
NAV_ITEMS: list[tuple[str, str, str]] = [
|
||||
("overview", "/", "Overview"),
|
||||
("hedge", "/hedge", "Hedge Analysis"),
|
||||
("options", "/options", "Options Chain"),
|
||||
("settings", "/settings", "Settings"),
|
||||
]
|
||||
|
||||
|
||||
def demo_spot_price() -> float:
|
||||
return 215.0
|
||||
|
||||
|
||||
def portfolio_snapshot() -> dict[str, float]:
|
||||
gold_units = 1_000.0
|
||||
spot = demo_spot_price()
|
||||
gold_value = gold_units * spot
|
||||
loan_amount = 145_000.0
|
||||
margin_call_ltv = 0.75
|
||||
return {
|
||||
"gold_value": gold_value,
|
||||
"loan_amount": loan_amount,
|
||||
"ltv_ratio": loan_amount / gold_value,
|
||||
"net_equity": gold_value - loan_amount,
|
||||
"spot_price": spot,
|
||||
"margin_call_ltv": margin_call_ltv,
|
||||
"margin_call_price": loan_amount / (margin_call_ltv * gold_units),
|
||||
"cash_buffer": 18_500.0,
|
||||
"hedge_budget": 8_000.0,
|
||||
}
|
||||
|
||||
|
||||
def strategy_catalog() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "protective_put",
|
||||
"label": "Protective Put",
|
||||
"description": "Full downside protection below the hedge strike with uncapped upside.",
|
||||
"estimated_cost": 6.25,
|
||||
"max_drawdown_floor": 210.0,
|
||||
"coverage": "High",
|
||||
},
|
||||
{
|
||||
"name": "collar",
|
||||
"label": "Collar",
|
||||
"description": "Lower premium by financing puts with covered call upside caps.",
|
||||
"estimated_cost": 2.10,
|
||||
"max_drawdown_floor": 208.0,
|
||||
"upside_cap": 228.0,
|
||||
"coverage": "Balanced",
|
||||
},
|
||||
{
|
||||
"name": "laddered_puts",
|
||||
"label": "Laddered Puts",
|
||||
"description": "Multiple maturities and strikes reduce roll concentration and smooth protection.",
|
||||
"estimated_cost": 4.45,
|
||||
"max_drawdown_floor": 205.0,
|
||||
"coverage": "Layered",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def quick_recommendations() -> list[dict[str, str]]:
|
||||
portfolio = portfolio_snapshot()
|
||||
ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100
|
||||
return [
|
||||
{
|
||||
"title": "Balanced hedge favored",
|
||||
"summary": "A collar keeps the current LTV comfortably below the margin threshold while limiting upfront spend.",
|
||||
"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) -> dict[str, Any]:
|
||||
strategy = next((item for item in strategy_catalog() if item["name"] == strategy_name), strategy_catalog()[0])
|
||||
spot = demo_spot_price()
|
||||
floor = float(strategy.get("max_drawdown_floor", spot * 0.95))
|
||||
cap = strategy.get("upside_cap")
|
||||
cost = float(strategy["estimated_cost"])
|
||||
|
||||
scenario_prices = [round(spot * (1 + pct / 100), 2) for pct in range(-25, 30, 5)]
|
||||
benefits: list[float] = []
|
||||
for price in scenario_prices:
|
||||
payoff = max(floor - price, 0.0)
|
||||
if isinstance(cap, (int, float)) and price > float(cap):
|
||||
payoff -= price - float(cap)
|
||||
benefits.append(round(payoff - cost, 2))
|
||||
|
||||
scenario_price = round(spot * (1 + scenario_pct / 100), 2)
|
||||
unhedged_equity = scenario_price * 1_000 - 145_000.0
|
||||
scenario_payoff = max(floor - scenario_price, 0.0)
|
||||
capped_upside = 0.0
|
||||
if isinstance(cap, (int, float)) and scenario_price > float(cap):
|
||||
capped_upside = -(scenario_price - float(cap))
|
||||
hedged_equity = unhedged_equity + scenario_payoff + capped_upside - cost * 1_000
|
||||
|
||||
waterfall_steps = [
|
||||
("Base equity", round(70_000.0, 2)),
|
||||
("Spot move", round((scenario_price - spot) * 1_000, 2)),
|
||||
("Option payoff", round(scenario_payoff * 1_000, 2)),
|
||||
("Call cap", round(capped_upside * 1_000, 2)),
|
||||
("Hedge cost", round(-cost * 1_000, 2)),
|
||||
("Net equity", round(hedged_equity, 2)),
|
||||
]
|
||||
|
||||
return {
|
||||
"strategy": strategy,
|
||||
"scenario_pct": scenario_pct,
|
||||
"scenario_price": scenario_price,
|
||||
"scenario_series": [{"price": price, "benefit": benefit} for price, benefit in zip(scenario_prices, benefits, strict=True)],
|
||||
"waterfall_steps": waterfall_steps,
|
||||
"unhedged_equity": round(unhedged_equity, 2),
|
||||
"hedged_equity": round(hedged_equity, 2),
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]:
|
||||
ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9")
|
||||
|
||||
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.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:
|
||||
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.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 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")
|
||||
Reference in New Issue
Block a user