- Pin black version in requirements-dev.txt (was >=24.0.0) - Update pre-commit to use black 26.3.1 with Python 3.12 - Add language_version: python3.12 to pre-commit black hook - Reformat files with new black version for consistency
181 lines
8.5 KiB
Python
181 lines
8.5 KiB
Python
from __future__ import annotations
|
||
|
||
from typing import Any
|
||
|
||
from nicegui import ui
|
||
|
||
from app.domain.portfolio_math import (
|
||
strategy_benefit_per_unit,
|
||
strategy_protection_floor_bounds,
|
||
strategy_upside_cap_price,
|
||
)
|
||
|
||
|
||
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))
|
||
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._format_floor(strategy)}</td>
|
||
<td class=\"px-4 py-3 text-slate-600 dark:text-slate-300\">{self._format_cap(strategy)}</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:
|
||
return strategy_benefit_per_unit(
|
||
strategy,
|
||
current_spot=self.current_spot,
|
||
scenario_spot=self._scenario_spot(),
|
||
)
|
||
|
||
def _format_floor(self, strategy: dict[str, Any]) -> str:
|
||
bounds = strategy_protection_floor_bounds(strategy, current_spot=self.current_spot)
|
||
if bounds is None:
|
||
return self._fmt_optional_money(strategy.get("max_drawdown_floor"))
|
||
low, high = bounds
|
||
if abs(high - low) < 1e-9:
|
||
return f"${high:,.2f}"
|
||
return f"${low:,.2f}–${high:,.2f}"
|
||
|
||
def _format_cap(self, strategy: dict[str, Any]) -> str:
|
||
cap = strategy_upside_cap_price(strategy, current_spot=self.current_spot)
|
||
if cap is None:
|
||
return self._fmt_optional_money(strategy.get("upside_cap"))
|
||
return f"${cap:,.2f}"
|
||
|
||
@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>"
|
||
)
|