Files
vault-dash/app/pages/settings.py
2026-03-28 23:48:41 +01:00

713 lines
33 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
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")
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
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",
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")