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:
Bu5hm4nn
2026-03-21 19:21:40 +01:00
commit 00a68bc767
63 changed files with 6239 additions and 0 deletions

1
app/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Vault dashboard application package."""

28
app/api/routes.py Normal file
View File

@@ -0,0 +1,28 @@
"""API routes for dashboard and strategy data."""
from __future__ import annotations
from fastapi import APIRouter, Depends, Request
from app.services.data_service import DataService
router = APIRouter(prefix="/api", tags=["api"])
def get_data_service(request: Request) -> DataService:
return request.app.state.data_service
@router.get("/portfolio")
async def portfolio(symbol: str = "GLD", data_service: DataService = Depends(get_data_service)) -> dict:
return await data_service.get_portfolio(symbol)
@router.get("/options")
async def options(symbol: str = "GLD", data_service: DataService = Depends(get_data_service)) -> dict:
return await data_service.get_options_chain(symbol)
@router.get("/strategies")
async def strategies(symbol: str = "GLD", data_service: DataService = Depends(get_data_service)) -> dict:
return await data_service.get_strategies(symbol)

View File

@@ -0,0 +1,13 @@
"""Reusable NiceGUI dashboard components for the Vault Dashboard."""
from .charts import CandlestickChart
from .greeks_table import GreeksTable
from .portfolio_view import PortfolioOverview
from .strategy_panel import StrategyComparisonPanel
__all__ = [
"CandlestickChart",
"GreeksTable",
"PortfolioOverview",
"StrategyComparisonPanel",
]

182
app/components/charts.py Normal file
View File

@@ -0,0 +1,182 @@
from __future__ import annotations
import json
from typing import Any
from uuid import uuid4
from nicegui import ui
_CHARTS_SCRIPT_ADDED = False
def _ensure_lightweight_charts_assets() -> None:
global _CHARTS_SCRIPT_ADDED
if _CHARTS_SCRIPT_ADDED:
return
ui.add_head_html(
"""
<script src="https://unpkg.com/lightweight-charts/dist/lightweight-charts.standalone.production.js"></script>
"""
)
_CHARTS_SCRIPT_ADDED = True
class CandlestickChart:
"""Minimal Lightweight-Charts wrapper for NiceGUI candlestick dashboards.
Features:
- real-time candlestick price updates
- volume histogram overlay
- moving-average / indicator line support
"""
def __init__(self, title: str = "Gold Price", *, height: int = 420) -> None:
_ensure_lightweight_charts_assets()
self.chart_id = f"chart_{uuid4().hex}"
self.height = height
with ui.card().classes("w-full rounded-2xl border border-slate-800 bg-slate-950/90 shadow-xl"):
with ui.row().classes("w-full items-center justify-between"):
ui.label(title).classes("text-lg font-semibold text-white")
ui.label("Live").classes(
"rounded-full bg-emerald-500/15 px-3 py-1 text-xs font-medium uppercase tracking-wide text-emerald-300"
)
self.container = ui.html(f'<div id="{self.chart_id}" class="w-full rounded-xl"></div>').style(
f"height: {height}px;"
)
self._initialize_chart()
def _initialize_chart(self) -> None:
ui.run_javascript(
f"""
(function() {{
const root = document.getElementById({json.dumps(self.chart_id)});
if (!root || typeof LightweightCharts === 'undefined') return;
root.innerHTML = '';
window.vaultDashCharts = window.vaultDashCharts || {{}};
const chart = LightweightCharts.createChart(root, {{
autoSize: true,
layout: {{
background: {{ color: '#020617' }},
textColor: '#cbd5e1',
}},
grid: {{
vertLines: {{ color: 'rgba(148, 163, 184, 0.12)' }},
horzLines: {{ color: 'rgba(148, 163, 184, 0.12)' }},
}},
rightPriceScale: {{ borderColor: 'rgba(148, 163, 184, 0.25)' }},
timeScale: {{ borderColor: 'rgba(148, 163, 184, 0.25)' }},
crosshair: {{ mode: LightweightCharts.CrosshairMode.Normal }},
}});
const candleSeries = chart.addSeries(LightweightCharts.CandlestickSeries, {{
upColor: '#22c55e',
downColor: '#ef4444',
borderVisible: false,
wickUpColor: '#22c55e',
wickDownColor: '#ef4444',
}});
const volumeSeries = chart.addSeries(LightweightCharts.HistogramSeries, {{
priceFormat: {{ type: 'volume' }},
priceScaleId: '',
scaleMargins: {{ top: 0.78, bottom: 0 }},
color: 'rgba(56, 189, 248, 0.45)',
}});
window.vaultDashCharts[{json.dumps(self.chart_id)}] = {{
chart,
candleSeries,
volumeSeries,
indicators: {{}},
}};
}})();
"""
)
def set_candles(self, candles: list[dict[str, Any]]) -> None:
payload = json.dumps(candles)
ui.run_javascript(
f"""
(function() {{
const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}];
if (!ref) return;
ref.candleSeries.setData({payload});
ref.chart.timeScale().fitContent();
}})();
"""
)
def update_price(self, candle: dict[str, Any]) -> None:
payload = json.dumps(candle)
ui.run_javascript(
f"""
(function() {{
const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}];
if (!ref) return;
ref.candleSeries.update({payload});
}})();
"""
)
def set_volume(self, volume_points: list[dict[str, Any]]) -> None:
payload = json.dumps(volume_points)
ui.run_javascript(
f"""
(function() {{
const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}];
if (!ref) return;
ref.volumeSeries.setData({payload});
}})();
"""
)
def update_volume(self, volume_point: dict[str, Any]) -> None:
payload = json.dumps(volume_point)
ui.run_javascript(
f"""
(function() {{
const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}];
if (!ref) return;
ref.volumeSeries.update({payload});
}})();
"""
)
def set_indicator(self, name: str, points: list[dict[str, Any]], *, color: str = '#f59e0b', line_width: int = 2) -> None:
key = json.dumps(name)
payload = json.dumps(points)
ui.run_javascript(
f"""
(function() {{
const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}];
if (!ref) return;
if (!ref.indicators[{key}]) {{
ref.indicators[{key}] = ref.chart.addSeries(LightweightCharts.LineSeries, {{
color: {json.dumps(color)},
lineWidth: {line_width},
priceLineVisible: false,
lastValueVisible: true,
}});
}}
ref.indicators[{key}].setData({payload});
}})();
"""
)
def update_indicator(self, name: str, point: dict[str, Any]) -> None:
key = json.dumps(name)
payload = json.dumps(point)
ui.run_javascript(
f"""
(function() {{
const ref = window.vaultDashCharts?.[{json.dumps(self.chart_id)}];
const series = ref?.indicators?.[{key}];
if (!series) return;
series.update({payload});
}})();
"""
)

View File

@@ -0,0 +1,104 @@
from __future__ import annotations
from typing import Any
from nicegui import ui
from app.models.option import OptionContract
class GreeksTable:
"""Live Greeks table with simple risk-level color coding."""
def __init__(self, options: list[OptionContract | dict[str, Any]] | None = None) -> None:
self.options = options or []
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("Option Greeks").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label("Live Risk Snapshot").classes(
"rounded-full bg-violet-100 px-3 py-1 text-xs font-semibold uppercase tracking-wide text-violet-700 dark:bg-violet-500/15 dark:text-violet-300"
)
self.table_html = ui.html("").classes("w-full")
self.set_options(self.options)
def set_options(self, options: list[OptionContract | dict[str, Any]]) -> None:
self.options = options
self.table_html.content = self._render_table()
self.table_html.update()
def _render_table(self) -> str:
rows = [self._row_html(option) for option in self.options]
return f"""
<div class=\"overflow-x-auto\">
<table class=\"min-w-full\">
<thead class=\"bg-slate-100 dark:bg-slate-800\">
<tr>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Option</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Delta</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Gamma</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Theta</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Vega</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Rho</th>
</tr>
</thead>
<tbody>{''.join(rows) if rows else self._empty_row()}</tbody>
</table>
</div>
"""
def _row_html(self, option: OptionContract | dict[str, Any]) -> str:
if isinstance(option, OptionContract):
label = f"{option.option_type.upper()} {option.strike:.2f}"
greeks = {
"delta": option.greeks.delta,
"gamma": option.greeks.gamma,
"theta": option.greeks.theta,
"vega": option.greeks.vega,
"rho": option.greeks.rho,
}
else:
label = str(option.get("label") or option.get("symbol") or option.get("name") or "Option")
greeks = {
greek: float(option.get(greek, option.get("greeks", {}).get(greek, 0.0)))
for greek in ("delta", "gamma", "theta", "vega", "rho")
}
cells = "".join(
f'<td class="px-4 py-3 font-semibold {self._risk_class(name, value)}">{value:+.4f}</td>'
for name, value in greeks.items()
)
return (
'<tr class="border-b border-slate-200 dark:border-slate-800">'
f'<td class="px-4 py-3 font-medium text-slate-900 dark:text-slate-100">{label}</td>'
f'{cells}'
'</tr>'
)
@staticmethod
def _risk_class(name: str, value: float) -> str:
magnitude = abs(value)
if name == "gamma":
if magnitude >= 0.08:
return "text-rose-600 dark:text-rose-400"
if magnitude >= 0.04:
return "text-amber-600 dark:text-amber-400"
return "text-emerald-600 dark:text-emerald-400"
if name == "theta":
if value <= -0.08:
return "text-rose-600 dark:text-rose-400"
if value <= -0.03:
return "text-amber-600 dark:text-amber-400"
return "text-emerald-600 dark:text-emerald-400"
if magnitude >= 0.6:
return "text-rose-600 dark:text-rose-400"
if magnitude >= 0.3:
return "text-amber-600 dark:text-amber-400"
return "text-emerald-600 dark:text-emerald-400"
@staticmethod
def _empty_row() -> str:
return (
'<tr><td colspan="6" class="px-4 py-6 text-center text-slate-500 dark:text-slate-400">'
'No options selected'
'</td></tr>'
)

View File

@@ -0,0 +1,68 @@
from __future__ import annotations
from typing import Any
from nicegui import ui
class PortfolioOverview:
"""Portfolio summary card with LTV risk coloring and margin warning."""
def __init__(self, *, margin_call_ltv: float = 0.75) -> None:
self.margin_call_ltv = margin_call_ltv
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("Portfolio Overview").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
self.warning_badge = ui.label("Monitoring").classes(
"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"
)
with ui.grid(columns=2).classes("w-full gap-4 max-sm:grid-cols-1"):
self.gold_value = self._metric_card("Current Gold Value")
self.loan_amount = self._metric_card("Loan Amount")
self.ltv = self._metric_card("Current LTV")
self.net_equity = self._metric_card("Net Equity")
def _metric_card(self, label: str) -> ui.label:
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(label).classes("text-sm font-medium text-slate-500 dark:text-slate-400")
return ui.label("--").classes("text-2xl font-bold text-slate-900 dark:text-slate-50")
def update(self, portfolio: dict[str, Any], *, margin_call_ltv: float | None = None) -> None:
threshold = margin_call_ltv if margin_call_ltv is not None else portfolio.get("margin_call_ltv", self.margin_call_ltv)
gold_value = float(portfolio.get("gold_value", portfolio.get("portfolio_value", 0.0)))
loan_amount = float(portfolio.get("loan_amount", 0.0))
current_ltv = float(portfolio.get("ltv_ratio", portfolio.get("current_ltv", 0.0)))
net_equity = float(portfolio.get("net_equity", gold_value - loan_amount))
self.gold_value.set_text(self._money(gold_value))
self.loan_amount.set_text(self._money(loan_amount))
self.net_equity.set_text(self._money(net_equity))
self.ltv.set_text(f"{current_ltv * 100:.1f}%")
self.ltv.style(f"color: {self._ltv_color(current_ltv, threshold)}")
badge_text, badge_style = self._warning_state(current_ltv, threshold)
self.warning_badge.set_text(badge_text)
self.warning_badge.style(badge_style)
@staticmethod
def _money(value: float) -> str:
return f"${value:,.2f}"
@staticmethod
def _ltv_color(ltv: float, threshold: float) -> str:
if ltv >= threshold:
return "#f43f5e"
if ltv >= threshold * 0.9:
return "#f59e0b"
return "#22c55e"
@staticmethod
def _warning_state(ltv: float, threshold: float) -> tuple[str, str]:
base = "border-radius: 9999px; padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 600;"
if ltv >= threshold:
return ("Margin call risk", base + " background: rgba(244, 63, 94, 0.14); color: #f43f5e;")
if ltv >= threshold * 0.9:
return ("Approaching threshold", base + " background: rgba(245, 158, 11, 0.14); color: #f59e0b;")
return ("Healthy collateral", base + " background: rgba(34, 197, 94, 0.14); color: #22c55e;")

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from typing import Any
from nicegui import ui
class StrategyComparisonPanel:
"""Interactive strategy comparison with scenario slider and cost-benefit table."""
def __init__(self, strategies: list[dict[str, Any]] | None = None, *, current_spot: float = 100.0) -> None:
self.strategies = strategies or []
self.current_spot = current_spot
self.price_change_pct = 0
self.strategy_cards: list[ui.html] = []
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 Comparison").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
with ui.row().classes("w-full items-center justify-between gap-4 max-sm:flex-col max-sm:items-start"):
self.slider_label = ui.label(self._slider_text()).classes("text-sm text-slate-500 dark:text-slate-400")
self.scenario_spot = ui.label(self._scenario_spot_text()).classes(
"rounded-full bg-sky-100 px-3 py-1 text-sm font-medium text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
)
ui.slider(min=-50, max=50, value=0, step=5, on_change=self._on_slider_change).classes("w-full")
with ui.row().classes("w-full gap-4 max-lg:flex-col"):
self.cards_container = ui.row().classes("w-full gap-4 max-lg:flex-col")
ui.separator().classes("my-2")
ui.label("Cost / Benefit Summary").classes("text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400")
self.table_html = ui.html("").classes("w-full")
self.set_strategies(self.strategies, current_spot=current_spot)
def _on_slider_change(self, event: Any) -> None:
self.price_change_pct = int(event.value)
self.slider_label.set_text(self._slider_text())
self.scenario_spot.set_text(self._scenario_spot_text())
self._render()
def set_strategies(self, strategies: list[dict[str, Any]], *, current_spot: float | None = None) -> None:
self.strategies = strategies
if current_spot is not None:
self.current_spot = current_spot
self._render()
def _render(self) -> None:
self.cards_container.clear()
self.strategy_cards.clear()
with self.cards_container:
for strategy in self.strategies:
self.strategy_cards.append(ui.html(self._strategy_card_html(strategy)).classes("w-full"))
self.table_html.content = self._table_html()
self.table_html.update()
def _scenario_spot(self) -> float:
return self.current_spot * (1 + self.price_change_pct / 100)
def _slider_text(self) -> str:
sign = "+" if self.price_change_pct > 0 else ""
return f"Scenario slider: {sign}{self.price_change_pct}% gold price change"
def _scenario_spot_text(self) -> str:
return f"Scenario spot: ${self._scenario_spot():,.2f}"
def _strategy_card_html(self, strategy: dict[str, Any]) -> str:
name = str(strategy.get("name", "strategy")).replace("_", " ").title()
description = strategy.get("description", "")
cost = float(strategy.get("estimated_cost", 0.0))
payoff = self._scenario_benefit(strategy)
payoff_class = "text-emerald-600 dark:text-emerald-400" if payoff >= 0 else "text-rose-600 dark:text-rose-400"
return f"""
<div class=\"h-full rounded-xl border border-slate-200 bg-slate-50 p-4 dark:border-slate-800 dark:bg-slate-950\">
<div class=\"mb-3 flex items-start justify-between gap-3\">
<div>
<div class=\"text-base font-semibold text-slate-900 dark:text-slate-100\">{name}</div>
<div class=\"mt-1 text-sm text-slate-500 dark:text-slate-400\">{description}</div>
</div>
<div class=\"rounded-full bg-slate-900 px-2 py-1 text-xs font-semibold text-white dark:bg-slate-100 dark:text-slate-900\">Live Scenario</div>
</div>
<div class=\"grid grid-cols-2 gap-3\">
<div class=\"rounded-lg bg-white p-3 dark:bg-slate-900\">
<div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">Est. Cost</div>
<div class=\"mt-1 text-lg font-bold text-slate-900 dark:text-slate-100\">${cost:,.2f}</div>
</div>
<div class=\"rounded-lg bg-white p-3 dark:bg-slate-900\">
<div class=\"text-xs uppercase tracking-wide text-slate-500 dark:text-slate-400\">Scenario Benefit</div>
<div class=\"mt-1 text-lg font-bold {payoff_class}\">${payoff:,.2f}</div>
</div>
</div>
</div>
"""
def _table_html(self) -> str:
rows = []
for strategy in self.strategies:
name = str(strategy.get("name", "strategy")).replace("_", " ").title()
cost = float(strategy.get("estimated_cost", 0.0))
floor = strategy.get("max_drawdown_floor", "")
cap = strategy.get("upside_cap", "")
scenario = self._scenario_benefit(strategy)
scenario_class = "text-emerald-600 dark:text-emerald-400" if scenario >= 0 else "text-rose-600 dark:text-rose-400"
rows.append(
f"""
<tr class=\"border-b border-slate-200 dark:border-slate-800\">
<td class=\"px-4 py-3 font-medium text-slate-900 dark:text-slate-100\">{name}</td>
<td class=\"px-4 py-3 text-slate-600 dark:text-slate-300\">${cost:,.2f}</td>
<td class=\"px-4 py-3 text-slate-600 dark:text-slate-300\">{self._fmt_optional_money(floor)}</td>
<td class=\"px-4 py-3 text-slate-600 dark:text-slate-300\">{self._fmt_optional_money(cap)}</td>
<td class=\"px-4 py-3 font-semibold {scenario_class}\">${scenario:,.2f}</td>
</tr>
"""
)
return f"""
<div class=\"overflow-x-auto\">
<table class=\"min-w-full rounded-xl overflow-hidden\">
<thead class=\"bg-slate-100 dark:bg-slate-800\">
<tr>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Strategy</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Estimated Cost</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Protection Floor</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Upside Cap</th>
<th class=\"px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300\">Scenario Benefit</th>
</tr>
</thead>
<tbody>{''.join(rows) if rows else self._empty_row()}</tbody>
</table>
</div>
"""
def _scenario_benefit(self, strategy: dict[str, Any]) -> float:
scenario_spot = self._scenario_spot()
cost = float(strategy.get("estimated_cost", 0.0))
floor = strategy.get("max_drawdown_floor")
cap = strategy.get("upside_cap")
benefit = -cost
if isinstance(floor, (int, float)) and scenario_spot < float(floor):
benefit += float(floor) - scenario_spot
if isinstance(cap, (int, float)) and scenario_spot > float(cap):
benefit -= scenario_spot - float(cap)
return benefit
@staticmethod
def _fmt_optional_money(value: Any) -> str:
if isinstance(value, (int, float)):
return f"${float(value):,.2f}"
return ""
@staticmethod
def _empty_row() -> str:
return (
'<tr><td colspan="5" class="px-4 py-6 text-center text-slate-500 dark:text-slate-400">'
'No strategies loaded'
'</td></tr>'
)

21
app/core/__init__.py Normal file
View File

@@ -0,0 +1,21 @@
"""Core domain and pricing utilities."""
from .calculations import (
loan_to_value,
ltv_scenarios,
margin_call_price,
net_equity,
option_payoff,
portfolio_net_equity,
strategy_payoff,
)
__all__ = [
"loan_to_value",
"ltv_scenarios",
"margin_call_price",
"net_equity",
"option_payoff",
"portfolio_net_equity",
"strategy_payoff",
]

98
app/core/calculations.py Normal file
View File

@@ -0,0 +1,98 @@
from __future__ import annotations
from typing import Iterable
from app.models.option import OptionContract
from app.models.portfolio import LombardPortfolio
from app.models.strategy import HedgingStrategy
def margin_call_price(gold_ounces: float, loan_amount: float, margin_call_ltv: float) -> float:
"""Calculate the gold price per ounce that triggers a margin call."""
if gold_ounces <= 0:
raise ValueError("gold_ounces must be positive")
if loan_amount < 0:
raise ValueError("loan_amount must be non-negative")
if not 0 < margin_call_ltv < 1:
raise ValueError("margin_call_ltv must be between 0 and 1")
return loan_amount / (margin_call_ltv * gold_ounces)
def loan_to_value(loan_amount: float, collateral_value: float) -> float:
"""Calculate the loan-to-value ratio."""
if loan_amount < 0:
raise ValueError("loan_amount must be non-negative")
if collateral_value <= 0:
raise ValueError("collateral_value must be positive")
return loan_amount / collateral_value
def ltv_scenarios(portfolio: LombardPortfolio, gold_prices: Iterable[float]) -> dict[float, float]:
"""Return LTV values for a collection of gold-price scenarios."""
scenarios: dict[float, float] = {}
for price in gold_prices:
if price <= 0:
raise ValueError("scenario gold prices must be positive")
scenarios[price] = portfolio.ltv_at_price(price)
if not scenarios:
raise ValueError("gold_prices must contain at least one scenario")
return scenarios
def option_payoff(contracts: Iterable[OptionContract], underlying_price: float, *, short: bool = False) -> float:
"""Aggregate expiry payoff across option contracts."""
if underlying_price <= 0:
raise ValueError("underlying_price must be positive")
payoff = sum(contract.payoff(underlying_price) for contract in contracts)
return -payoff if short else payoff
def strategy_payoff(strategy: HedgingStrategy, underlying_price: float) -> float:
"""Net option payoff before premium cost for a hedging strategy."""
return strategy.gross_payoff(underlying_price)
def net_equity(
gold_ounces: float,
gold_price_per_ounce: float,
loan_amount: float,
hedge_cost: float = 0.0,
option_payoff_value: float = 0.0,
) -> float:
"""Calculate net equity after debt and hedging effects.
Formula:
``gold_value - loan_amount - hedge_cost + option_payoff``
"""
if gold_ounces <= 0:
raise ValueError("gold_ounces must be positive")
if gold_price_per_ounce <= 0:
raise ValueError("gold_price_per_ounce must be positive")
if loan_amount < 0:
raise ValueError("loan_amount must be non-negative")
if hedge_cost < 0:
raise ValueError("hedge_cost must be non-negative")
gold_value = gold_ounces * gold_price_per_ounce
return gold_value - loan_amount - hedge_cost + option_payoff_value
def portfolio_net_equity(
portfolio: LombardPortfolio,
gold_price_per_ounce: float | None = None,
strategy: HedgingStrategy | None = None,
) -> float:
"""Calculate scenario net equity for a portfolio with an optional hedge."""
scenario_price = portfolio.gold_price_per_ounce if gold_price_per_ounce is None else gold_price_per_ounce
if scenario_price <= 0:
raise ValueError("gold_price_per_ounce must be positive")
payoff_value = strategy.gross_payoff(scenario_price) if strategy is not None else 0.0
hedge_cost = strategy.hedge_cost if strategy is not None else 0.0
return net_equity(
gold_ounces=portfolio.gold_ounces,
gold_price_per_ounce=scenario_price,
loan_amount=portfolio.loan_amount,
hedge_cost=hedge_cost,
option_payoff_value=payoff_value,
)

View File

@@ -0,0 +1,58 @@
"""Core options pricing utilities for the Vault dashboard.
This package provides pricing helpers for:
- European Black-Scholes valuation
- American option pricing via binomial trees when QuantLib is installed
- Implied volatility inversion when QuantLib is installed
Research defaults are based on the Vault hedging paper:
- Gold price: 4,600 USD/oz
- GLD price: 460 USD/share
- Risk-free rate: 4.5%
- Volatility: 16% annualized
- GLD dividend yield: 0%
"""
from .black_scholes import (
DEFAULT_GLD_PRICE,
DEFAULT_GOLD_PRICE_PER_OUNCE,
DEFAULT_RISK_FREE_RATE,
DEFAULT_VOLATILITY,
BlackScholesInputs,
HedgingCost,
PricingResult,
annual_hedging_cost,
black_scholes_price_and_greeks,
margin_call_threshold_price,
)
__all__ = [
"DEFAULT_GLD_PRICE",
"DEFAULT_GOLD_PRICE_PER_OUNCE",
"DEFAULT_RISK_FREE_RATE",
"DEFAULT_VOLATILITY",
"BlackScholesInputs",
"HedgingCost",
"PricingResult",
"annual_hedging_cost",
"black_scholes_price_and_greeks",
"margin_call_threshold_price",
]
try: # pragma: no cover - optional QuantLib modules
from .american_pricing import AmericanOptionInputs, AmericanPricingResult, american_option_price_and_greeks
from .volatility import implied_volatility
except ImportError: # pragma: no cover - optional dependency
AmericanOptionInputs = None
AmericanPricingResult = None
american_option_price_and_greeks = None
implied_volatility = None
else:
__all__.extend(
[
"AmericanOptionInputs",
"AmericanPricingResult",
"american_option_price_and_greeks",
"implied_volatility",
]
)

View File

@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from typing import Literal
import QuantLib as ql
OptionType = Literal["call", "put"]
DEFAULT_RISK_FREE_RATE: float = 0.045
DEFAULT_VOLATILITY: float = 0.16
DEFAULT_DIVIDEND_YIELD: float = 0.0
DEFAULT_GLD_PRICE: float = 460.0
@dataclass(frozen=True)
class AmericanOptionInputs:
"""Inputs for American option pricing via a binomial tree.
This module is intended primarily for GLD protective puts, where early
exercise can matter in stressed scenarios.
Example:
>>> params = AmericanOptionInputs(
... spot=460.0,
... strike=420.0,
... time_to_expiry=0.5,
... option_type="put",
... )
>>> params.steps
500
"""
spot: float = DEFAULT_GLD_PRICE
strike: float = DEFAULT_GLD_PRICE
time_to_expiry: float = 0.5
risk_free_rate: float = DEFAULT_RISK_FREE_RATE
volatility: float = DEFAULT_VOLATILITY
option_type: OptionType = "put"
dividend_yield: float = DEFAULT_DIVIDEND_YIELD
steps: int = 500
valuation_date: date | None = None
tree: str = "crr"
@dataclass(frozen=True)
class AmericanPricingResult:
"""American option price and finite-difference Greeks."""
price: float
delta: float
gamma: float
theta: float
vega: float
rho: float
def _validate_option_type(option_type: str) -> OptionType:
option = option_type.lower()
if option not in {"call", "put"}:
raise ValueError("option_type must be either 'call' or 'put'")
return option # type: ignore[return-value]
def _to_quantlib_option_type(option_type: OptionType) -> ql.Option.Type:
return ql.Option.Call if option_type == "call" else ql.Option.Put
def _build_dates(time_to_expiry: float, valuation_date: date | None) -> tuple[ql.Date, ql.Date]:
if time_to_expiry <= 0.0:
raise ValueError("time_to_expiry must be positive")
valuation = valuation_date or date.today()
maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365)))
return (
ql.Date(valuation.day, valuation.month, valuation.year),
ql.Date(maturity.day, maturity.month, maturity.year),
)
def _american_price(
params: AmericanOptionInputs,
*,
spot: float | None = None,
risk_free_rate: float | None = None,
volatility: float | None = None,
time_to_expiry: float | None = None,
) -> float:
option_type = _validate_option_type(params.option_type)
used_spot = params.spot if spot is None else spot
used_rate = params.risk_free_rate if risk_free_rate is None else risk_free_rate
used_vol = params.volatility if volatility is None else volatility
used_time = params.time_to_expiry if time_to_expiry is None else time_to_expiry
if used_spot <= 0 or used_vol <= 0 or used_time <= 0:
raise ValueError("spot, volatility, and time_to_expiry must be positive")
if params.steps < 10:
raise ValueError("steps must be at least 10 for binomial pricing")
valuation_ql, maturity_ql = _build_dates(used_time, params.valuation_date)
ql.Settings.instance().evaluationDate = valuation_ql
day_count = ql.Actual365Fixed()
calendar = ql.NullCalendar()
spot_handle = ql.QuoteHandle(ql.SimpleQuote(used_spot))
dividend_curve = ql.YieldTermStructureHandle(
ql.FlatForward(valuation_ql, params.dividend_yield, day_count)
)
risk_free_curve = ql.YieldTermStructureHandle(
ql.FlatForward(valuation_ql, used_rate, day_count)
)
volatility_curve = ql.BlackVolTermStructureHandle(
ql.BlackConstantVol(valuation_ql, calendar, used_vol, day_count)
)
process = ql.BlackScholesMertonProcess(
spot_handle,
dividend_curve,
risk_free_curve,
volatility_curve,
)
payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), params.strike)
exercise = ql.AmericanExercise(valuation_ql, maturity_ql)
option = ql.VanillaOption(payoff, exercise)
option.setPricingEngine(ql.BinomialVanillaEngine(process, params.tree, params.steps))
return float(option.NPV())
def american_option_price_and_greeks(params: AmericanOptionInputs) -> AmericanPricingResult:
"""Price an American option and estimate Greeks with finite differences.
Notes:
- The price uses a QuantLib binomial tree engine.
- Greeks are finite-difference approximations because closed-form
American Greeks are not available in general.
- Theta is annualized and approximated by rolling one calendar day forward.
Args:
params: American option inputs.
Returns:
A price and finite-difference Greeks.
Example:
>>> params = AmericanOptionInputs(
... spot=460.0,
... strike=400.0,
... time_to_expiry=0.5,
... risk_free_rate=0.045,
... volatility=0.16,
... option_type="put",
... )
>>> result = american_option_price_and_greeks(params)
>>> result.price > 0
True
"""
base_price = _american_price(params)
spot_bump = max(0.01, params.spot * 0.01)
vol_bump = 0.01
rate_bump = 0.0001
dt = 1.0 / 365.0
price_up = _american_price(params, spot=params.spot + spot_bump)
price_down = _american_price(params, spot=max(1e-8, params.spot - spot_bump))
delta = (price_up - price_down) / (2.0 * spot_bump)
gamma = (price_up - 2.0 * base_price + price_down) / (spot_bump**2)
vega_up = _american_price(params, volatility=params.volatility + vol_bump)
vega_down = _american_price(params, volatility=max(1e-6, params.volatility - vol_bump))
vega = (vega_up - vega_down) / (2.0 * vol_bump)
rho_up = _american_price(params, risk_free_rate=params.risk_free_rate + rate_bump)
rho_down = _american_price(params, risk_free_rate=params.risk_free_rate - rate_bump)
rho = (rho_up - rho_down) / (2.0 * rate_bump)
if params.time_to_expiry <= dt:
theta = 0.0
else:
shorter_price = _american_price(params, time_to_expiry=params.time_to_expiry - dt)
theta = (shorter_price - base_price) / dt
return AmericanPricingResult(
price=base_price,
delta=delta,
gamma=gamma,
theta=theta,
vega=vega,
rho=rho,
)

View File

@@ -0,0 +1,210 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
import math
from typing import Any, Literal
try: # pragma: no cover - optional dependency
import QuantLib as ql
except ImportError: # pragma: no cover - optional dependency
ql = None
OptionType = Literal["call", "put"]
DEFAULT_GOLD_PRICE_PER_OUNCE: float = 4600.0
DEFAULT_GLD_PRICE: float = 460.0
DEFAULT_RISK_FREE_RATE: float = 0.045
DEFAULT_VOLATILITY: float = 0.16
DEFAULT_DIVIDEND_YIELD: float = 0.0
@dataclass(frozen=True)
class BlackScholesInputs:
"""Inputs for European Black-Scholes pricing."""
spot: float = DEFAULT_GLD_PRICE
strike: float = DEFAULT_GLD_PRICE
time_to_expiry: float = 0.25
risk_free_rate: float = DEFAULT_RISK_FREE_RATE
volatility: float = DEFAULT_VOLATILITY
option_type: OptionType = "put"
dividend_yield: float = DEFAULT_DIVIDEND_YIELD
valuation_date: date | None = None
@dataclass(frozen=True)
class PricingResult:
"""European option price and Greeks."""
price: float
delta: float
gamma: float
theta: float
vega: float
rho: float
@dataclass(frozen=True)
class HedgingCost:
"""Annualized hedging cost summary."""
premium_paid: float
annual_cost_dollars: float
annual_cost_pct: float
def _validate_option_type(option_type: str) -> OptionType:
option = option_type.lower()
if option not in {"call", "put"}:
raise ValueError("option_type must be either 'call' or 'put'")
return option # type: ignore[return-value]
def _to_quantlib_option_type(option_type: OptionType) -> Any:
if ql is None:
raise RuntimeError("QuantLib is not installed")
return ql.Option.Call if option_type == "call" else ql.Option.Put
def _build_dates(time_to_expiry: float, valuation_date: date | None) -> tuple[Any, Any]:
if time_to_expiry <= 0.0:
raise ValueError("time_to_expiry must be positive")
if ql is None:
return (None, None)
valuation = valuation_date or date.today()
maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365)))
return (
ql.Date(valuation.day, valuation.month, valuation.year),
ql.Date(maturity.day, maturity.month, maturity.year),
)
def _norm_pdf(value: float) -> float:
return math.exp(-(value**2) / 2.0) / math.sqrt(2.0 * math.pi)
def _norm_cdf(value: float) -> float:
return 0.5 * (1.0 + math.erf(value / math.sqrt(2.0)))
def _analytic_black_scholes(params: BlackScholesInputs, option_type: OptionType) -> PricingResult:
if params.spot <= 0 or params.strike <= 0 or params.time_to_expiry <= 0 or params.volatility <= 0:
raise ValueError("spot, strike, time_to_expiry, and volatility must be positive")
t = params.time_to_expiry
sigma = params.volatility
sqrt_t = math.sqrt(t)
d1 = (
math.log(params.spot / params.strike)
+ (params.risk_free_rate - params.dividend_yield + 0.5 * sigma**2) * t
) / (sigma * sqrt_t)
d2 = d1 - sigma * sqrt_t
disc_r = math.exp(-params.risk_free_rate * t)
disc_q = math.exp(-params.dividend_yield * t)
pdf_d1 = _norm_pdf(d1)
if option_type == "call":
price = params.spot * disc_q * _norm_cdf(d1) - params.strike * disc_r * _norm_cdf(d2)
delta = disc_q * _norm_cdf(d1)
theta = (
-(params.spot * disc_q * pdf_d1 * sigma) / (2 * sqrt_t)
- params.risk_free_rate * params.strike * disc_r * _norm_cdf(d2)
+ params.dividend_yield * params.spot * disc_q * _norm_cdf(d1)
)
rho = params.strike * t * disc_r * _norm_cdf(d2)
else:
price = params.strike * disc_r * _norm_cdf(-d2) - params.spot * disc_q * _norm_cdf(-d1)
delta = disc_q * (_norm_cdf(d1) - 1.0)
theta = (
-(params.spot * disc_q * pdf_d1 * sigma) / (2 * sqrt_t)
+ params.risk_free_rate * params.strike * disc_r * _norm_cdf(-d2)
- params.dividend_yield * params.spot * disc_q * _norm_cdf(-d1)
)
rho = -params.strike * t * disc_r * _norm_cdf(-d2)
gamma = (disc_q * pdf_d1) / (params.spot * sigma * sqrt_t)
vega = params.spot * disc_q * pdf_d1 * sqrt_t
return PricingResult(price=float(price), delta=float(delta), gamma=float(gamma), theta=float(theta), vega=float(vega), rho=float(rho))
def black_scholes_price_and_greeks(params: BlackScholesInputs) -> PricingResult:
"""Price a European option with QuantLib when available, otherwise analytic BSM."""
option_type = _validate_option_type(params.option_type)
if ql is None:
return _analytic_black_scholes(params, option_type)
valuation_ql, maturity_ql = _build_dates(params.time_to_expiry, params.valuation_date)
ql.Settings.instance().evaluationDate = valuation_ql
day_count = ql.Actual365Fixed()
calendar = ql.NullCalendar()
spot_handle = ql.QuoteHandle(ql.SimpleQuote(params.spot))
dividend_curve = ql.YieldTermStructureHandle(
ql.FlatForward(valuation_ql, params.dividend_yield, day_count)
)
risk_free_curve = ql.YieldTermStructureHandle(
ql.FlatForward(valuation_ql, params.risk_free_rate, day_count)
)
volatility = ql.BlackVolTermStructureHandle(
ql.BlackConstantVol(valuation_ql, calendar, params.volatility, day_count)
)
process = ql.BlackScholesMertonProcess(spot_handle, dividend_curve, risk_free_curve, volatility)
payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), params.strike)
exercise = ql.EuropeanExercise(maturity_ql)
option = ql.VanillaOption(payoff, exercise)
option.setPricingEngine(ql.AnalyticEuropeanEngine(process))
return PricingResult(
price=float(option.NPV()),
delta=float(option.delta()),
gamma=float(option.gamma()),
theta=float(option.theta()),
vega=float(option.vega()),
rho=float(option.rho()),
)
def margin_call_threshold_price(
portfolio_value: float,
loan_amount: float,
current_price: float = DEFAULT_GLD_PRICE,
margin_call_ltv: float = 0.75,
) -> float:
"""Calculate the underlying price where a margin call is triggered."""
if portfolio_value <= 0 or loan_amount <= 0 or current_price <= 0:
raise ValueError("portfolio_value, loan_amount, and current_price must be positive")
if not 0 < margin_call_ltv < 1:
raise ValueError("margin_call_ltv must be between 0 and 1")
units = portfolio_value / current_price
return loan_amount / (margin_call_ltv * units)
def annual_hedging_cost(
premium_per_share: float,
shares_hedged: float,
portfolio_value: float,
hedge_term_years: float,
) -> HedgingCost:
"""Annualize the premium cost of a hedging program."""
if premium_per_share < 0 or shares_hedged <= 0 or portfolio_value <= 0 or hedge_term_years <= 0:
raise ValueError(
"premium_per_share must be non-negative and shares_hedged, portfolio_value, "
"and hedge_term_years must be positive"
)
premium_paid = premium_per_share * shares_hedged
annual_cost_dollars = premium_paid / hedge_term_years
annual_cost_pct = annual_cost_dollars / portfolio_value
return HedgingCost(
premium_paid=premium_paid,
annual_cost_dollars=annual_cost_dollars,
annual_cost_pct=annual_cost_pct,
)

View File

@@ -0,0 +1,127 @@
from __future__ import annotations
from datetime import date, timedelta
from typing import Literal
import QuantLib as ql
OptionType = Literal["call", "put"]
DEFAULT_RISK_FREE_RATE: float = 0.045
DEFAULT_VOLATILITY_GUESS: float = 0.16
DEFAULT_DIVIDEND_YIELD: float = 0.0
def _validate_option_type(option_type: str) -> OptionType:
option = option_type.lower()
if option not in {"call", "put"}:
raise ValueError("option_type must be either 'call' or 'put'")
return option # type: ignore[return-value]
def _to_quantlib_option_type(option_type: OptionType) -> ql.Option.Type:
return ql.Option.Call if option_type == "call" else ql.Option.Put
def implied_volatility(
option_price: float,
spot: float,
strike: float,
time_to_expiry: float,
risk_free_rate: float = DEFAULT_RISK_FREE_RATE,
option_type: OptionType = "put",
dividend_yield: float = DEFAULT_DIVIDEND_YIELD,
valuation_date: date | None = None,
initial_guess: float = DEFAULT_VOLATILITY_GUESS,
min_vol: float = 1e-4,
max_vol: float = 4.0,
accuracy: float = 1e-8,
max_evaluations: int = 500,
) -> float:
"""Invert the Black-Scholes-Merton model to solve for implied volatility.
Assumptions:
- European option exercise
- Flat rate, dividend, and volatility term structures
- GLD dividend yield defaults to zero
Args:
option_price: Observed market premium.
spot: Current underlying price.
strike: Option strike price.
time_to_expiry: Time to maturity in years.
risk_free_rate: Annual risk-free rate.
option_type: ``"call"`` or ``"put"``.
dividend_yield: Continuous dividend yield.
valuation_date: Pricing date, defaults to today.
initial_guess: Starting volatility guess used in the pricing process.
min_vol: Lower volatility search bound.
max_vol: Upper volatility search bound.
accuracy: Root-finding tolerance.
max_evaluations: Maximum solver iterations.
Returns:
The annualized implied volatility as a decimal.
Example:
>>> vol = implied_volatility(
... option_price=12.0,
... spot=460.0,
... strike=430.0,
... time_to_expiry=0.5,
... risk_free_rate=0.045,
... option_type="put",
... )
>>> vol > 0
True
"""
if option_price <= 0 or spot <= 0 or strike <= 0 or time_to_expiry <= 0:
raise ValueError("option_price, spot, strike, and time_to_expiry must be positive")
if initial_guess <= 0 or min_vol <= 0 or max_vol <= min_vol:
raise ValueError("invalid volatility bounds or initial_guess")
option_type = _validate_option_type(option_type)
valuation = valuation_date or date.today()
maturity = valuation + timedelta(days=max(1, round(time_to_expiry * 365)))
valuation_ql = ql.Date(valuation.day, valuation.month, valuation.year)
maturity_ql = ql.Date(maturity.day, maturity.month, maturity.year)
ql.Settings.instance().evaluationDate = valuation_ql
day_count = ql.Actual365Fixed()
calendar = ql.NullCalendar()
spot_handle = ql.QuoteHandle(ql.SimpleQuote(spot))
dividend_curve = ql.YieldTermStructureHandle(
ql.FlatForward(valuation_ql, dividend_yield, day_count)
)
risk_free_curve = ql.YieldTermStructureHandle(
ql.FlatForward(valuation_ql, risk_free_rate, day_count)
)
volatility_curve = ql.BlackVolTermStructureHandle(
ql.BlackConstantVol(valuation_ql, calendar, initial_guess, day_count)
)
process = ql.BlackScholesMertonProcess(
spot_handle,
dividend_curve,
risk_free_curve,
volatility_curve,
)
payoff = ql.PlainVanillaPayoff(_to_quantlib_option_type(option_type), strike)
exercise = ql.EuropeanExercise(maturity_ql)
option = ql.VanillaOption(payoff, exercise)
option.setPricingEngine(ql.AnalyticEuropeanEngine(process))
return float(
option.impliedVolatility(
option_price,
process,
accuracy,
max_evaluations,
min_vol,
max_vol,
)
)

172
app/main.py Normal file
View File

@@ -0,0 +1,172 @@
"""FastAPI application entry point with NiceGUI integration."""
from __future__ import annotations
import asyncio
import contextlib
import logging
import os
from contextlib import asynccontextmanager
from dataclasses import dataclass
from typing import Any
from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from nicegui import ui
from app.api.routes import router as api_router
import app.pages # noqa: F401
from app.services.cache import CacheService
from app.services.data_service import DataService
logging.basicConfig(level=os.getenv("LOG_LEVEL", "INFO"))
logger = logging.getLogger(__name__)
@dataclass(slots=True)
class Settings:
app_name: str = "Vault Dashboard"
environment: str = "development"
cors_origins: list[str] | None = None
redis_url: str | None = None
cache_ttl: int = 300
default_symbol: str = "GLD"
websocket_interval_seconds: int = 5
nicegui_mount_path: str = "/"
nicegui_storage_secret: str = "vault-dash-dev-secret"
@classmethod
def load(cls) -> "Settings":
cls._load_dotenv()
origins = os.getenv("CORS_ORIGINS", "*")
return cls(
app_name=os.getenv("APP_NAME", cls.app_name),
environment=os.getenv("APP_ENV", os.getenv("ENVIRONMENT", cls.environment)),
cors_origins=[origin.strip() for origin in origins.split(",") if origin.strip()],
redis_url=os.getenv("REDIS_URL"),
cache_ttl=int(os.getenv("CACHE_TTL", cls.cache_ttl)),
default_symbol=os.getenv("DEFAULT_SYMBOL", cls.default_symbol),
websocket_interval_seconds=int(os.getenv("WEBSOCKET_INTERVAL_SECONDS", cls.websocket_interval_seconds)),
nicegui_mount_path=os.getenv("NICEGUI_MOUNT_PATH", cls.nicegui_mount_path),
nicegui_storage_secret=os.getenv("NICEGUI_STORAGE_SECRET", cls.nicegui_storage_secret),
)
@staticmethod
def _load_dotenv() -> None:
try:
from dotenv import load_dotenv
except ImportError:
return
load_dotenv()
settings = Settings.load()
class ConnectionManager:
def __init__(self) -> None:
self._connections: set[WebSocket] = set()
async def connect(self, websocket: WebSocket) -> None:
await websocket.accept()
self._connections.add(websocket)
def disconnect(self, websocket: WebSocket) -> None:
self._connections.discard(websocket)
async def broadcast_json(self, payload: dict[str, Any]) -> None:
stale: list[WebSocket] = []
for websocket in self._connections:
try:
await websocket.send_json(payload)
except Exception:
stale.append(websocket)
for websocket in stale:
self.disconnect(websocket)
@property
def count(self) -> int:
return len(self._connections)
async def publish_updates(app: FastAPI) -> None:
try:
while True:
payload = {
"type": "portfolio_update",
"connections": app.state.ws_manager.count,
"portfolio": await app.state.data_service.get_portfolio(app.state.settings.default_symbol),
}
await app.state.ws_manager.broadcast_json(payload)
await asyncio.sleep(app.state.settings.websocket_interval_seconds)
except asyncio.CancelledError:
logger.info("WebSocket publisher stopped")
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.settings = settings
app.state.cache = CacheService(settings.redis_url, default_ttl=settings.cache_ttl)
await app.state.cache.connect()
app.state.data_service = DataService(app.state.cache, default_symbol=settings.default_symbol)
app.state.ws_manager = ConnectionManager()
app.state.publisher_task = asyncio.create_task(publish_updates(app))
logger.info("Application startup complete")
try:
yield
finally:
app.state.publisher_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await app.state.publisher_task
await app.state.cache.close()
logger.info("Application shutdown complete")
app = FastAPI(title=settings.app_name, lifespan=lifespan)
app.include_router(api_router)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins or ["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/health", tags=["health"])
async def health(request: Request) -> dict[str, Any]:
return {
"status": "ok",
"environment": request.app.state.settings.environment,
"redis_enabled": request.app.state.cache.enabled,
}
@app.websocket("/ws/updates")
async def websocket_updates(websocket: WebSocket) -> None:
manager: ConnectionManager = websocket.app.state.ws_manager
await manager.connect(websocket)
try:
await websocket.send_json({"type": "connected", "message": "Real-time updates enabled"})
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.disconnect(websocket)
ui.run_with(
app,
mount_path=settings.nicegui_mount_path,
storage_secret=settings.nicegui_storage_secret,
show=False,
)
if __name__ in {"__main__", "__mp_main__"}:
import uvicorn
uvicorn.run("app.main:app", host="0.0.0.0", port=8000, reload=settings.environment == "development")

15
app/models/__init__.py Normal file
View File

@@ -0,0 +1,15 @@
"""Application domain models."""
from .option import Greeks, OptionContract, OptionMoneyness
from .portfolio import LombardPortfolio
from .strategy import HedgingStrategy, ScenarioResult, StrategyType
__all__ = [
"Greeks",
"HedgingStrategy",
"LombardPortfolio",
"OptionContract",
"OptionMoneyness",
"ScenarioResult",
"StrategyType",
]

109
app/models/option.py Normal file
View File

@@ -0,0 +1,109 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date
from typing import Literal
OptionType = Literal["call", "put"]
OptionMoneyness = Literal["ITM", "ATM", "OTM"]
@dataclass(frozen=True)
class Greeks:
"""Option Greeks container."""
delta: float = 0.0
gamma: float = 0.0
theta: float = 0.0
vega: float = 0.0
rho: float = 0.0
@dataclass(frozen=True)
class OptionContract:
"""Vanilla option contract used in hedging strategies.
Attributes:
option_type: Contract type, either ``"put"`` or ``"call"``.
strike: Strike price.
expiry: Expiration date.
premium: Premium paid or received per unit of underlying.
quantity: Number of contracts or units.
contract_size: Underlying units per contract.
underlying_price: Current underlying spot price for classification.
greeks: Stored option Greeks.
"""
option_type: OptionType
strike: float
expiry: date
premium: float
quantity: float = 1.0
contract_size: float = 1.0
underlying_price: float | None = None
greeks: Greeks = Greeks()
def __post_init__(self) -> None:
option = self.option_type.lower()
if option not in {"call", "put"}:
raise ValueError("option_type must be either 'call' or 'put'")
object.__setattr__(self, "option_type", option)
if self.strike <= 0:
raise ValueError("strike must be positive")
if self.premium < 0:
raise ValueError("premium must be non-negative")
if self.quantity <= 0:
raise ValueError("quantity must be positive")
if self.contract_size <= 0:
raise ValueError("contract_size must be positive")
if self.expiry <= date.today():
raise ValueError("expiry must be in the future")
if self.underlying_price is not None and self.underlying_price <= 0:
raise ValueError("underlying_price must be positive when provided")
@property
def notional_units(self) -> float:
"""Underlying units covered by the contract position."""
return self.quantity * self.contract_size
@property
def total_premium(self) -> float:
"""Total premium paid or received for the position."""
return self.premium * self.notional_units
def classify_moneyness(self, underlying_price: float | None = None, *, atm_tolerance: float = 0.01) -> OptionMoneyness:
"""Classify the contract as ITM, ATM, or OTM.
Args:
underlying_price: Spot price used for classification. Falls back to
``self.underlying_price``.
atm_tolerance: Relative tolerance around strike treated as at-the-money.
"""
spot = self.underlying_price if underlying_price is None else underlying_price
if spot is None:
raise ValueError("underlying_price must be provided for strategy classification")
if spot <= 0:
raise ValueError("underlying_price must be positive")
if atm_tolerance < 0:
raise ValueError("atm_tolerance must be non-negative")
relative_gap = abs(spot - self.strike) / self.strike
if relative_gap <= atm_tolerance:
return "ATM"
if self.option_type == "put":
return "ITM" if self.strike > spot else "OTM"
return "ITM" if self.strike < spot else "OTM"
def intrinsic_value(self, underlying_price: float) -> float:
"""Intrinsic value per underlying unit at a given spot price."""
if underlying_price <= 0:
raise ValueError("underlying_price must be positive")
if self.option_type == "put":
return max(self.strike - underlying_price, 0.0)
return max(underlying_price - self.strike, 0.0)
def payoff(self, underlying_price: float) -> float:
"""Gross payoff of the option position at expiry."""
return self.intrinsic_value(underlying_price) * self.notional_units

71
app/models/portfolio.py Normal file
View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class LombardPortfolio:
"""Lombard loan portfolio backed by physical gold.
Attributes:
gold_ounces: Quantity of pledged gold in troy ounces.
gold_price_per_ounce: Current gold spot price per ounce.
loan_amount: Outstanding Lombard loan balance.
initial_ltv: Origination or current reference loan-to-value ratio.
margin_call_ltv: LTV threshold at which a margin call is triggered.
"""
gold_ounces: float
gold_price_per_ounce: float
loan_amount: float
initial_ltv: float
margin_call_ltv: float
def __post_init__(self) -> None:
if self.gold_ounces <= 0:
raise ValueError("gold_ounces must be positive")
if self.gold_price_per_ounce <= 0:
raise ValueError("gold_price_per_ounce must be positive")
if self.loan_amount < 0:
raise ValueError("loan_amount must be non-negative")
if not 0 < self.initial_ltv < 1:
raise ValueError("initial_ltv must be between 0 and 1")
if not 0 < self.margin_call_ltv < 1:
raise ValueError("margin_call_ltv must be between 0 and 1")
if self.initial_ltv > self.margin_call_ltv:
raise ValueError("initial_ltv cannot exceed margin_call_ltv")
if self.loan_amount > self.gold_value:
raise ValueError("loan_amount cannot exceed current gold value")
@property
def gold_value(self) -> float:
"""Current market value of pledged gold."""
return self.gold_ounces * self.gold_price_per_ounce
@property
def current_ltv(self) -> float:
"""Current loan-to-value ratio."""
return self.loan_amount / self.gold_value
@property
def net_equity(self) -> float:
"""Equity remaining after subtracting the loan from gold value."""
return self.gold_value - self.loan_amount
def gold_value_at_price(self, gold_price_per_ounce: float) -> float:
"""Gold value under an alternative spot-price scenario."""
if gold_price_per_ounce <= 0:
raise ValueError("gold_price_per_ounce must be positive")
return self.gold_ounces * gold_price_per_ounce
def ltv_at_price(self, gold_price_per_ounce: float) -> float:
"""Portfolio LTV under an alternative gold-price scenario."""
return self.loan_amount / self.gold_value_at_price(gold_price_per_ounce)
def net_equity_at_price(self, gold_price_per_ounce: float) -> float:
"""Net equity under an alternative gold-price scenario."""
return self.gold_value_at_price(gold_price_per_ounce) - self.loan_amount
def margin_call_price(self) -> float:
"""Gold price per ounce at which the portfolio breaches the margin LTV."""
return self.loan_amount / (self.margin_call_ltv * self.gold_ounces)

101
app/models/strategy.py Normal file
View File

@@ -0,0 +1,101 @@
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from .option import OptionContract
StrategyType = Literal["single_put", "laddered_put", "collar"]
@dataclass(frozen=True)
class ScenarioResult:
"""Scenario output for a hedging strategy."""
underlying_price: float
gross_option_payoff: float
hedge_cost: float
net_option_benefit: float
@dataclass(frozen=True)
class HedgingStrategy:
"""Collection of option positions representing a hedge.
Notes:
Premiums on long positions are positive cash outflows. Premiums on
short positions are handled through ``short_contracts`` and reduce the
total hedge cost.
"""
strategy_type: StrategyType
long_contracts: tuple[OptionContract, ...] = field(default_factory=tuple)
short_contracts: tuple[OptionContract, ...] = field(default_factory=tuple)
description: str = ""
def __post_init__(self) -> None:
if self.strategy_type not in {"single_put", "laddered_put", "collar"}:
raise ValueError("unsupported strategy_type")
if not self.long_contracts and not self.short_contracts:
raise ValueError("at least one option contract is required")
if self.strategy_type == "single_put":
if len(self.long_contracts) != 1 or self.long_contracts[0].option_type != "put":
raise ValueError("single_put requires exactly one long put contract")
if self.short_contracts:
raise ValueError("single_put cannot include short contracts")
if self.strategy_type == "laddered_put":
if len(self.long_contracts) < 2:
raise ValueError("laddered_put requires at least two long put contracts")
if any(contract.option_type != "put" for contract in self.long_contracts):
raise ValueError("laddered_put supports only long put contracts")
if self.short_contracts:
raise ValueError("laddered_put cannot include short contracts")
if self.strategy_type == "collar":
if not self.long_contracts or not self.short_contracts:
raise ValueError("collar requires both long and short contracts")
if any(contract.option_type != "put" for contract in self.long_contracts):
raise ValueError("collar long leg must be put options")
if any(contract.option_type != "call" for contract in self.short_contracts):
raise ValueError("collar short leg must be call options")
@property
def hedge_cost(self) -> float:
"""Net upfront hedge cost."""
long_cost = sum(contract.total_premium for contract in self.long_contracts)
short_credit = sum(contract.total_premium for contract in self.short_contracts)
return long_cost - short_credit
def gross_payoff(self, underlying_price: float) -> float:
"""Gross expiry payoff from all option legs."""
if underlying_price <= 0:
raise ValueError("underlying_price must be positive")
long_payoff = sum(contract.payoff(underlying_price) for contract in self.long_contracts)
short_payoff = sum(contract.payoff(underlying_price) for contract in self.short_contracts)
return long_payoff - short_payoff
def net_benefit(self, underlying_price: float) -> float:
"""Net value added by the hedge after premium cost."""
return self.gross_payoff(underlying_price) - self.hedge_cost
def scenario_analysis(self, underlying_prices: list[float] | tuple[float, ...]) -> list[ScenarioResult]:
"""Evaluate the hedge across alternative underlying-price scenarios."""
if not underlying_prices:
raise ValueError("underlying_prices must not be empty")
results: list[ScenarioResult] = []
for price in underlying_prices:
if price <= 0:
raise ValueError("scenario prices must be positive")
gross_payoff = self.gross_payoff(price)
results.append(
ScenarioResult(
underlying_price=price,
gross_option_payoff=gross_payoff,
hedge_cost=self.hedge_cost,
net_option_benefit=gross_payoff - self.hedge_cost,
)
)
return results

3
app/pages/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
from . import hedge, options, overview, settings
__all__ = ["overview", "hedge", "options", "settings"]

203
app/pages/common.py Normal file
View 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")

126
app/pages/hedge.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from nicegui import ui
from app.pages.common import dashboard_page, demo_spot_price, strategy_catalog, strategy_metrics
def _cost_benefit_options(metrics: dict) -> dict:
return {
"tooltip": {"trigger": "axis"},
"xAxis": {
"type": "category",
"data": [f"${point['price']:.0f}" for point in metrics["scenario_series"]],
"name": "GLD spot",
},
"yAxis": {"type": "value", "name": "Net benefit / oz"},
"series": [
{
"type": "bar",
"data": [point["benefit"] for point in metrics["scenario_series"]],
"itemStyle": {
"color": "#0ea5e9",
},
}
],
}
def _waterfall_options(metrics: dict) -> dict:
steps = metrics["waterfall_steps"]
running = 0.0
base = []
values = []
for index, (_, amount) in enumerate(steps):
if index == 0:
base.append(0)
values.append(amount)
running = amount
elif index == len(steps) - 1:
base.append(0)
values.append(amount)
else:
base.append(running)
values.append(amount)
running += amount
return {
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
"xAxis": {"type": "category", "data": [label for label, _ in steps]},
"yAxis": {"type": "value", "name": "USD"},
"series": [
{"type": "bar", "stack": "total", "data": base, "itemStyle": {"color": "rgba(0,0,0,0)"}},
{
"type": "bar",
"stack": "total",
"data": values,
"itemStyle": {
"color": "#22c55e",
},
},
],
}
@ui.page("/hedge")
def hedge_page() -> None:
strategies = strategy_catalog()
strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies}
selected = {"strategy": strategies[0]["name"], "scenario_pct": 0}
with dashboard_page(
"Hedge Analysis",
"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.",
"hedge",
):
with ui.row().classes("w-full gap-6 max-lg: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"):
ui.label("Strategy Controls").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
selector = ui.select(strategy_map, value=selected["strategy"], label="Strategy selector").classes("w-full")
slider_value = ui.label("Scenario move: +0%").classes("text-sm text-slate-500 dark:text-slate-400")
slider = ui.slider(min=-25, max=25, value=0, step=5).classes("w-full")
ui.label(f"Current spot reference: ${demo_spot_price():,.2f}").classes("text-sm text-slate-500 dark:text-slate-400")
summary = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900")
charts_row = ui.row().classes("w-full gap-6 max-lg:flex-col")
with charts_row:
cost_chart = ui.echart(_cost_benefit_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900")
waterfall_chart = ui.echart(_waterfall_options(strategy_metrics(selected["strategy"], selected["scenario_pct"]))).classes("h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900")
def render_summary() -> None:
metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"])
strategy = metrics["strategy"]
summary.clear()
with summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
with ui.grid(columns=2).classes("w-full gap-4 max-sm:grid-cols-1"):
cards = [
("Scenario spot", f"${metrics['scenario_price']:,.2f}"),
("Hedge cost", f"${strategy['estimated_cost']:,.2f}/oz"),
("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"),
("Hedged equity", f"${metrics['hedged_equity']:,.0f}"),
]
for label, value in 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(label).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-2xl font-bold text-slate-900 dark:text-slate-100")
ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300")
cost_chart.options = _cost_benefit_options(metrics)
cost_chart.update()
waterfall_chart.options = _waterfall_options(metrics)
waterfall_chart.update()
def refresh_from_selector(event) -> None:
selected["strategy"] = event.value
render_summary()
def refresh_from_slider(event) -> None:
selected["scenario_pct"] = int(event.value)
sign = "+" if selected["scenario_pct"] >= 0 else ""
slider_value.set_text(f"Scenario move: {sign}{selected['scenario_pct']}%")
render_summary()
selector.on_value_change(refresh_from_selector)
slider.on_value_change(refresh_from_slider)
render_summary()

126
app/pages/options.py Normal file
View File

@@ -0,0 +1,126 @@
from __future__ import annotations
from nicegui import ui
from app.components import GreeksTable
from app.pages.common import dashboard_page, option_chain, strategy_catalog
@ui.page("/options")
def options_page() -> None:
chain = option_chain()
expiries = sorted({row["expiry"] for row in chain})
strike_values = sorted({row["strike"] for row in chain})
selected_expiry = {"value": expiries[0]}
strike_range = {"min": strike_values[0], "max": strike_values[-1]}
selected_strategy = {"value": strategy_catalog()[0]["label"]}
chosen_contracts: list[dict] = []
with dashboard_page(
"Options Chain",
"Browse GLD contracts, filter by expiry and strike range, inspect Greeks, and attach contracts to hedge workflows.",
"options",
):
with ui.row().classes("w-full gap-6 max-lg: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"):
ui.label("Filters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
expiry_select = ui.select(expiries, value=selected_expiry["value"], label="Expiry").classes("w-full")
min_strike = ui.number("Min strike", value=strike_range["min"], min=strike_values[0], max=strike_values[-1], step=5).classes("w-full")
max_strike = ui.number("Max strike", value=strike_range["max"], min=strike_values[0], max=strike_values[-1], step=5).classes("w-full")
strategy_select = ui.select([item["label"] for item in strategy_catalog()], value=selected_strategy["value"], label="Add to hedge strategy").classes("w-full")
selection_card = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900")
chain_table = ui.html("").classes("w-full")
greeks = GreeksTable([])
def filtered_rows() -> list[dict]:
return [
row
for row in chain
if row["expiry"] == selected_expiry["value"] and strike_range["min"] <= row["strike"] <= strike_range["max"]
]
def render_selection() -> None:
selection_card.clear()
with selection_card:
ui.label("Strategy Integration").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Target strategy: {selected_strategy['value']}").classes("text-sm text-slate-500 dark:text-slate-400")
if not chosen_contracts:
ui.label("No contracts added yet.").classes("text-sm text-slate-500 dark:text-slate-400")
return
for contract in chosen_contracts[-3:]:
ui.label(
f"{contract['symbol']} · premium ${contract['premium']:.2f} · Δ {contract['delta']:+.3f}"
).classes("text-sm text-slate-600 dark:text-slate-300")
def add_to_strategy(contract: dict) -> None:
chosen_contracts.append(contract)
render_selection()
greeks.set_options(chosen_contracts[-6:])
ui.notify(f"Added {contract['symbol']} to {selected_strategy['value']}", color="positive")
def render_chain() -> None:
rows = filtered_rows()
chain_table.content = """
<div class='overflow-x-auto rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900'>
<table class='min-w-full'>
<thead class='bg-slate-100 dark:bg-slate-800'>
<tr>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Contract</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Type</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Strike</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Bid / Ask</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Greeks</th>
<th class='px-4 py-3 text-left text-xs font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-300'>Action</th>
</tr>
</thead>
<tbody>
""" + "".join(
f"""
<tr class='border-b border-slate-200 dark:border-slate-800'>
<td class='px-4 py-3 font-medium text-slate-900 dark:text-slate-100'>{row['symbol']}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>{row['type'].upper()}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${row['strike']:.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'>${row['bid']:.2f} / ${row['ask']:.2f}</td>
<td class='px-4 py-3 text-slate-600 dark:text-slate-300'{row['delta']:+.3f} · Γ {row['gamma']:.3f} · Θ {row['theta']:+.3f}</td>
<td class='px-4 py-3 text-sky-600 dark:text-sky-300'>Use quick-add buttons below</td>
</tr>
"""
for row in rows
) + ("" if rows else "<tr><td colspan='6' class='px-4 py-6 text-center text-slate-500 dark:text-slate-400'>No contracts match the current filter.</td></tr>") + """
</tbody>
</table>
</div>
"""
chain_table.update()
quick_add.clear()
with quick_add:
ui.label("Quick add to hedge").classes("text-sm font-semibold uppercase tracking-wide text-slate-500 dark:text-slate-400")
with ui.row().classes("w-full gap-2 max-sm:flex-col"):
for row in rows[:6]:
ui.button(
f"Add {row['type'].upper()} {row['strike']:.0f}",
on_click=lambda _, contract=row: add_to_strategy(contract),
).props("outline color=primary")
greeks.set_options(rows[:6])
quick_add = ui.card().classes("w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900")
def update_filters() -> None:
selected_expiry["value"] = expiry_select.value
strike_range["min"] = float(min_strike.value)
strike_range["max"] = float(max_strike.value)
if strike_range["min"] > strike_range["max"]:
strike_range["min"], strike_range["max"] = strike_range["max"], strike_range["min"]
min_strike.value = strike_range["min"]
max_strike.value = strike_range["max"]
render_chain()
expiry_select.on_value_change(lambda _: update_filters())
min_strike.on_value_change(lambda _: update_filters())
max_strike.on_value_change(lambda _: update_filters())
strategy_select.on_value_change(lambda event: (selected_strategy.__setitem__("value", event.value), render_selection()))
render_selection()
render_chain()

66
app/pages/overview.py Normal file
View File

@@ -0,0 +1,66 @@
from __future__ import annotations
from nicegui import ui
from app.components import PortfolioOverview
from app.pages.common import dashboard_page, portfolio_snapshot, quick_recommendations, recommendation_style, strategy_catalog
@ui.page("/")
@ui.page("/overview")
def overview_page() -> None:
portfolio = portfolio_snapshot()
with dashboard_page(
"Overview",
"Portfolio health, LTV risk, and quick strategy guidance for the current GLD-backed loan.",
"overview",
):
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
summary_cards = [
("Spot Price", f"${portfolio['spot_price']:,.2f}", "GLD reference price"),
("Margin Call Price", f"${portfolio['margin_call_price']:,.2f}", "Implied trigger level"),
("Cash Buffer", f"${portfolio['cash_buffer']:,.0f}", "Available liquidity"),
("Hedge Budget", f"${portfolio['hedge_budget']:,.0f}", "Approved premium budget"),
]
for title, value, caption in summary_cards:
with ui.card().classes("rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"):
ui.label(title).classes("text-sm font-medium text-slate-500 dark:text-slate-400")
ui.label(value).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
ui.label(caption).classes("text-sm text-slate-500 dark:text-slate-400")
portfolio_view = PortfolioOverview(margin_call_ltv=portfolio["margin_call_ltv"])
portfolio_view.update(portfolio)
with ui.row().classes("w-full gap-6 max-lg: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 {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=portfolio["ltv_ratio"] / portfolio["margin_call_ltv"], show_value=False).props("color=warning track-color=grey-3 rounded")
ui.label(
f"Current LTV is {portfolio['ltv_ratio'] * 100:.1f}% with a margin buffer of {(portfolio['margin_call_ltv'] - 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"):
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"
)
ui.label("Quick Strategy Recommendations").classes("text-xl font-semibold text-slate-900 dark:text-slate-100")
with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"):
for rec in quick_recommendations():
with ui.card().classes(f"rounded-2xl border shadow-sm {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")

58
app/pages/settings.py Normal file
View File

@@ -0,0 +1,58 @@
from __future__ import annotations
from nicegui import ui
from app.pages.common import dashboard_page
@ui.page("/settings")
def settings_page() -> None:
with dashboard_page(
"Settings",
"Configure portfolio assumptions, preferred market data inputs, alert thresholds, and import/export behavior.",
"settings",
):
with ui.row().classes("w-full gap-6 max-lg: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"):
ui.label("Portfolio Parameters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
gold_value = ui.number("Gold collateral value", value=215000, min=0, step=1000).classes("w-full")
loan_amount = ui.number("Loan amount", value=145000, min=0, step=1000).classes("w-full")
margin_threshold = ui.number("Margin call LTV", value=0.75, min=0.1, max=0.95, step=0.01).classes("w-full")
hedge_budget = ui.number("Monthly hedge budget", value=8000, min=0, step=500).classes("w-full")
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("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
primary_source = ui.select(["yfinance", "ibkr", "alpaca"], value="yfinance", label="Primary source").classes("w-full")
fallback_source = ui.select(["fallback", "yfinance", "manual"], value="fallback", label="Fallback source").classes("w-full")
refresh_interval = ui.number("Refresh interval (seconds)", value=5, min=1, step=1).classes("w-full")
ui.switch("Enable Redis cache", value=True)
with ui.row().classes("w-full gap-6 max-lg: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"):
ui.label("Alert Thresholds").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ltv_warning = ui.number("LTV warning level", value=0.70, min=0.1, max=0.95, step=0.01).classes("w-full")
vol_alert = ui.number("Volatility spike alert", value=0.25, min=0.01, max=2.0, step=0.01).classes("w-full")
price_alert = ui.number("Spot drawdown alert (%)", value=7.5, min=0.1, max=50.0, step=0.5).classes("w-full")
email_alerts = ui.switch("Email alerts", value=False)
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("Export / Import").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
export_format = ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full")
ui.switch("Include scenario history", value=True)
ui.switch("Include option selections", value=True)
ui.button("Import settings", icon="upload").props("outline color=primary")
ui.button("Export settings", icon="download").props("outline color=primary")
def save_settings() -> None:
status.set_text(
"Saved configuration: "
f"gold=${gold_value.value:,.0f}, loan=${loan_amount.value:,.0f}, margin={margin_threshold.value:.2f}, "
f"primary={primary_source.value}, fallback={fallback_source.value}, refresh={refresh_interval.value}s, "
f"ltv warning={ltv_warning.value:.2f}, vol={vol_alert.value:.2f}, drawdown={price_alert.value:.1f}%, "
f"email alerts={'on' if email_alerts.value else 'off'}, export={export_format.value}."
)
ui.notify("Settings saved", color="positive")
with ui.row().classes("w-full items-center justify-between gap-4"):
status = ui.label("").classes("text-sm text-slate-500 dark:text-slate-400")
ui.button("Save settings", on_click=save_settings).props("color=primary")

72
app/services/cache.py Normal file
View File

@@ -0,0 +1,72 @@
"""Redis-backed caching utilities with graceful fallback support."""
from __future__ import annotations
import json
import logging
from datetime import datetime
from typing import Any
logger = logging.getLogger(__name__)
try:
from redis.asyncio import Redis
except ImportError: # pragma: no cover - optional dependency
Redis = None
class CacheService:
"""Small async cache wrapper around Redis."""
def __init__(self, url: str | None, default_ttl: int = 300) -> None:
self.url = url
self.default_ttl = default_ttl
self._client: Redis | None = None
self._enabled = bool(url and Redis)
@property
def enabled(self) -> bool:
return self._enabled and self._client is not None
async def connect(self) -> None:
if not self._enabled:
if self.url and Redis is None:
logger.warning("Redis URL configured but redis package is not installed; cache disabled")
return
try:
self._client = Redis.from_url(self.url, decode_responses=True)
await self._client.ping()
logger.info("Connected to Redis cache")
except Exception as exc: # pragma: no cover - network dependent
logger.warning("Redis unavailable, cache disabled: %s", exc)
self._client = None
async def close(self) -> None:
if self._client is None:
return
await self._client.aclose()
self._client = None
async def get_json(self, key: str) -> dict[str, Any] | list[Any] | None:
if self._client is None:
return None
value = await self._client.get(key)
if value is None:
return None
return json.loads(value)
async def set_json(self, key: str, value: Any, ttl: int | None = None) -> None:
if self._client is None:
return
payload = json.dumps(value, default=self._json_default)
await self._client.set(key, payload, ex=ttl or self.default_ttl)
@staticmethod
def _json_default(value: Any) -> str:
if isinstance(value, datetime):
return value.isoformat()
raise TypeError(f"Object of type {type(value).__name__} is not JSON serializable")

View File

@@ -0,0 +1,145 @@
"""Market data access layer with caching support."""
from __future__ import annotations
import asyncio
import logging
from datetime import UTC, datetime
from typing import Any
from app.services.cache import CacheService
from app.strategies.engine import StrategySelectionEngine
logger = logging.getLogger(__name__)
try:
import yfinance as yf
except ImportError: # pragma: no cover - optional dependency
yf = None
class DataService:
"""Fetches portfolio and market data, using Redis when available."""
def __init__(self, cache: CacheService, default_symbol: str = "GLD") -> None:
self.cache = cache
self.default_symbol = default_symbol
async def get_portfolio(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper()
cache_key = f"portfolio:{ticker}"
cached = await self.cache.get_json(cache_key)
if cached:
return cached
quote = await self.get_quote(ticker)
portfolio = {
"symbol": ticker,
"spot_price": quote["price"],
"portfolio_value": round(quote["price"] * 1000, 2),
"loan_amount": 600_000.0,
"ltv_ratio": round(600_000.0 / max(quote["price"] * 1000, 1), 4),
"updated_at": datetime.now(UTC).isoformat(),
"source": quote["source"],
}
await self.cache.set_json(cache_key, portfolio)
return portfolio
async def get_quote(self, symbol: str) -> dict[str, Any]:
cache_key = f"quote:{symbol}"
cached = await self.cache.get_json(cache_key)
if cached:
return cached
quote = await self._fetch_quote(symbol)
await self.cache.set_json(cache_key, quote)
return quote
async def get_options_chain(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper()
cache_key = f"options:{ticker}"
cached = await self.cache.get_json(cache_key)
if cached:
return cached
quote = await self.get_quote(ticker)
base_price = quote["price"]
options_chain = {
"symbol": ticker,
"updated_at": datetime.now(UTC).isoformat(),
"calls": [
{"strike": round(base_price * 1.05, 2), "premium": round(base_price * 0.03, 2), "expiry": "2026-06-19"},
{"strike": round(base_price * 1.10, 2), "premium": round(base_price * 0.02, 2), "expiry": "2026-09-18"},
],
"puts": [
{"strike": round(base_price * 0.95, 2), "premium": round(base_price * 0.028, 2), "expiry": "2026-06-19"},
{"strike": round(base_price * 0.90, 2), "premium": round(base_price * 0.018, 2), "expiry": "2026-09-18"},
],
"source": quote["source"],
}
await self.cache.set_json(cache_key, options_chain)
return options_chain
async def get_strategies(self, symbol: str | None = None) -> dict[str, Any]:
ticker = (symbol or self.default_symbol).upper()
quote = await self.get_quote(ticker)
engine = StrategySelectionEngine(spot_price=quote["price"] if ticker != "GLD" else 460.0)
return {
"symbol": ticker,
"updated_at": datetime.now(UTC).isoformat(),
"paper_parameters": {
"portfolio_value": engine.portfolio_value,
"loan_amount": engine.loan_amount,
"margin_call_threshold": engine.margin_call_threshold,
"spot_price": engine.spot_price,
"volatility": engine.volatility,
"risk_free_rate": engine.risk_free_rate,
},
"strategies": engine.compare_all_strategies(),
"recommendations": {
profile: engine.recommend(profile)
for profile in ("conservative", "balanced", "cost_sensitive")
},
"sensitivity_analysis": engine.sensitivity_analysis(),
}
async def _fetch_quote(self, symbol: str) -> dict[str, Any]:
if yf is None:
return self._fallback_quote(symbol, source="fallback")
try:
ticker = yf.Ticker(symbol)
history = await asyncio.to_thread(ticker.history, period="5d", interval="1d")
if history.empty:
return self._fallback_quote(symbol, source="fallback")
closes = history["Close"]
last = float(closes.iloc[-1])
previous = float(closes.iloc[-2]) if len(closes) > 1 else last
change = round(last - previous, 4)
change_percent = round((change / previous) * 100, 4) if previous else 0.0
return {
"symbol": symbol,
"price": round(last, 4),
"change": change,
"change_percent": change_percent,
"updated_at": datetime.now(UTC).isoformat(),
"source": "yfinance",
}
except Exception as exc: # pragma: no cover - network dependent
logger.warning("Failed to fetch %s from yfinance: %s", symbol, exc)
return self._fallback_quote(symbol, source="fallback")
@staticmethod
def _fallback_quote(symbol: str, source: str) -> dict[str, Any]:
return {
"symbol": symbol,
"price": 215.0,
"change": 0.0,
"change_percent": 0.0,
"updated_at": datetime.now(UTC).isoformat(),
"source": source,
}

View File

@@ -0,0 +1,17 @@
from .base import BaseStrategy, StrategyConfig
from .engine import StrategySelectionEngine
from .laddered_put import LadderSpec, LadderedPutStrategy
from .lease import LeaseAnalysisSpec, LeaseStrategy
from .protective_put import ProtectivePutSpec, ProtectivePutStrategy
__all__ = [
"BaseStrategy",
"StrategyConfig",
"ProtectivePutSpec",
"ProtectivePutStrategy",
"LadderSpec",
"LadderedPutStrategy",
"LeaseAnalysisSpec",
"LeaseStrategy",
"StrategySelectionEngine",
]

40
app/strategies/base.py Normal file
View File

@@ -0,0 +1,40 @@
from __future__ import annotations
from abc import ABC, abstractmethod
from dataclasses import dataclass
from app.models.portfolio import LombardPortfolio
@dataclass(frozen=True)
class StrategyConfig:
"""Common research inputs used by all strategy implementations."""
portfolio: LombardPortfolio
spot_price: float
volatility: float
risk_free_rate: float
class BaseStrategy(ABC):
"""Abstract strategy interface for paper-based hedge analysis."""
def __init__(self, config: StrategyConfig) -> None:
self.config = config
@property
@abstractmethod
def name(self) -> str: # pragma: no cover - interface only
raise NotImplementedError
@abstractmethod
def calculate_cost(self) -> dict:
raise NotImplementedError
@abstractmethod
def calculate_protection(self) -> dict:
raise NotImplementedError
@abstractmethod
def get_scenarios(self) -> list[dict]:
raise NotImplementedError

159
app/strategies/engine.py Normal file
View File

@@ -0,0 +1,159 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Literal
from app.core.pricing.black_scholes import DEFAULT_GLD_PRICE, DEFAULT_RISK_FREE_RATE, DEFAULT_VOLATILITY
from app.models.portfolio import LombardPortfolio
from app.strategies.base import BaseStrategy, StrategyConfig
from app.strategies.laddered_put import LadderSpec, LadderedPutStrategy
from app.strategies.lease import LeaseStrategy
from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy
RiskProfile = Literal["conservative", "balanced", "cost_sensitive"]
RESEARCH_PORTFOLIO_VALUE = 1_000_000.0
RESEARCH_LOAN_AMOUNT = 600_000.0
RESEARCH_MARGIN_CALL_THRESHOLD = 0.75
RESEARCH_GLD_SPOT = 460.0
RESEARCH_VOLATILITY = 0.16
RESEARCH_RISK_FREE_RATE = 0.045
@dataclass(frozen=True)
class StrategySelectionEngine:
"""Compare paper strategies and recommend the best fit by risk profile."""
portfolio_value: float = RESEARCH_PORTFOLIO_VALUE
loan_amount: float = RESEARCH_LOAN_AMOUNT
margin_call_threshold: float = RESEARCH_MARGIN_CALL_THRESHOLD
spot_price: float = RESEARCH_GLD_SPOT
volatility: float = RESEARCH_VOLATILITY
risk_free_rate: float = RESEARCH_RISK_FREE_RATE
def _config(self) -> StrategyConfig:
portfolio = LombardPortfolio(
gold_ounces=self.portfolio_value / self.spot_price,
gold_price_per_ounce=self.spot_price,
loan_amount=self.loan_amount,
initial_ltv=self.loan_amount / self.portfolio_value,
margin_call_ltv=self.margin_call_threshold,
)
return StrategyConfig(
portfolio=portfolio,
spot_price=self.spot_price,
volatility=self.volatility,
risk_free_rate=self.risk_free_rate,
)
def _strategies(self) -> list[BaseStrategy]:
config = self._config()
return [
ProtectivePutStrategy(config, ProtectivePutSpec(label="ATM", strike_pct=1.0, months=12)),
ProtectivePutStrategy(config, ProtectivePutSpec(label="OTM_95", strike_pct=0.95, months=12)),
ProtectivePutStrategy(config, ProtectivePutSpec(label="OTM_90", strike_pct=0.90, months=12)),
LadderedPutStrategy(
config,
LadderSpec(label="50_50_ATM_OTM95", weights=(0.5, 0.5), strike_pcts=(1.0, 0.95), months=12),
),
LadderedPutStrategy(
config,
LadderSpec(label="33_33_33_ATM_OTM95_OTM90", weights=(1 / 3, 1 / 3, 1 / 3), strike_pcts=(1.0, 0.95, 0.90), months=12),
),
LeaseStrategy(config),
]
def compare_all_strategies(self) -> list[dict]:
comparisons: list[dict] = []
for strategy in self._strategies():
cost = strategy.calculate_cost()
protection = strategy.calculate_protection()
scenarios = strategy.get_scenarios()
annual_cost = cost.get("annualized_cost", cost.get("lowest_annual_cost", 0.0))
protection_ltv = protection.get("hedged_ltv_at_threshold")
if protection_ltv is None:
duration_rows = protection.get("durations", [])
protection_ltv = min((row["hedged_ltv_at_threshold"] for row in duration_rows), default=1.0)
comparisons.append(
{
"name": strategy.name,
"cost": cost,
"protection": protection,
"scenarios": scenarios,
"score_inputs": {
"annual_cost": annual_cost,
"hedged_ltv_at_threshold": protection_ltv,
},
}
)
return comparisons
def recommend(self, risk_profile: RiskProfile = "balanced") -> dict:
comparisons = self.compare_all_strategies()
def score(item: dict) -> tuple[float, float]:
annual_cost = item["score_inputs"]["annual_cost"]
hedged_ltv = item["score_inputs"]["hedged_ltv_at_threshold"]
if risk_profile == "conservative":
return (hedged_ltv, annual_cost)
if risk_profile == "cost_sensitive":
return (annual_cost, hedged_ltv)
return (hedged_ltv + (annual_cost / self.portfolio_value), annual_cost)
recommended = min(comparisons, key=score)
return {
"risk_profile": risk_profile,
"recommended_strategy": recommended["name"],
"rationale": {
"portfolio_value": self.portfolio_value,
"loan_amount": self.loan_amount,
"margin_call_threshold": self.margin_call_threshold,
"spot_price": self.spot_price,
"volatility": self.volatility,
"risk_free_rate": self.risk_free_rate,
},
"comparison_summary": [
{
"name": item["name"],
"annual_cost": round(item["score_inputs"]["annual_cost"], 2),
"hedged_ltv_at_threshold": round(item["score_inputs"]["hedged_ltv_at_threshold"], 6),
}
for item in comparisons
],
}
def sensitivity_analysis(self) -> dict:
results: dict[str, list[dict]] = {"volatility": [], "spot_price": []}
for volatility in (0.12, 0.16, 0.20):
engine = StrategySelectionEngine(
portfolio_value=self.portfolio_value,
loan_amount=self.loan_amount,
margin_call_threshold=self.margin_call_threshold,
spot_price=self.spot_price,
volatility=volatility,
risk_free_rate=self.risk_free_rate,
)
recommendation = engine.recommend("balanced")
results["volatility"].append(
{
"volatility": volatility,
"recommended_strategy": recommendation["recommended_strategy"],
}
)
for spot_price in (DEFAULT_GLD_PRICE * 0.9, DEFAULT_GLD_PRICE, DEFAULT_GLD_PRICE * 1.1):
engine = StrategySelectionEngine(
portfolio_value=self.portfolio_value,
loan_amount=self.loan_amount,
margin_call_threshold=self.margin_call_threshold,
spot_price=spot_price,
volatility=DEFAULT_VOLATILITY,
risk_free_rate=DEFAULT_RISK_FREE_RATE,
)
recommendation = engine.recommend("balanced")
results["spot_price"].append(
{
"spot_price": round(spot_price, 2),
"recommended_strategy": recommendation["recommended_strategy"],
}
)
return results

View File

@@ -0,0 +1,129 @@
from __future__ import annotations
from dataclasses import dataclass
from app.strategies.base import BaseStrategy, StrategyConfig
from app.strategies.protective_put import DEFAULT_SCENARIO_CHANGES, ProtectivePutSpec, ProtectivePutStrategy
@dataclass(frozen=True)
class LadderSpec:
label: str
weights: tuple[float, ...]
strike_pcts: tuple[float, ...]
months: int = 12
class LadderedPutStrategy(BaseStrategy):
"""Multi-strike protective put ladder with blended premium and protection analysis."""
def __init__(self, config: StrategyConfig, spec: LadderSpec) -> None:
super().__init__(config)
if len(spec.weights) != len(spec.strike_pcts):
raise ValueError("weights and strike_pcts must have the same length")
if abs(sum(spec.weights) - 1.0) > 1e-9:
raise ValueError("weights must sum to 1.0")
self.spec = spec
@property
def name(self) -> str:
return f"laddered_put_{self.spec.label.lower()}"
def _legs(self) -> list[tuple[float, ProtectivePutStrategy]]:
legs: list[tuple[float, ProtectivePutStrategy]] = []
for index, (weight, strike_pct) in enumerate(zip(self.spec.weights, self.spec.strike_pcts, strict=True), start=1):
leg = ProtectivePutStrategy(
self.config,
ProtectivePutSpec(label=f"{self.spec.label}_leg_{index}", strike_pct=strike_pct, months=self.spec.months),
)
legs.append((weight, leg))
return legs
def calculate_cost(self) -> dict:
blended_cost = 0.0
blended_premium = 0.0
legs_summary: list[dict] = []
for weight, leg in self._legs():
contract = leg.build_contract()
weighted_cost = contract.total_premium * weight
blended_cost += weighted_cost
blended_premium += contract.premium * weight
legs_summary.append(
{
"weight": weight,
"strike": round(contract.strike, 2),
"premium_per_share": round(contract.premium, 4),
"weighted_cost": round(weighted_cost, 2),
}
)
annualized_cost = blended_cost / (self.spec.months / 12.0)
return {
"strategy": self.name,
"label": self.spec.label,
"legs": legs_summary,
"blended_premium_per_share": round(blended_premium, 4),
"blended_cost": round(blended_cost, 2),
"cost_pct_of_portfolio": round(blended_cost / self.config.portfolio.gold_value, 6),
"annualized_cost": round(annualized_cost, 2),
"annualized_cost_pct": round(annualized_cost / self.config.portfolio.gold_value, 6),
}
def calculate_protection(self) -> dict:
threshold_price = self.config.portfolio.margin_call_price()
total_payoff = 0.0
floor_value = 0.0
leg_protection: list[dict] = []
for weight, leg in self._legs():
contract = leg.build_contract()
weighted_payoff = contract.payoff(threshold_price) * weight
total_payoff += weighted_payoff
floor_value += contract.strike * leg.hedge_units * weight
leg_protection.append(
{
"weight": weight,
"strike": round(contract.strike, 2),
"weighted_payoff_at_threshold": round(weighted_payoff, 2),
}
)
hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + total_payoff
protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold
return {
"strategy": self.name,
"threshold_price": round(threshold_price, 2),
"portfolio_floor_value": round(floor_value, 2),
"payoff_at_threshold": round(total_payoff, 2),
"unhedged_ltv_at_threshold": round(self.config.portfolio.ltv_at_price(threshold_price), 6),
"hedged_ltv_at_threshold": round(protected_ltv, 6),
"maintains_margin_call_buffer": protected_ltv < self.config.portfolio.margin_call_ltv,
"legs": leg_protection,
}
def get_scenarios(self) -> list[dict]:
cost = self.calculate_cost()["blended_cost"]
scenarios: list[dict] = []
for change in DEFAULT_SCENARIO_CHANGES:
price = self.config.spot_price * (1 + change)
if price <= 0:
continue
gold_value = self.config.portfolio.gold_value_at_price(price)
option_payoff = 0.0
for weight, leg in self._legs():
option_payoff += leg.build_contract().payoff(price) * weight
hedged_collateral = gold_value + option_payoff
scenarios.append(
{
"price_change_pct": round(change, 2),
"gld_price": round(price, 2),
"gold_value": round(gold_value, 2),
"option_payoff": round(option_payoff, 2),
"hedge_cost": round(cost, 2),
"net_portfolio_value": round(gold_value + option_payoff - cost, 2),
"unhedged_ltv": round(self.config.portfolio.loan_amount / gold_value, 6),
"hedged_ltv": round(self.config.portfolio.loan_amount / hedged_collateral, 6),
"margin_call_without_hedge": (self.config.portfolio.loan_amount / gold_value)
>= self.config.portfolio.margin_call_ltv,
"margin_call_with_hedge": (self.config.portfolio.loan_amount / hedged_collateral)
>= self.config.portfolio.margin_call_ltv,
}
)
return scenarios

95
app/strategies/lease.py Normal file
View File

@@ -0,0 +1,95 @@
from __future__ import annotations
from dataclasses import dataclass
from app.strategies.base import BaseStrategy, StrategyConfig
from app.strategies.protective_put import ProtectivePutSpec, ProtectivePutStrategy
@dataclass(frozen=True)
class LeaseAnalysisSpec:
strike_pct: float = 1.0
durations_months: tuple[int, ...] = (3, 6, 12, 18, 24)
class LeaseStrategy(BaseStrategy):
"""LEAPS duration analysis with roll timing and annualized cost comparison."""
def __init__(self, config: StrategyConfig, spec: LeaseAnalysisSpec | None = None) -> None:
super().__init__(config)
self.spec = spec or LeaseAnalysisSpec()
@property
def name(self) -> str:
return "lease_duration_analysis"
def _protective_put(self, months: int) -> ProtectivePutStrategy:
return ProtectivePutStrategy(
self.config,
ProtectivePutSpec(label=f"LEAPS_{months}M", strike_pct=self.spec.strike_pct, months=months),
)
def _duration_rows(self) -> list[dict]:
rows: list[dict] = []
for months in self.spec.durations_months:
strategy = self._protective_put(months)
cost = strategy.calculate_cost()
rolls_per_year = 12 / months
rows.append(
{
"months": months,
"strike": cost["strike"],
"premium_per_share": cost["premium_per_share"],
"total_cost": cost["total_cost"],
"annualized_cost": cost["annualized_cost"],
"annualized_cost_pct": cost["annualized_cost_pct"],
"rolls_per_year": round(rolls_per_year, 4),
"recommended_roll_month": max(1, months - 1),
}
)
return rows
def calculate_cost(self) -> dict:
rows = self._duration_rows()
optimal = min(rows, key=lambda item: item["annualized_cost"])
return {
"strategy": self.name,
"comparison": rows,
"optimal_duration_months": optimal["months"],
"lowest_annual_cost": optimal["annualized_cost"],
"lowest_annual_cost_pct": optimal["annualized_cost_pct"],
}
def calculate_protection(self) -> dict:
threshold_price = self.config.portfolio.margin_call_price()
rows: list[dict] = []
for months in self.spec.durations_months:
strategy = self._protective_put(months)
protection = strategy.calculate_protection()
rows.append(
{
"months": months,
"payoff_at_threshold": protection["payoff_at_threshold"],
"hedged_ltv_at_threshold": protection["hedged_ltv_at_threshold"],
"maintains_margin_call_buffer": protection["maintains_margin_call_buffer"],
}
)
return {
"strategy": self.name,
"threshold_price": round(threshold_price, 2),
"durations": rows,
}
def get_scenarios(self) -> list[dict]:
scenarios: list[dict] = []
for months in self.spec.durations_months:
strategy = self._protective_put(months)
scenarios.append(
{
"months": months,
"annualized_cost": strategy.calculate_cost()["annualized_cost"],
"annualized_cost_pct": strategy.calculate_cost()["annualized_cost_pct"],
"sample_scenarios": strategy.get_scenarios(),
}
)
return scenarios

View File

@@ -0,0 +1,139 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import date, timedelta
from app.core.pricing.black_scholes import BlackScholesInputs, black_scholes_price_and_greeks
from app.models.option import Greeks, OptionContract
from app.models.strategy import HedgingStrategy
from app.strategies.base import BaseStrategy, StrategyConfig
DEFAULT_SCENARIO_CHANGES = (-0.6, -0.5, -0.4, -0.3, -0.2, -0.1, 0.0, 0.1, 0.2, 0.3, 0.4, 0.5)
@dataclass(frozen=True)
class ProtectivePutSpec:
label: str
strike_pct: float
months: int = 12
class ProtectivePutStrategy(BaseStrategy):
"""Single-leg protective put strategy using ATM or configurable OTM strikes."""
def __init__(self, config: StrategyConfig, spec: ProtectivePutSpec) -> None:
super().__init__(config)
self.spec = spec
@property
def name(self) -> str:
return f"protective_put_{self.spec.label.lower()}"
@property
def hedge_units(self) -> float:
return self.config.portfolio.gold_value / self.config.spot_price
@property
def strike(self) -> float:
return self.config.spot_price * self.spec.strike_pct
@property
def term_years(self) -> float:
return self.spec.months / 12.0
def build_contract(self) -> OptionContract:
pricing = black_scholes_price_and_greeks(
BlackScholesInputs(
spot=self.config.spot_price,
strike=self.strike,
time_to_expiry=self.term_years,
risk_free_rate=self.config.risk_free_rate,
volatility=self.config.volatility,
option_type="put",
)
)
return OptionContract(
option_type="put",
strike=self.strike,
expiry=date.today() + timedelta(days=max(1, round(365 * self.term_years))),
premium=pricing.price,
quantity=1.0,
contract_size=self.hedge_units,
underlying_price=self.config.spot_price,
greeks=Greeks(
delta=pricing.delta,
gamma=pricing.gamma,
theta=pricing.theta,
vega=pricing.vega,
rho=pricing.rho,
),
)
def build_hedging_strategy(self) -> HedgingStrategy:
return HedgingStrategy(
strategy_type="single_put",
long_contracts=(self.build_contract(),),
description=f"{self.spec.label} protective put",
)
def calculate_cost(self) -> dict:
contract = self.build_contract()
total_cost = contract.total_premium
return {
"strategy": self.name,
"label": self.spec.label,
"strike": round(contract.strike, 2),
"strike_pct": self.spec.strike_pct,
"premium_per_share": round(contract.premium, 4),
"total_cost": round(total_cost, 2),
"cost_pct_of_portfolio": round(total_cost / self.config.portfolio.gold_value, 6),
"term_months": self.spec.months,
"annualized_cost": round(total_cost / self.term_years, 2),
"annualized_cost_pct": round((total_cost / self.term_years) / self.config.portfolio.gold_value, 6),
}
def calculate_protection(self) -> dict:
contract = self.build_contract()
threshold_price = self.config.portfolio.margin_call_price()
payoff_at_threshold = contract.payoff(threshold_price)
hedged_value_at_threshold = self.config.portfolio.gold_value_at_price(threshold_price) + payoff_at_threshold
protected_ltv = self.config.portfolio.loan_amount / hedged_value_at_threshold
floor_value = contract.strike * self.hedge_units
return {
"strategy": self.name,
"threshold_price": round(threshold_price, 2),
"strike": round(contract.strike, 2),
"portfolio_floor_value": round(floor_value, 2),
"unhedged_ltv_at_threshold": round(self.config.portfolio.ltv_at_price(threshold_price), 6),
"hedged_ltv_at_threshold": round(protected_ltv, 6),
"payoff_at_threshold": round(payoff_at_threshold, 2),
"maintains_margin_call_buffer": protected_ltv < self.config.portfolio.margin_call_ltv,
}
def get_scenarios(self) -> list[dict]:
strategy = self.build_hedging_strategy()
scenarios: list[dict] = []
for change in DEFAULT_SCENARIO_CHANGES:
price = self.config.spot_price * (1 + change)
if price <= 0:
continue
gold_value = self.config.portfolio.gold_value_at_price(price)
option_payoff = strategy.gross_payoff(price)
hedged_collateral = gold_value + option_payoff
scenarios.append(
{
"price_change_pct": round(change, 2),
"gld_price": round(price, 2),
"gold_value": round(gold_value, 2),
"option_payoff": round(option_payoff, 2),
"hedge_cost": round(strategy.hedge_cost, 2),
"net_portfolio_value": round(gold_value + option_payoff - strategy.hedge_cost, 2),
"unhedged_ltv": round(self.config.portfolio.loan_amount / gold_value, 6),
"hedged_ltv": round(self.config.portfolio.loan_amount / hedged_collateral, 6),
"margin_call_without_hedge": (self.config.portfolio.loan_amount / gold_value)
>= self.config.portfolio.margin_call_ltv,
"margin_call_with_hedge": (self.config.portfolio.loan_amount / hedged_collateral)
>= self.config.portfolio.margin_call_ltv,
}
)
return scenarios