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:
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>'
|
||||
)
|
||||
Reference in New Issue
Block a user