from __future__ import annotations from nicegui import ui from app.models.portfolio import PortfolioConfig, get_portfolio_repository from app.pages.common import dashboard_page @ui.page("/settings") def settings_page() -> None: """Settings page with persistent portfolio configuration.""" repo = get_portfolio_repository() config = repo.load() 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: try: parsed = float(value) except (TypeError, ValueError): return 0.0 return max(parsed, 0.0) with dashboard_page( "Settings", "Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.", "settings", ): 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("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.number( "Loan amount ($)", value=config.loan_amount, 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("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.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("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) 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) ui.button("Import settings", icon="upload").props("outline color=primary") ui.button("Export settings", icon="download").props("outline 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 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: ltv = (loan / collateral_value) * 100 buffer = ((margin or 0.0) - loan / collateral_value) * 100 if margin is not None else 0.0 ltv_display.set_text(f"{ltv:.1f}%") buffer_display.set_text(f"{buffer:.1f}%") else: ltv_display.set_text("—") buffer_display.set_text("—") if margin is not None and ounces is not None and ounces > 0: margin_price_display.set_text(f"${loan / (margin * ounces):,.2f}/oz") elif 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("—") 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): element.on_value_change(update_calculations) apply_entry_basis_mode() update_entry_basis() def save_settings() -> None: try: new_config = 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=as_non_negative_float(loan_amount.value), 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=int(refresh_interval.value), volatility_spike=float(vol_alert.value), spot_drawdown=float(price_alert.value), email_alerts=bool(email_alerts.value), ) repo.save(new_config) status.set_text( f"Saved: basis={new_config.entry_basis_mode}, start=${new_config.gold_value:,.0f}, " f"entry=${new_config.entry_price:,.2f}/oz, weight={new_config.gold_ounces:,.2f} oz, " f"LTV={new_config.current_ltv:.1%}, trigger=${new_config.margin_call_price:,.2f}/oz" ) ui.notify("Settings saved successfully", color="positive") except ValueError as e: ui.notify(f"Validation error: {e}", color="negative") except Exception as e: ui.notify(f"Failed to save: {e}", color="negative") with ui.row().classes("mt-6 w-full items-center justify-between gap-4"): status = ui.label( f"Current: start=${config.gold_value:,.0f}, entry=${config.entry_price:,.2f}/oz, " f"weight={config.gold_ounces:,.2f} oz, current LTV={config.current_ltv:.1%}" ).classes("text-sm text-slate-500 dark:text-slate-400") ui.button("Save settings", on_click=save_settings).props("color=primary")