Files
vault-dash/app/pages/settings.py
2026-03-24 11:04:32 +01:00

345 lines
16 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 nicegui import ui
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
from app.pages.common import dashboard_page
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")
@ui.page("/settings")
def settings_page() -> None:
"""Settings page with persistent portfolio configuration."""
repo = get_portfolio_repository()
config = repo.load()
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",
):
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)
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 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.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("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 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 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()
repo.save(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")
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")