175 lines
7.7 KiB
Python
175 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import Request
|
|
from fastapi.responses import RedirectResponse
|
|
from nicegui import ui
|
|
|
|
from app.models.workspace import WORKSPACE_COOKIE, get_workspace_repository
|
|
from app.pages.common import (
|
|
dashboard_page,
|
|
demo_spot_price,
|
|
portfolio_snapshot,
|
|
render_workspace_recovery,
|
|
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"]
|
|
values: list[dict[str, object]] = []
|
|
for label, amount in steps:
|
|
color = "#0ea5e9" if label == "Net equity" else ("#22c55e" if amount >= 0 else "#ef4444")
|
|
values.append({"value": amount, "itemStyle": {"color": color}})
|
|
|
|
return {
|
|
"tooltip": {"trigger": "axis", "axisPointer": {"type": "shadow"}},
|
|
"xAxis": {"type": "category", "data": [label for label, _ in steps]},
|
|
"yAxis": {"type": "value", "name": "USD"},
|
|
"series": [
|
|
{
|
|
"type": "bar",
|
|
"data": values,
|
|
},
|
|
],
|
|
}
|
|
|
|
|
|
@ui.page("/hedge")
|
|
def legacy_hedge_page(request: Request):
|
|
repo = get_workspace_repository()
|
|
workspace_id = request.cookies.get(WORKSPACE_COOKIE, "")
|
|
if workspace_id and repo.workspace_exists(workspace_id):
|
|
return RedirectResponse(url=f"/{workspace_id}/hedge", status_code=307)
|
|
_render_hedge_page()
|
|
|
|
|
|
@ui.page("/{workspace_id}/hedge")
|
|
def workspace_hedge_page(workspace_id: str) -> None:
|
|
repo = get_workspace_repository()
|
|
if not repo.workspace_exists(workspace_id):
|
|
render_workspace_recovery()
|
|
return
|
|
_render_hedge_page(workspace_id=workspace_id)
|
|
|
|
|
|
|
|
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 = portfolio_snapshot(config)
|
|
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}
|
|
|
|
with dashboard_page(
|
|
"Hedge Analysis",
|
|
"Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.",
|
|
"hedge",
|
|
workspace_id=workspace_id,
|
|
):
|
|
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["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(f"Current spot reference: ${portfolio['spot_price']:,.2f}").classes(
|
|
"text-sm text-slate-500 dark:text-slate-400"
|
|
)
|
|
if workspace_id:
|
|
ui.label(f"Workspace route: /{workspace_id}/hedge").classes(
|
|
"text-xs text-slate-500 dark:text-slate-400"
|
|
)
|
|
else:
|
|
ui.label(f"Demo spot reference: ${demo_spot_price():,.2f}").classes(
|
|
"text-xs 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:
|
|
initial_metrics = strategy_metrics(selected["strategy"], selected["scenario_pct"], portfolio=portfolio)
|
|
cost_chart = ui.echart(_cost_benefit_options(initial_metrics)).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(initial_metrics)).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"], 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")
|
|
with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
|
|
cards = [
|
|
("Start value", f"${portfolio['gold_value']:,.0f}"),
|
|
("Start price", f"${portfolio['spot_price']:,.2f}/oz"),
|
|
("Weight", f"{portfolio['gold_units']:,.0f} oz"),
|
|
("Loan amount", f"${portfolio['loan_amount']:,.0f}"),
|
|
("Margin call LTV", f"{portfolio['margin_call_ltv']:.1%}"),
|
|
("Monthly hedge budget", f"${portfolio['hedge_budget']:,.0f}"),
|
|
("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.clear()
|
|
cost_chart.options.update(_cost_benefit_options(metrics))
|
|
cost_chart.update()
|
|
waterfall_chart.options.clear()
|
|
waterfall_chart.options.update(_waterfall_options(metrics))
|
|
waterfall_chart.update()
|
|
|
|
def refresh_from_selector(event) -> None:
|
|
selected["label"] = str(event.value)
|
|
selected["strategy"] = strategy_map[selected["label"]]
|
|
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()
|