634 lines
29 KiB
Python
634 lines
29 KiB
Python
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.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
|
||
|
||
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),
|
||
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("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")
|
||
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")
|
||
|
||
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")
|
||
|
||
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:
|
||
new_position = Position(
|
||
id=uuid4(),
|
||
underlying=str(pos_underlying.value),
|
||
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 ""),
|
||
)
|
||
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"
|
||
)
|
||
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,
|
||
):
|
||
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")
|