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:
1
app/__init__.py
Normal file
1
app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Vault dashboard application package."""
|
||||
28
app/api/routes.py
Normal file
28
app/api/routes.py
Normal 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)
|
||||
13
app/components/__init__.py
Normal file
13
app/components/__init__.py
Normal 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
182
app/components/charts.py
Normal 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});
|
||||
}})();
|
||||
"""
|
||||
)
|
||||
104
app/components/greeks_table.py
Normal file
104
app/components/greeks_table.py
Normal 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>'
|
||||
)
|
||||
68
app/components/portfolio_view.py
Normal file
68
app/components/portfolio_view.py
Normal 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;")
|
||||
158
app/components/strategy_panel.py
Normal file
158
app/components/strategy_panel.py
Normal 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
21
app/core/__init__.py
Normal 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
98
app/core/calculations.py
Normal 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,
|
||||
)
|
||||
58
app/core/pricing/__init__.py
Normal file
58
app/core/pricing/__init__.py
Normal 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",
|
||||
]
|
||||
)
|
||||
194
app/core/pricing/american_pricing.py
Normal file
194
app/core/pricing/american_pricing.py
Normal 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,
|
||||
)
|
||||
210
app/core/pricing/black_scholes.py
Normal file
210
app/core/pricing/black_scholes.py
Normal 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,
|
||||
)
|
||||
127
app/core/pricing/volatility.py
Normal file
127
app/core/pricing/volatility.py
Normal 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
172
app/main.py
Normal 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
15
app/models/__init__.py
Normal 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
109
app/models/option.py
Normal 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
71
app/models/portfolio.py
Normal 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
101
app/models/strategy.py
Normal 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
3
app/pages/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import hedge, options, overview, settings
|
||||
|
||||
__all__ = ["overview", "hedge", "options", "settings"]
|
||||
203
app/pages/common.py
Normal file
203
app/pages/common.py
Normal file
@@ -0,0 +1,203 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, Iterator
|
||||
|
||||
from nicegui import ui
|
||||
|
||||
NAV_ITEMS: list[tuple[str, str, str]] = [
|
||||
("overview", "/", "Overview"),
|
||||
("hedge", "/hedge", "Hedge Analysis"),
|
||||
("options", "/options", "Options Chain"),
|
||||
("settings", "/settings", "Settings"),
|
||||
]
|
||||
|
||||
|
||||
def demo_spot_price() -> float:
|
||||
return 215.0
|
||||
|
||||
|
||||
def portfolio_snapshot() -> dict[str, float]:
|
||||
gold_units = 1_000.0
|
||||
spot = demo_spot_price()
|
||||
gold_value = gold_units * spot
|
||||
loan_amount = 145_000.0
|
||||
margin_call_ltv = 0.75
|
||||
return {
|
||||
"gold_value": gold_value,
|
||||
"loan_amount": loan_amount,
|
||||
"ltv_ratio": loan_amount / gold_value,
|
||||
"net_equity": gold_value - loan_amount,
|
||||
"spot_price": spot,
|
||||
"margin_call_ltv": margin_call_ltv,
|
||||
"margin_call_price": loan_amount / (margin_call_ltv * gold_units),
|
||||
"cash_buffer": 18_500.0,
|
||||
"hedge_budget": 8_000.0,
|
||||
}
|
||||
|
||||
|
||||
def strategy_catalog() -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": "protective_put",
|
||||
"label": "Protective Put",
|
||||
"description": "Full downside protection below the hedge strike with uncapped upside.",
|
||||
"estimated_cost": 6.25,
|
||||
"max_drawdown_floor": 210.0,
|
||||
"coverage": "High",
|
||||
},
|
||||
{
|
||||
"name": "collar",
|
||||
"label": "Collar",
|
||||
"description": "Lower premium by financing puts with covered call upside caps.",
|
||||
"estimated_cost": 2.10,
|
||||
"max_drawdown_floor": 208.0,
|
||||
"upside_cap": 228.0,
|
||||
"coverage": "Balanced",
|
||||
},
|
||||
{
|
||||
"name": "laddered_puts",
|
||||
"label": "Laddered Puts",
|
||||
"description": "Multiple maturities and strikes reduce roll concentration and smooth protection.",
|
||||
"estimated_cost": 4.45,
|
||||
"max_drawdown_floor": 205.0,
|
||||
"coverage": "Layered",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def quick_recommendations() -> list[dict[str, str]]:
|
||||
portfolio = portfolio_snapshot()
|
||||
ltv_gap = (portfolio["margin_call_ltv"] - portfolio["ltv_ratio"]) * 100
|
||||
return [
|
||||
{
|
||||
"title": "Balanced hedge favored",
|
||||
"summary": "A collar keeps the current LTV comfortably below the margin threshold while limiting upfront spend.",
|
||||
"tone": "positive",
|
||||
},
|
||||
{
|
||||
"title": f"{ltv_gap:.1f} pts LTV headroom",
|
||||
"summary": "You still have room before a margin trigger, so prefer cost-efficient protection over maximum convexity.",
|
||||
"tone": "info",
|
||||
},
|
||||
{
|
||||
"title": "Roll window approaching",
|
||||
"summary": "Stage long-dated puts now and keep a near-dated layer for event risk over the next quarter.",
|
||||
"tone": "warning",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def option_chain() -> list[dict[str, Any]]:
|
||||
spot = demo_spot_price()
|
||||
expiries = ["2026-04-17", "2026-06-19", "2026-09-18"]
|
||||
strikes = [190.0, 200.0, 210.0, 215.0, 220.0, 230.0]
|
||||
rows: list[dict[str, Any]] = []
|
||||
for expiry in expiries:
|
||||
for strike in strikes:
|
||||
distance = (strike - spot) / spot
|
||||
for option_type in ("put", "call"):
|
||||
premium_base = 8.2 if option_type == "put" else 7.1
|
||||
premium = round(max(1.1, premium_base - abs(distance) * 18 + (0.8 if expiry == "2026-09-18" else 0.0)), 2)
|
||||
delta = round((0.5 - distance * 1.8) * (-1 if option_type == "put" else 1), 3)
|
||||
rows.append(
|
||||
{
|
||||
"symbol": f"GLD {expiry} {option_type.upper()} {strike:.0f}",
|
||||
"expiry": expiry,
|
||||
"type": option_type,
|
||||
"strike": strike,
|
||||
"premium": premium,
|
||||
"bid": round(max(premium - 0.18, 0.5), 2),
|
||||
"ask": round(premium + 0.18, 2),
|
||||
"open_interest": int(200 + abs(spot - strike) * 14),
|
||||
"volume": int(75 + abs(spot - strike) * 8),
|
||||
"delta": max(-0.95, min(0.95, delta)),
|
||||
"gamma": round(max(0.012, 0.065 - abs(distance) * 0.12), 3),
|
||||
"theta": round(-0.014 - abs(distance) * 0.025, 3),
|
||||
"vega": round(0.09 + max(0.0, 0.24 - abs(distance) * 0.6), 3),
|
||||
"rho": round((0.04 + abs(distance) * 0.09) * (-1 if option_type == "put" else 1), 3),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def strategy_metrics(strategy_name: str, scenario_pct: int) -> dict[str, Any]:
|
||||
strategy = next((item for item in strategy_catalog() if item["name"] == strategy_name), strategy_catalog()[0])
|
||||
spot = demo_spot_price()
|
||||
floor = float(strategy.get("max_drawdown_floor", spot * 0.95))
|
||||
cap = strategy.get("upside_cap")
|
||||
cost = float(strategy["estimated_cost"])
|
||||
|
||||
scenario_prices = [round(spot * (1 + pct / 100), 2) for pct in range(-25, 30, 5)]
|
||||
benefits: list[float] = []
|
||||
for price in scenario_prices:
|
||||
payoff = max(floor - price, 0.0)
|
||||
if isinstance(cap, (int, float)) and price > float(cap):
|
||||
payoff -= price - float(cap)
|
||||
benefits.append(round(payoff - cost, 2))
|
||||
|
||||
scenario_price = round(spot * (1 + scenario_pct / 100), 2)
|
||||
unhedged_equity = scenario_price * 1_000 - 145_000.0
|
||||
scenario_payoff = max(floor - scenario_price, 0.0)
|
||||
capped_upside = 0.0
|
||||
if isinstance(cap, (int, float)) and scenario_price > float(cap):
|
||||
capped_upside = -(scenario_price - float(cap))
|
||||
hedged_equity = unhedged_equity + scenario_payoff + capped_upside - cost * 1_000
|
||||
|
||||
waterfall_steps = [
|
||||
("Base equity", round(70_000.0, 2)),
|
||||
("Spot move", round((scenario_price - spot) * 1_000, 2)),
|
||||
("Option payoff", round(scenario_payoff * 1_000, 2)),
|
||||
("Call cap", round(capped_upside * 1_000, 2)),
|
||||
("Hedge cost", round(-cost * 1_000, 2)),
|
||||
("Net equity", round(hedged_equity, 2)),
|
||||
]
|
||||
|
||||
return {
|
||||
"strategy": strategy,
|
||||
"scenario_pct": scenario_pct,
|
||||
"scenario_price": scenario_price,
|
||||
"scenario_series": [{"price": price, "benefit": benefit} for price, benefit in zip(scenario_prices, benefits, strict=True)],
|
||||
"waterfall_steps": waterfall_steps,
|
||||
"unhedged_equity": round(unhedged_equity, 2),
|
||||
"hedged_equity": round(hedged_equity, 2),
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def dashboard_page(title: str, subtitle: str, current: str) -> Iterator[ui.column]:
|
||||
ui.colors(primary="#0f172a", secondary="#1e293b", accent="#0ea5e9")
|
||||
|
||||
with ui.column().classes("mx-auto w-full max-w-7xl gap-6 bg-slate-50 p-6 dark:bg-slate-950") as container:
|
||||
with ui.header(elevated=False).classes("items-center justify-between border-b border-slate-200 bg-white/90 px-6 py-4 backdrop-blur dark:border-slate-800 dark:bg-slate-950/90"):
|
||||
with ui.row().classes("items-center gap-3"):
|
||||
ui.icon("shield").classes("text-2xl text-sky-500")
|
||||
with ui.column().classes("gap-0"):
|
||||
ui.label("Vault Dashboard").classes("text-lg font-bold text-slate-900 dark:text-slate-50")
|
||||
ui.label("NiceGUI hedging cockpit").classes("text-xs text-slate-500 dark:text-slate-400")
|
||||
with ui.row().classes("items-center gap-2 max-sm:flex-wrap"):
|
||||
for key, href, label in NAV_ITEMS:
|
||||
active = key == current
|
||||
link_classes = (
|
||||
"rounded-lg px-4 py-2 text-sm font-medium no-underline transition "
|
||||
+ (
|
||||
"bg-slate-900 text-white dark:bg-slate-100 dark:text-slate-900"
|
||||
if active
|
||||
else "text-slate-600 hover:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-800"
|
||||
)
|
||||
)
|
||||
ui.link(label, href).classes(link_classes)
|
||||
|
||||
with ui.row().classes("w-full items-end justify-between gap-4 max-md:flex-col max-md:items-start"):
|
||||
with ui.column().classes("gap-1"):
|
||||
ui.label(title).classes("text-3xl font-bold text-slate-900 dark:text-slate-50")
|
||||
ui.label(subtitle).classes("text-slate-500 dark:text-slate-400")
|
||||
yield container
|
||||
|
||||
|
||||
def recommendation_style(tone: str) -> str:
|
||||
return {
|
||||
"positive": "border-emerald-200 bg-emerald-50 dark:border-emerald-900/60 dark:bg-emerald-950/30",
|
||||
"warning": "border-amber-200 bg-amber-50 dark:border-amber-900/60 dark:bg-amber-950/30",
|
||||
"info": "border-sky-200 bg-sky-50 dark:border-sky-900/60 dark:bg-sky-950/30",
|
||||
}.get(tone, "border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900")
|
||||
126
app/pages/hedge.py
Normal file
126
app/pages/hedge.py
Normal 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
126
app/pages/options.py
Normal 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
66
app/pages/overview.py
Normal 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
58
app/pages/settings.py
Normal 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
72
app/services/cache.py
Normal 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")
|
||||
145
app/services/data_service.py
Normal file
145
app/services/data_service.py
Normal 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,
|
||||
}
|
||||
17
app/strategies/__init__.py
Normal file
17
app/strategies/__init__.py
Normal 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
40
app/strategies/base.py
Normal 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
159
app/strategies/engine.py
Normal 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
|
||||
129
app/strategies/laddered_put.py
Normal file
129
app/strategies/laddered_put.py
Normal 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
95
app/strategies/lease.py
Normal 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
|
||||
139
app/strategies/protective_put.py
Normal file
139
app/strategies/protective_put.py
Normal 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
|
||||
Reference in New Issue
Block a user