feat(PORT-002): add alert status and history

This commit is contained in:
Bu5hm4nn
2026-03-24 11:04:32 +01:00
parent 7c6b8ef2c6
commit d0b1304b71
9 changed files with 525 additions and 59 deletions

View File

@@ -5,8 +5,9 @@ from datetime import datetime, timezone
from nicegui import ui
from app.components import PortfolioOverview
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
from app.models.portfolio import get_portfolio_repository
from app.pages.common import dashboard_page, quick_recommendations, recommendation_style, strategy_catalog
from app.services.alerts import AlertService, build_portfolio_alert_context
from app.services.runtime import get_data_service
_DEFAULT_CASH_BUFFER = 18_500.0
@@ -22,30 +23,12 @@ def _format_timestamp(value: str | None) -> str:
return timestamp.astimezone(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC")
def _build_live_portfolio(config: PortfolioConfig, quote: dict[str, object]) -> dict[str, float | str]:
fallback_spot_price = float(config.entry_price or 0.0)
spot_price = float(quote.get("price", fallback_spot_price))
configured_gold_value = float(config.gold_value or 0.0)
gold_units = float(config.gold_ounces or 0.0)
live_gold_value = gold_units * spot_price
loan_amount = float(config.loan_amount)
margin_call_ltv = float(config.margin_threshold)
ltv_ratio = loan_amount / live_gold_value if live_gold_value > 0 else 0.0
def _alert_badge_classes(severity: str) -> str:
return {
"spot_price": spot_price,
"gold_units": gold_units,
"gold_value": live_gold_value,
"loan_amount": loan_amount,
"ltv_ratio": ltv_ratio,
"net_equity": live_gold_value - loan_amount,
"margin_call_ltv": margin_call_ltv,
"margin_call_price": loan_amount / (margin_call_ltv * gold_units) if gold_units > 0 else 0.0,
"cash_buffer": max(live_gold_value - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER,
"hedge_budget": float(config.monthly_budget),
"quote_source": str(quote.get("source", "unknown")),
"quote_updated_at": str(quote.get("updated_at", "")),
}
"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("/")
@@ -55,7 +38,16 @@ async def overview_page() -> None:
data_service = get_data_service()
symbol = data_service.default_symbol
quote = await data_service.get_quote(symbol)
portfolio = _build_live_portfolio(config, quote)
portfolio = build_portfolio_alert_context(
config,
spot_price=float(quote.get("price", float(config.entry_price or 0.0))),
source=str(quote.get("source", "unknown")),
updated_at=str(quote.get("updated_at", "")),
)
configured_gold_value = float(config.gold_value or 0.0)
portfolio["cash_buffer"] = max(float(portfolio["gold_value"]) - configured_gold_value, 0.0) + _DEFAULT_CASH_BUFFER
portfolio["hedge_budget"] = float(config.monthly_budget)
alert_status = AlertService().evaluate(config, portfolio)
quote_status = (
f"Live quote source: {portfolio['quote_source']} · "
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
@@ -72,6 +64,23 @@ async def overview_page() -> None:
f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
).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"
):
with ui.row().classes("w-full items-center justify-between gap-3"):
ui.label("Alert Status").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
ui.label(alert_status.severity.upper()).classes(_alert_badge_classes(alert_status.severity))
ui.label(alert_status.message).classes("text-sm text-slate-600 dark:text-slate-300")
ui.label(
f"Warning at {alert_status.warning_threshold:.0%} · Critical at {alert_status.critical_threshold:.0%} · "
f"Email alerts {'enabled' if alert_status.email_alerts_enabled else 'disabled'}"
).classes("text-sm text-slate-500 dark:text-slate-400")
if alert_status.history:
latest = alert_status.history[0]
ui.label(
f"Latest alert logged {_format_timestamp(latest.updated_at)} at spot ${latest.spot_price:,.2f}"
).classes("text-xs text-slate-500 dark:text-slate-400")
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
summary_cards = [
(
@@ -129,17 +138,37 @@ async def overview_page() -> None:
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("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
for strategy in strategy_catalog():
with ui.row().classes(
"w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"
):
with ui.column().classes("gap-1"):
ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100")
ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes(
"rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
)
ui.label("Recent Alert History").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
if alert_status.history:
for event in alert_status.history[:5]:
with ui.row().classes(
"w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-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"Logged {_format_timestamp(event.updated_at)} · 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:
ui.label("No alert history yet. Alerts will be logged once the warning threshold is crossed.").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("Strategy Snapshot").classes("text-lg font-semibold text-slate-900 dark:text-slate-100")
for strategy in strategy_catalog():
with ui.row().classes(
"w-full items-start justify-between gap-4 border-b border-slate-100 py-3 last:border-b-0 dark:border-slate-800"
):
with ui.column().classes("gap-1"):
ui.label(strategy["label"]).classes("font-semibold text-slate-900 dark:text-slate-100")
ui.label(strategy["description"]).classes("text-sm text-slate-500 dark:text-slate-400")
ui.label(f"${strategy['estimated_cost']:.2f}/oz").classes(
"rounded-full bg-sky-100 px-3 py-1 text-xs font-semibold text-sky-700 dark:bg-sky-500/15 dark:text-sky-300"
)
ui.label("Quick Strategy Recommendations").classes("text-xl font-semibold text-slate-900 dark:text-slate-100")
with ui.grid(columns=3).classes("w-full gap-4 max-lg:grid-cols-1"):

View File

@@ -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")