from __future__ import annotations import logging from datetime import date from decimal import Decimal from uuid import uuid4 from fastapi.responses import RedirectResponse from nicegui import ui from app.models.portfolio import PortfolioConfig from app.models.position import Position 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 logger = logging.getLogger(__name__) 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 _save_card_status_text( last_saved_config: PortfolioConfig, *, preview_config: PortfolioConfig | None = None, invalid: bool = False, save_failed: bool = False, ) -> str: base = save_status_text(last_saved_config).replace("Saved:", "Last saved:", 1) if save_failed: return f"Save failed — {base}" if invalid: return f"Unsaved invalid changes — {base}" if preview_config is not None and preview_config.to_dict() != last_saved_config.to_dict(): return f"Unsaved changes — {base}" return base 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 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, underlying=str(underlying.value), display_mode=str(display_mode.value), 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("Display Mode").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label( "Choose how to view your portfolio: GLD shares (financial instrument view) or physical gold ounces." ).classes("text-sm text-slate-500 dark:text-slate-400 mb-3") display_mode = ui.select( { "GLD": "GLD Shares (show share prices directly)", "XAU": "Physical Gold (oz) (convert to gold ounces)", }, value=config.display_mode, label="Display mode", ).classes("w-full") ui.separator().classes("my-4") ui.label("Data Sources").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") underlying = ui.select( { "GLD": "SPDR Gold Shares ETF (live data via yfinance)", "GC=F": "Gold Futures (coming soon)", }, value=config.underlying, label="Underlying instrument", ).classes("w-full") 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") # Position Management Card 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 Positions").classes("text-lg font-semibold text-slate-900 dark:text-slate-100") ui.label( "Manage individual position entries. Each position tracks its own entry date and price." ).classes("text-sm text-slate-500 dark:text-slate-400") # Position list container position_list_container = ui.column().classes("w-full gap-2 mt-3") # Add position form (hidden by default) with ( ui.dialog() as add_position_dialog, ui.card().classes( "w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-lg dark:border-slate-800 dark:bg-slate-900" ), ): ui.label("Add New Position").classes( "text-lg font-semibold text-slate-900 dark:text-slate-100 mb-4" ) pos_underlying = ui.select( { "GLD": "SPDR Gold Shares ETF", "XAU": "Physical Gold (oz)", "GC=F": "Gold Futures", }, value="GLD", label="Underlying", ).classes("w-full") pos_quantity = ui.number( "Quantity", value=100.0, min=0.0001, step=0.01, ).classes("w-full") pos_unit = ui.select( {"oz": "Troy Ounces", "shares": "Shares", "g": "Grams", "contracts": "Contracts"}, value="oz", label="Unit", ).classes("w-full") pos_entry_price = ui.number( "Entry Price ($/unit)", value=2150.0, min=0.01, step=0.01, ).classes("w-full") with ui.row().classes("w-full items-center gap-2"): ui.label("Entry Date").classes("text-sm font-medium") pos_entry_date = ( ui.date( value=date.today().isoformat(), ) .classes("w-full") .props("stack-label") ) pos_notes = ui.textarea( label="Notes (optional)", placeholder="Add notes about this position...", ).classes("w-full") with ui.row().classes("w-full gap-3 mt-4"): ui.button("Cancel", on_click=lambda: add_position_dialog.close()).props("outline") ui.button("Add Position", on_click=lambda: add_position_from_form()).props("color=primary") def add_position_from_form() -> None: """Add a new position from the form.""" try: new_position = Position( id=uuid4(), underlying=str(pos_underlying.value), quantity=Decimal(str(pos_quantity.value)), unit=str(pos_unit.value), entry_price=Decimal(str(pos_entry_price.value)), entry_date=date.fromisoformat(str(pos_entry_date.value)), entry_basis_mode="weight", notes=str(pos_notes.value or ""), ) workspace_repo.add_position(workspace_id, new_position) add_position_dialog.close() render_positions() ui.notify("Position added successfully", color="positive") except Exception as e: logger.exception("Failed to add position") ui.notify(f"Failed to add position: {e}", color="negative") def render_positions() -> None: """Render the list of positions.""" position_list_container.clear() positions = workspace_repo.list_positions(workspace_id) if not positions: with position_list_container: ui.label("No positions yet. Click 'Add Position' to create one.").classes( "text-sm text-slate-500 dark:text-slate-400 italic" ) return for pos in positions: with ui.card().classes( "w-full rounded-lg border border-slate-200 bg-slate-50 p-3 dark:border-slate-700 dark:bg-slate-800" ): with ui.row().classes("w-full items-start justify-between gap-3"): with ui.column().classes("gap-1"): ui.label(f"{pos.underlying} · {float(pos.quantity):,.4f} {pos.unit}").classes( "text-sm font-medium text-slate-900 dark:text-slate-100" ) ui.label( f"Entry: ${float(pos.entry_price):,.2f}/{pos.unit} · Date: {pos.entry_date}" ).classes("text-xs text-slate-500 dark:text-slate-400") if pos.notes: ui.label(pos.notes).classes("text-xs text-slate-500 dark:text-slate-400 italic") ui.label(f"Value: ${float(pos.entry_value):,.2f}").classes( "text-xs font-semibold text-emerald-600 dark:text-emerald-400" ) with ui.row().classes("gap-1"): ui.button( icon="delete", on_click=lambda p=pos: remove_position(p.id), ).props( "flat dense color=negative size=sm" ).classes("self-start") def remove_position(position_id) -> None: """Remove a position.""" try: workspace_repo.remove_position(workspace_id, position_id) render_positions() ui.notify("Position removed", color="positive") except Exception as e: logger.exception("Failed to remove position") ui.notify(f"Failed to remove position: {e}", color="negative") with ui.row().classes("w-full mt-3"): ui.button("Add Position", icon="add", on_click=lambda: add_position_dialog.open()).props( "color=primary" ) # Initial render render_positions() 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(_save_card_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() 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(_save_card_status_text(last_saved_config, invalid=True)) alert_history_column.clear() return try: 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, ) except Exception: logger.exception("Settings alert preview failed for workspace %s", workspace_id) alert_state_container.clear() with alert_state_container: ui.label("UNAVAILABLE").classes(_alert_badge_classes("critical")) email_state_label.set_text("Preview unavailable due to an internal error") alert_message.set_text( "Preview unavailable due to an internal error. Last saved settings remain unchanged." ) status.set_text(_save_card_status_text(last_saved_config, preview_config=preview_config)) alert_history_column.clear() return 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) status.set_text(_save_card_status_text(last_saved_config, preview_config=preview_config)) alert_history_column.clear() if alert_status.history_notice: with alert_history_column: ui.label(alert_status.history_notice).classes("text-sm text-amber-700 dark:text-amber-300") elif 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") 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(_save_card_status_text(last_saved_config)) ui.notify("Settings saved successfully", color="positive") except ValueError as e: status.set_text(_save_card_status_text(last_saved_config, invalid=True)) ui.notify(f"Validation error: {e}", color="negative") except Exception: logger.exception("Failed to save settings for workspace %s", workspace_id) status.set_text(_save_card_status_text(last_saved_config, save_failed=True)) ui.notify("Failed to save settings. Check logs for details.", color="negative")