from __future__ import annotations from fastapi.responses import RedirectResponse from nicegui import ui from app.models.portfolio import PortfolioConfig from app.models.workspace import get_workspace_repository from app.pages.common import dashboard_page, split_page_panes from app.services.alerts import AlertService, build_portfolio_alert_context from app.services.settings_status import save_status_text def _alert_badge_classes(severity: str) -> str: return { "critical": "rounded-full bg-rose-100 px-3 py-1 text-xs font-semibold text-rose-700 dark:bg-rose-500/15 dark:text-rose-300", "warning": "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", "ok": "rounded-full bg-emerald-100 px-3 py-1 text-xs font-semibold text-emerald-700 dark:bg-emerald-500/15 dark:text-emerald-300", }.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700") def _render_workspace_recovery() -> None: with ui.column().classes("mx-auto mt-24 w-full max-w-2xl gap-6 px-6 text-center"): ui.icon("folder_off").classes("mx-auto text-6xl text-slate-400") ui.label("Workspace not found").classes("text-3xl font-bold text-slate-900 dark:text-slate-50") ui.label( "The requested workspace is unavailable. Start a new workspace or return to the welcome page." ).classes("text-base text-slate-500 dark:text-slate-400") with ui.row().classes("mx-auto gap-3"): ui.link("Get started", "/").classes( "rounded-lg bg-slate-900 px-5 py-3 text-sm font-semibold text-white no-underline dark:bg-slate-100 dark:text-slate-900" ) ui.link("Go to welcome page", "/").classes( "rounded-lg border border-slate-300 px-5 py-3 text-sm font-semibold text-slate-700 no-underline dark:border-slate-700 dark:text-slate-200" ) @ui.page("/{workspace_id}/settings") def settings_page(workspace_id: str) -> None: """Settings page with workspace-scoped persistent portfolio configuration.""" workspace_repo = get_workspace_repository() if not workspace_repo.workspace_exists(workspace_id): return RedirectResponse(url="/", status_code=307) config = workspace_repo.load_portfolio_config(workspace_id) last_saved_config = config alert_service = AlertService() syncing_entry_basis = False def as_positive_float(value: object) -> float | None: try: parsed = float(value) except (TypeError, ValueError): return None return parsed if parsed > 0 else None def as_non_negative_float(value: object) -> float | None: try: parsed = float(value) except (TypeError, ValueError): return None return parsed if parsed >= 0 else None def display_number_input_value(value: object) -> str: try: parsed = float(value) except (TypeError, ValueError): return "" if parsed.is_integer(): return str(int(parsed)) return str(parsed) def as_positive_int(value: object) -> int | None: try: parsed = float(value) except (TypeError, ValueError): return None if parsed < 1 or not parsed.is_integer(): return None return int(parsed) def last_saved_status_text(config: PortfolioConfig) -> str: return save_status_text(config).replace("Saved:", "Last saved:", 1) def build_preview_config() -> PortfolioConfig: parsed_loan_amount = as_non_negative_float(loan_amount.value) if parsed_loan_amount is None: raise ValueError("Loan amount must be zero or greater") parsed_refresh_interval = as_positive_int(refresh_interval.value) if parsed_refresh_interval is None: raise ValueError("Refresh interval must be a whole number of seconds") return PortfolioConfig( gold_value=as_positive_float(gold_value.value), entry_price=as_positive_float(entry_price.value), gold_ounces=as_positive_float(gold_ounces.value), entry_basis_mode=str(entry_basis_mode.value), loan_amount=parsed_loan_amount, margin_threshold=float(margin_threshold.value), monthly_budget=float(monthly_budget.value), ltv_warning=float(ltv_warning.value), primary_source=str(primary_source.value), fallback_source=str(fallback_source.value), refresh_interval=parsed_refresh_interval, volatility_spike=float(vol_alert.value), spot_drawdown=float(price_alert.value), email_alerts=bool(email_alerts.value), ) with dashboard_page( "Settings", "Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.", "settings", workspace_id=workspace_id, ): left_pane, right_pane = split_page_panes( left_testid="settings-left-pane", right_testid="settings-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("Portfolio Parameters").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label( "Choose whether collateral entry is keyed by start value or by gold weight. The paired field is derived automatically from the entry price." ).classes("text-sm text-slate-500 dark:text-slate-400") entry_basis_mode = ui.select( {"value_price": "Start value + entry price", "weight": "Gold weight + entry price"}, value=config.entry_basis_mode, label="Collateral entry basis", ).classes("w-full") entry_price = ui.number( "Entry price ($/oz)", value=config.entry_price, min=0.01, step=0.01, ).classes("w-full") gold_value = ui.number( "Collateral start value ($)", value=config.gold_value, min=0.01, step=1000, ).classes("w-full") gold_ounces = ui.number( "Gold weight (oz)", value=config.gold_ounces, min=0.0001, step=0.01, ).classes("w-full") loan_amount = ( ui.input( "Loan amount ($)", value=display_number_input_value(config.loan_amount), ) .props("type=number min=0 step=1000") .classes("w-full") ) margin_threshold = ui.number( "Margin call LTV threshold", value=config.margin_threshold, min=0.1, max=0.95, step=0.01, ).classes("w-full") monthly_budget = ui.number( "Monthly hedge budget ($)", value=config.monthly_budget, min=0, step=500, ).classes("w-full") derived_hint = ui.label().classes("text-sm text-slate-500 dark:text-slate-400") with ui.row().classes("w-full gap-2 mt-4 rounded-lg bg-slate-50 p-4 dark:bg-slate-800"): ui.label("Current LTV:").classes("font-medium") ltv_display = ui.label() ui.label("Margin buffer:").classes("ml-4 font-medium") buffer_display = ui.label() ui.label("Margin call at:").classes("ml-4 font-medium") margin_price_display = ui.label() 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("Alert Thresholds").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ltv_warning = ui.number( "LTV warning level", value=config.ltv_warning, min=0.1, max=0.95, step=0.01, ).classes("w-full") vol_alert = ui.number( "Volatility spike alert", value=config.volatility_spike, min=0.01, max=2.0, step=0.01, ).classes("w-full") price_alert = ui.number( "Spot drawdown alert (%)", value=config.spot_drawdown, min=0.1, max=50.0, step=0.5, ).classes("w-full") email_alerts = ui.switch("Email alerts", value=config.email_alerts) ui.label("Defaults remain warn at 70% and critical at 75% unless you override them.").classes( "text-sm text-slate-500 dark:text-slate-400" ) with right_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("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") primary_source = ui.select( ["yfinance", "ibkr", "alpaca"], value=config.primary_source, label="Primary source", ).classes("w-full") fallback_source = ui.select( ["fallback", "yfinance", "manual"], value=config.fallback_source, label="Fallback source", ).classes("w-full") refresh_interval = ui.number( "Refresh interval (seconds)", value=config.refresh_interval, min=1, step=1, ).classes("w-full") 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("Current Alert State").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") with ui.row().classes("w-full items-center justify-between gap-3"): alert_state_container = ui.row().classes("items-center") email_state_label = ui.label().classes("text-xs text-slate-500 dark:text-slate-400") alert_message = ui.label().classes("text-sm text-slate-600 dark:text-slate-300") alert_history_column = ui.column().classes("w-full gap-2") 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("Export / Import").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.select(["json", "csv", "yaml"], value="json", label="Export format").classes("w-full") ui.switch("Include scenario history", value=True) ui.switch("Include option selections", value=True) with ui.row().classes("w-full gap-3 max-sm:flex-col"): ui.button("Import settings", icon="upload").props("outline color=primary") ui.button("Export settings", icon="download").props("outline color=primary") 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("Save Workspace Settings").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") status = ui.label(last_saved_status_text(last_saved_config)).classes( "text-sm text-slate-500 dark:text-slate-400" ) ui.button("Save settings", on_click=lambda: save_settings()).props("color=primary") def apply_entry_basis_mode() -> None: mode = str(entry_basis_mode.value or "value_price") if mode == "weight": gold_value.props("readonly") gold_ounces.props(remove="readonly") derived_hint.set_text( "Gold weight is the editable basis; start value is derived from weight × entry price." ) else: gold_ounces.props("readonly") gold_value.props(remove="readonly") derived_hint.set_text( "Start value is the editable basis; gold weight is derived from start value ÷ entry price." ) def render_alert_state() -> None: try: preview_config = build_preview_config() alert_status = alert_service.evaluate( preview_config, build_portfolio_alert_context( preview_config, spot_price=float(preview_config.entry_price or 0.0), source="settings-preview", updated_at="", ), persist=False, ) alert_state_container.clear() with alert_state_container: ui.label(alert_status.severity.upper()).classes(_alert_badge_classes(alert_status.severity)) email_state_label.set_text( f"Email alerts {'enabled' if alert_status.email_alerts_enabled else 'disabled'} · Warning {alert_status.warning_threshold:.0%} · Critical {alert_status.critical_threshold:.0%}" ) alert_message.set_text(alert_status.message) if preview_config.to_dict() == last_saved_config.to_dict(): status.set_text(last_saved_status_text(last_saved_config)) else: status.set_text(f"Unsaved changes — {last_saved_status_text(last_saved_config)}") alert_history_column.clear() if alert_status.history: for event in alert_status.history[:5]: with alert_history_column: with ui.row().classes( "w-full items-start justify-between gap-3 rounded-lg bg-slate-50 p-3 dark:bg-slate-800" ): with ui.column().classes("gap-1"): ui.label(event.message).classes( "text-sm font-medium text-slate-900 dark:text-slate-100" ) ui.label(f"Spot ${event.spot_price:,.2f} · LTV {event.ltv_ratio:.1%}").classes( "text-xs text-slate-500 dark:text-slate-400" ) ui.label(event.severity.upper()).classes(_alert_badge_classes(event.severity)) else: with alert_history_column: ui.label("No alert history yet.").classes("text-sm text-slate-500 dark:text-slate-400") except (ValueError, TypeError) as exc: alert_state_container.clear() with alert_state_container: ui.label("INVALID").classes(_alert_badge_classes("critical")) email_state_label.set_text("Fix validation errors to preview alert state") alert_message.set_text(str(exc)) status.set_text(f"Unsaved invalid changes — {last_saved_status_text(last_saved_config)}") alert_history_column.clear() def update_entry_basis(*_args: object) -> None: nonlocal syncing_entry_basis apply_entry_basis_mode() if syncing_entry_basis: return price = as_positive_float(entry_price.value) if price is None: update_calculations() return syncing_entry_basis = True try: mode = str(entry_basis_mode.value or "value_price") if mode == "weight": ounces = as_positive_float(gold_ounces.value) if ounces is not None: gold_value.value = round(ounces * price, 2) else: start_value = as_positive_float(gold_value.value) if start_value is not None: gold_ounces.value = round(start_value / price, 6) finally: syncing_entry_basis = False update_calculations() def update_calculations(*_args: object) -> None: price = as_positive_float(entry_price.value) collateral_value = as_positive_float(gold_value.value) ounces = as_positive_float(gold_ounces.value) loan = as_non_negative_float(loan_amount.value) margin = as_positive_float(margin_threshold.value) if collateral_value is not None and collateral_value > 0 and loan is not None: ltv = (loan / collateral_value) * 100 ltv_display.set_text(f"{ltv:.1f}%") if margin is not None: buffer = (margin - loan / collateral_value) * 100 buffer_display.set_text(f"{buffer:.1f}%") else: buffer_display.set_text("—") else: ltv_display.set_text("—") buffer_display.set_text("—") if loan is not None and margin is not None and ounces is not None and ounces > 0: margin_price_display.set_text(f"${loan / (margin * ounces):,.2f}/oz") elif ( loan is not None and margin is not None and price is not None and collateral_value is not None and collateral_value > 0 ): implied_ounces = collateral_value / price margin_price_display.set_text(f"${loan / (margin * implied_ounces):,.2f}/oz") else: margin_price_display.set_text("—") render_alert_state() for element in (entry_basis_mode, entry_price, gold_value, gold_ounces): element.on_value_change(update_entry_basis) for element in ( loan_amount, margin_threshold, monthly_budget, ltv_warning, vol_alert, price_alert, email_alerts, primary_source, fallback_source, refresh_interval, ): element.on_value_change(update_calculations) apply_entry_basis_mode() update_entry_basis() def save_settings() -> None: nonlocal last_saved_config try: new_config = build_preview_config() workspace_repo.save_portfolio_config(workspace_id, new_config) last_saved_config = new_config render_alert_state() status.set_text(last_saved_status_text(last_saved_config)) ui.notify("Settings saved successfully", color="positive") except ValueError as e: status.set_text(f"Unsaved invalid changes — {last_saved_status_text(last_saved_config)}") ui.notify(f"Validation error: {e}", color="negative") except Exception as e: status.set_text(f"Save failed — {last_saved_status_text(last_saved_config)}") ui.notify(f"Failed to save: {e}", color="negative")