Initial commit: Vault Dashboard for options hedging

- FastAPI + NiceGUI web application
- QuantLib-based Black-Scholes pricing with Greeks
- Protective put, laddered, and LEAPS strategies
- Real-time WebSocket updates
- TradingView-style charts via Lightweight-Charts
- Docker containerization
- GitLab CI/CD pipeline for VPS deployment
- VPN-only access configuration
This commit is contained in:
Bu5hm4nn
2026-03-21 19:21:40 +01:00
commit 00a68bc767
63 changed files with 6239 additions and 0 deletions

View File

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

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

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

View File

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

View File

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

View File

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