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.domain.conversions import get_display_mode_options 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 from app.services.storage_costs import get_default_storage_cost_for_underlying 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") display_mode = ui.select( get_display_mode_options(), value=config.display_mode, label="Display Mode", ).classes("w-full") ui.label("Choose how to display positions and collateral values.").classes( "text-xs text-slate-500 dark:text-slate-400 -mt-2" ) 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") def update_storage_cost_default() -> None: """Update storage cost defaults based on underlying selection.""" underlying = str(pos_underlying.value) default_basis, default_period = get_default_storage_cost_for_underlying(underlying) if default_basis is not None: pos_storage_cost_basis.value = float(default_basis) pos_storage_cost_period.value = default_period or "annual" else: pos_storage_cost_basis.value = 0.0 pos_storage_cost_period.value = "annual" pos_underlying.on_value_change(lambda _: update_storage_cost_default()) 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") ui.separator().classes("my-3") ui.label("Storage Costs (optional)").classes("text-sm font-semibold text-slate-700 dark:text-slate-300") ui.label("For physical gold (XAU), defaults to 0.12% annual vault storage.").classes("text-xs text-slate-500 dark:text-slate-400 mb-2") pos_storage_cost_basis = ui.number( "Storage cost (% per year or fixed $)", value=0.0, min=0.0, step=0.01, ).classes("w-full") pos_storage_cost_period = ui.select( {"annual": "Annual", "monthly": "Monthly"}, value="annual", label="Cost period", ).classes("w-full") ui.separator().classes("my-3") ui.label("Premium & Spread (optional)").classes("text-sm font-semibold text-slate-700 dark:text-slate-300") ui.label("For physical gold, accounts for dealer markup and bid/ask spread.").classes("text-xs text-slate-500 dark:text-slate-400 mb-2") pos_purchase_premium = ui.number( "Purchase premium over spot (%)", value=0.0, min=0.0, max=100.0, step=0.1, ).classes("w-full") pos_bid_ask_spread = ui.number( "Bid/ask spread on exit (%)", value=0.0, min=0.0, max=100.0, step=0.1, ).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: underlying = str(pos_underlying.value) storage_cost_basis_val = float(pos_storage_cost_basis.value) storage_cost_basis = Decimal(str(storage_cost_basis_val)) if storage_cost_basis_val > 0 else None storage_cost_period = str(pos_storage_cost_period.value) if storage_cost_basis else None purchase_premium_val = float(pos_purchase_premium.value) purchase_premium = Decimal(str(purchase_premium_val / 100)) if purchase_premium_val > 0 else None bid_ask_spread_val = float(pos_bid_ask_spread.value) bid_ask_spread = Decimal(str(bid_ask_spread_val / 100)) if bid_ask_spread_val > 0 else None new_position = Position( id=uuid4(), underlying=underlying, 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", purchase_premium=purchase_premium, bid_ask_spread=bid_ask_spread, notes=str(pos_notes.value or ""), storage_cost_basis=storage_cost_basis, storage_cost_period=storage_cost_period, ) 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" ) # Show storage cost if configured if pos.storage_cost_basis is not None: basis_val = float(pos.storage_cost_basis) period = pos.storage_cost_period or "annual" if basis_val < 1: # Percentage storage_label = f"{basis_val:.2f}% {period} storage" else: # Fixed amount storage_label = f"${basis_val:,.2f} {period} storage" ui.label(f"Storage: {storage_label}").classes( "text-xs text-slate-500 dark:text-slate-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, display_mode, ): 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")