from __future__ import annotations import logging from fastapi.responses import RedirectResponse from nicegui import ui from app.domain.portfolio_math import resolve_portfolio_spot_from_quote from app.models.workspace import get_workspace_repository from app.pages.common import ( dashboard_page, demo_spot_price, portfolio_snapshot, split_page_panes, strategy_catalog, strategy_metrics, ) from app.services.runtime import get_data_service logger = logging.getLogger(__name__) def _cost_benefit_options(metrics: dict) -> dict: return { "tooltip": {"trigger": "axis"}, "grid": {"left": 64, "right": 24, "top": 24, "bottom": 48}, "xAxis": { "type": "category", "data": [f"${point['price']:.0f}" for point in metrics["scenario_series"]], "name": "Collateral spot", }, "yAxis": {"type": "value", "name": "Net hedge benefit / oz"}, "series": [ { "type": "bar", "data": [point["benefit"] for point in metrics["scenario_series"]], "itemStyle": { "color": "#0ea5e9", }, "markLine": { "symbol": "none", "lineStyle": {"color": "#94a3b8", "type": "dashed"}, "data": [{"yAxis": 0}], }, } ], } 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"}}, "grid": {"left": 80, "right": 24, "top": 24, "bottom": 48}, "xAxis": {"type": "category", "data": [label for label, _ in steps]}, "yAxis": {"type": "value", "name": "USD"}, "series": [ { "type": "bar", "data": values, "label": {"show": True, "position": "top", "formatter": "{c}"}, }, ], } @ui.page("/{workspace_id}/hedge") async def workspace_hedge_page(workspace_id: str) -> None: repo = get_workspace_repository() if not repo.workspace_exists(workspace_id): return RedirectResponse(url="/", status_code=307) await _render_hedge_page(workspace_id=workspace_id) async def _resolve_hedge_spot(workspace_id: str | None = None) -> tuple[dict[str, float], str, str]: """Resolve hedge page spot price using the same quote-unit seam as overview.""" repo = get_workspace_repository() config = repo.load_portfolio_config(workspace_id) if workspace_id else None if config is None: return {"spot_price": demo_spot_price()}, "demo", "" try: data_service = get_data_service() quote = await data_service.get_quote(data_service.default_symbol) spot, source, updated_at = resolve_portfolio_spot_from_quote( config, quote, fallback_symbol=data_service.default_symbol ) portfolio = portfolio_snapshot(config, runtime_spot_price=spot) return portfolio, source, updated_at except Exception as exc: logger.warning("Falling back to configured hedge spot for workspace %s: %s", workspace_id, exc) portfolio = portfolio_snapshot(config) return portfolio, "configured_entry_price", "" 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} if quote_source == "configured_entry_price": spot_label = f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (configured entry price)" else: spot_label = ( f"Current spot reference: ${portfolio['spot_price']:,.2f}/oz (converted collateral spot via {quote_source})" ) updated_label = f"Quote timestamp: {quote_updated_at}" if quote_updated_at else "Quote timestamp: unavailable" with dashboard_page( "Hedge Analysis", "Compare hedge structures across scenarios, visualize cost-benefit tradeoffs, and inspect net equity impacts.", "hedge", workspace_id=workspace_id, ): left_pane, right_pane = split_page_panes( left_testid="hedge-left-pane", right_testid="hedge-right-pane", ) with left_pane: 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(spot_label).classes("text-sm text-slate-500 dark:text-slate-400") ui.label(updated_label).classes("text-xs 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" ) with right_pane: scenario_results = 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 gap-6 max-xl:flex-col"): 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") 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 = [ ("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}"), ] 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") scenario_results.clear() with scenario_results: ui.label("Scenario Results").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") 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"), ("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}"), ("Scenario move", f"{selected['scenario_pct']:+d}%"), ] for label, value in result_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") 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()