feat(EXEC-001): add hedge strategy builder

This commit is contained in:
Bu5hm4nn
2026-03-27 22:33:20 +01:00
parent 554a41a060
commit 4620234967
9 changed files with 429 additions and 37 deletions

View File

@@ -12,10 +12,10 @@ from app.pages.common import (
demo_spot_price,
portfolio_snapshot,
split_page_panes,
strategy_catalog,
strategy_metrics,
)
from app.services.runtime import get_data_service
from app.services.strategy_templates import StrategyTemplateService
logger = logging.getLogger(__name__)
@@ -99,12 +99,18 @@ async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str
async def _render_hedge_page(workspace_id: str | None = None) -> None:
repo = get_workspace_repository()
config = repo.load_portfolio_config(workspace_id) if workspace_id else None
portfolio, quote_source, quote_updated_at = await _resolve_hedge_spot(workspace_id)
strategies = strategy_catalog()
strategy_map = {strategy["label"]: strategy["name"] for strategy in strategies}
selected = {"strategy": strategies[0]["name"], "label": strategies[0]["label"], "scenario_pct": 0}
template_service = StrategyTemplateService()
strategies_state = {"items": template_service.catalog_items()}
def strategy_map() -> dict[str, str]:
return {strategy["label"]: strategy["template_slug"] for strategy in strategies_state["items"]}
selected = {
"strategy": strategies_state["items"][0]["template_slug"],
"label": strategies_state["items"][0]["label"],
"scenario_pct": 0,
}
if quote_source == "configured_entry_price":
spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (configured entry price)"
@@ -130,7 +136,9 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"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["label"], label="Strategy selector").classes("w-full")
selector = ui.select(
list(strategy_map().keys()), value=selected["label"], 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(spot_label).classes("text-sm text-slate-500 dark:text-slate-400")
@@ -144,6 +152,45 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"text-xs text-slate-500 dark:text-slate-400"
)
with (
ui.card()
.classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
.props("data-testid=strategy-builder-card")
):
ui.label("Strategy Builder").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(
"Save a custom protective put or equal-weight two-leg ladder for reuse across hedge, backtests, and event comparison."
).classes("text-sm text-slate-500 dark:text-slate-400")
builder_type_options = {"Protective put": "protective_put", "Two-leg ladder": "laddered_put"}
builder_name = ui.input("Template name", placeholder="Crash Guard 97").classes("w-full")
builder_type = ui.select(
list(builder_type_options.keys()),
value="Protective put",
label="Strategy type",
).classes("w-full")
builder_expiry_days = ui.number("Expiration days", value=365, min=30, step=30).classes("w-full")
builder_primary_strike = ui.number(
"Primary strike (% of spot)",
value=100,
min=1,
max=150,
step=1,
).classes("w-full")
builder_secondary_strike = ui.number(
"Secondary strike (% of spot)",
value=95,
min=1,
max=150,
step=1,
).classes("w-full")
ui.label("Two-leg ladders currently save with equal 50/50 weights.").classes(
"text-xs text-slate-500 dark:text-slate-400"
)
builder_status = ui.label("").classes("text-sm text-slate-600 dark:text-slate-300")
save_template_button = ui.button("Save template").props("color=primary outline")
summary = ui.card().classes(
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
@@ -161,12 +208,32 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
"h-96 w-full rounded-2xl border border-slate-200 bg-white p-4 shadow-sm dark:border-slate-800 dark:bg-slate-900"
)
syncing_controls = {"value": False}
def refresh_available_strategies() -> None:
strategies_state["items"] = template_service.catalog_items()
options = strategy_map()
syncing_controls["value"] = True
try:
selector.options = list(options.keys())
if selected["label"] not in options:
first_label = next(iter(options))
selected["label"] = first_label
selected["strategy"] = options[first_label]
selector.value = first_label
selector.update()
finally:
syncing_controls["value"] = False
def render_summary() -> None:
metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio)
strategy = metrics["strategy"]
summary.clear()
with summary:
ui.label("Scenario Summary").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(f"Selected template: {strategy['label']}").classes(
"text-sm text-slate-500 dark:text-slate-400"
)
ui.label(strategy["description"]).classes("text-sm text-slate-600 dark:text-slate-300")
with ui.grid(columns=1).classes("w-full gap-4 sm:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2"):
cards = [
@@ -190,7 +257,7 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
with ui.grid(columns=2).classes("w-full gap-4 max-md:grid-cols-1"):
result_cards = [
("Scenario spot", f"${metrics['scenario_price']:,.2f}"),
("Hedge cost", f"${strategy['estimated_cost']:,.2f}/oz"),
("Hedge cost", f"${float(strategy.get('estimated_cost', 0.0)):,.2f}/oz"),
("Unhedged equity", f"${metrics['unhedged_equity']:,.0f}"),
("Hedged equity", f"${metrics['hedged_equity']:,.0f}"),
("Net hedge benefit", f"${metrics['hedged_equity'] - metrics['unhedged_equity']:,.0f}"),
@@ -211,8 +278,10 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
waterfall_chart.update()
def refresh_from_selector(event) -> None:
if syncing_controls["value"]:
return
selected["label"] = str(event.value)
selected["strategy"] = strategy_map[selected["label"]]
selected["strategy"] = strategy_map()[selected["label"]]
render_summary()
def refresh_from_slider(event) -> None:
@@ -221,6 +290,43 @@ async def _render_hedge_page(workspace_id: str | None = None) -> None:
slider_value.set_text(f"Scenario move: {sign}{selected['scenario_pct']}%")
render_summary()
def save_template() -> None:
builder_status.set_text("")
try:
builder_kind = builder_type_options[str(builder_type.value)]
strikes = (float(builder_primary_strike.value or 0.0) / 100.0,)
weights: tuple[float, ...] | None = None
if builder_kind == "laddered_put":
strikes = (
float(builder_primary_strike.value or 0.0) / 100.0,
float(builder_secondary_strike.value or 0.0) / 100.0,
)
weights = (0.5, 0.5)
template = template_service.create_custom_template(
display_name=str(builder_name.value or ""),
template_kind=builder_kind,
target_expiry_days=int(builder_expiry_days.value or 0),
strike_pcts=strikes,
weights=weights,
)
except (ValueError, KeyError) as exc:
builder_status.set_text(str(exc))
return
refresh_available_strategies()
selected["label"] = template.display_name
selected["strategy"] = template.slug
syncing_controls["value"] = True
try:
selector.value = template.display_name
selector.update()
finally:
syncing_controls["value"] = False
builder_status.set_text(
f"Saved template {template.display_name}. Reusable on hedge, backtests, and event comparison."
)
render_summary()
selector.on_value_change(refresh_from_selector)
slider.on_value_change(refresh_from_slider)
save_template_button.on_click(lambda: save_template())
render_summary()