feat(PORT-002): add alert status and history
This commit is contained in:
@@ -4,6 +4,16 @@ 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")
|
||||
@@ -11,6 +21,7 @@ def settings_page() -> None:
|
||||
"""Settings page with persistent portfolio configuration."""
|
||||
repo = get_portfolio_repository()
|
||||
config = repo.load()
|
||||
alert_service = AlertService()
|
||||
|
||||
syncing_entry_basis = False
|
||||
|
||||
@@ -28,6 +39,24 @@ def settings_page() -> None:
|
||||
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.",
|
||||
@@ -151,7 +180,21 @@ def settings_page() -> None:
|
||||
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"
|
||||
):
|
||||
@@ -173,6 +216,52 @@ def settings_page() -> None:
|
||||
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()
|
||||
@@ -224,9 +313,11 @@ def settings_page() -> None:
|
||||
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):
|
||||
for element in (loan_amount, margin_threshold, ltv_warning, email_alerts):
|
||||
element.on_value_change(update_calculations)
|
||||
|
||||
apply_entry_basis_mode()
|
||||
@@ -234,30 +325,11 @@ def settings_page() -> None:
|
||||
|
||||
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),
|
||||
)
|
||||
|
||||
new_config = build_preview_config()
|
||||
repo.save(new_config)
|
||||
render_alert_state()
|
||||
|
||||
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"
|
||||
)
|
||||
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")
|
||||
|
||||
Reference in New Issue
Block a user