Files
vault-dash/app/pages/settings.py
2026-03-26 13:19:18 +01:00

379 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
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:
try:
parsed = float(value)
except (TypeError, ValueError):
return 0.0
return max(parsed, 0.0)
def build_preview_config() -> PortfolioConfig:
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=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),
)
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.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("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(
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=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)
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))
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:
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("")
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, ltv_warning, email_alerts):
element.on_value_change(update_calculations)
apply_entry_basis_mode()
update_entry_basis()
def save_settings() -> None:
try:
new_config = build_preview_config()
workspace_repo.save_portfolio_config(workspace_id, new_config)
render_alert_state()
status.set_text(save_status_text(new_config))
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")