feat(PORT-002): add alert status and history
This commit is contained in:
64
app/models/alerts.py
Normal file
64
app/models/alerts.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Alert notification domain models."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlertEvent:
|
||||||
|
severity: str
|
||||||
|
message: str
|
||||||
|
ltv_ratio: float
|
||||||
|
warning_threshold: float
|
||||||
|
critical_threshold: float
|
||||||
|
spot_price: float
|
||||||
|
updated_at: str
|
||||||
|
email_alerts_enabled: bool
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return asdict(self)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict[str, Any]) -> "AlertEvent":
|
||||||
|
return cls(**{k: v for k, v in data.items() if k in cls.__dataclass_fields__})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AlertStatus:
|
||||||
|
severity: str
|
||||||
|
message: str
|
||||||
|
ltv_ratio: float
|
||||||
|
warning_threshold: float
|
||||||
|
critical_threshold: float
|
||||||
|
email_alerts_enabled: bool
|
||||||
|
history: list[AlertEvent]
|
||||||
|
|
||||||
|
|
||||||
|
class AlertHistoryRepository:
|
||||||
|
"""File-backed alert history store."""
|
||||||
|
|
||||||
|
HISTORY_PATH = Path("data/alert_history.json")
|
||||||
|
|
||||||
|
def __init__(self, history_path: Path | None = None) -> None:
|
||||||
|
self.history_path = history_path or self.HISTORY_PATH
|
||||||
|
self.history_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
def load(self) -> list[AlertEvent]:
|
||||||
|
if not self.history_path.exists():
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
with self.history_path.open() as f:
|
||||||
|
data = json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return []
|
||||||
|
if not isinstance(data, list):
|
||||||
|
return []
|
||||||
|
return [AlertEvent.from_dict(item) for item in data if isinstance(item, dict)]
|
||||||
|
|
||||||
|
def save(self, events: list[AlertEvent]) -> None:
|
||||||
|
with self.history_path.open("w") as f:
|
||||||
|
json.dump([event.to_dict() for event in events], f, indent=2)
|
||||||
@@ -154,6 +154,8 @@ class PortfolioConfig:
|
|||||||
raise ValueError("Margin threshold must be between 10% and 95%")
|
raise ValueError("Margin threshold must be between 10% and 95%")
|
||||||
if not 0.1 <= self.ltv_warning <= 0.95:
|
if not 0.1 <= self.ltv_warning <= 0.95:
|
||||||
raise ValueError("LTV warning level must be between 10% and 95%")
|
raise ValueError("LTV warning level must be between 10% and 95%")
|
||||||
|
if self.ltv_warning >= self.margin_threshold:
|
||||||
|
raise ValueError("LTV warning level must be less than the margin threshold")
|
||||||
if self.refresh_interval < 1:
|
if self.refresh_interval < 1:
|
||||||
raise ValueError("Refresh interval must be at least 1 second")
|
raise ValueError("Refresh interval must be at least 1 second")
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ from datetime import datetime, timezone
|
|||||||
from nicegui import ui
|
from nicegui import ui
|
||||||
|
|
||||||
from app.components import PortfolioOverview
|
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.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
|
from app.services.runtime import get_data_service
|
||||||
|
|
||||||
_DEFAULT_CASH_BUFFER = 18_500.0
|
_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")
|
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]:
|
def _alert_badge_classes(severity: str) -> 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
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"spot_price": spot_price,
|
"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",
|
||||||
"gold_units": gold_units,
|
"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",
|
||||||
"gold_value": live_gold_value,
|
"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",
|
||||||
"loan_amount": loan_amount,
|
}.get(severity, "rounded-full bg-slate-100 px-3 py-1 text-xs font-semibold text-slate-700")
|
||||||
"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", "")),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@ui.page("/")
|
@ui.page("/")
|
||||||
@@ -55,7 +38,16 @@ async def overview_page() -> None:
|
|||||||
data_service = get_data_service()
|
data_service = get_data_service()
|
||||||
symbol = data_service.default_symbol
|
symbol = data_service.default_symbol
|
||||||
quote = await data_service.get_quote(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 = (
|
quote_status = (
|
||||||
f"Live quote source: {portfolio['quote_source']} · "
|
f"Live quote source: {portfolio['quote_source']} · "
|
||||||
f"Last updated {_format_timestamp(str(portfolio['quote_updated_at']))}"
|
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}"
|
f"Configured collateral baseline: ${config.gold_value:,.0f} · Loan ${config.loan_amount:,.0f}"
|
||||||
).classes("text-sm text-slate-500 dark:text-slate-400")
|
).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"):
|
with ui.grid(columns=4).classes("w-full gap-4 max-lg:grid-cols-2 max-sm:grid-cols-1"):
|
||||||
summary_cards = [
|
summary_cards = [
|
||||||
(
|
(
|
||||||
@@ -126,6 +135,26 @@ async def overview_page() -> None:
|
|||||||
"Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required."
|
"Warning: if GLD approaches the margin-call price, collateral remediation or hedge monetization will be required."
|
||||||
).classes("text-sm font-medium text-amber-700 dark:text-amber-300")
|
).classes("text-sm font-medium text-amber-700 dark:text-amber-300")
|
||||||
|
|
||||||
|
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("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(
|
with ui.card().classes(
|
||||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
||||||
):
|
):
|
||||||
|
|||||||
@@ -4,6 +4,16 @@ from nicegui import ui
|
|||||||
|
|
||||||
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
|
from app.models.portfolio import PortfolioConfig, get_portfolio_repository
|
||||||
from app.pages.common import dashboard_page
|
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")
|
@ui.page("/settings")
|
||||||
@@ -11,6 +21,7 @@ def settings_page() -> None:
|
|||||||
"""Settings page with persistent portfolio configuration."""
|
"""Settings page with persistent portfolio configuration."""
|
||||||
repo = get_portfolio_repository()
|
repo = get_portfolio_repository()
|
||||||
config = repo.load()
|
config = repo.load()
|
||||||
|
alert_service = AlertService()
|
||||||
|
|
||||||
syncing_entry_basis = False
|
syncing_entry_basis = False
|
||||||
|
|
||||||
@@ -28,6 +39,24 @@ def settings_page() -> None:
|
|||||||
return 0.0
|
return 0.0
|
||||||
return max(parsed, 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(
|
with dashboard_page(
|
||||||
"Settings",
|
"Settings",
|
||||||
"Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.",
|
"Configure portfolio assumptions, collateral entry basis, preferred market data inputs, and alert thresholds.",
|
||||||
@@ -151,7 +180,21 @@ def settings_page() -> None:
|
|||||||
step=0.5,
|
step=0.5,
|
||||||
).classes("w-full")
|
).classes("w-full")
|
||||||
email_alerts = ui.switch("Email alerts", value=config.email_alerts)
|
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(
|
with ui.card().classes(
|
||||||
"w-full rounded-2xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-900"
|
"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")
|
gold_value.props(remove="readonly")
|
||||||
derived_hint.set_text("Start value is the editable basis; gold weight is derived from start value ÷ entry price.")
|
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:
|
def update_entry_basis(*_args: object) -> None:
|
||||||
nonlocal syncing_entry_basis
|
nonlocal syncing_entry_basis
|
||||||
apply_entry_basis_mode()
|
apply_entry_basis_mode()
|
||||||
@@ -224,9 +313,11 @@ def settings_page() -> None:
|
|||||||
else:
|
else:
|
||||||
margin_price_display.set_text("—")
|
margin_price_display.set_text("—")
|
||||||
|
|
||||||
|
render_alert_state()
|
||||||
|
|
||||||
for element in (entry_basis_mode, entry_price, gold_value, gold_ounces):
|
for element in (entry_basis_mode, entry_price, gold_value, gold_ounces):
|
||||||
element.on_value_change(update_entry_basis)
|
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)
|
element.on_value_change(update_calculations)
|
||||||
|
|
||||||
apply_entry_basis_mode()
|
apply_entry_basis_mode()
|
||||||
@@ -234,30 +325,11 @@ def settings_page() -> None:
|
|||||||
|
|
||||||
def save_settings() -> None:
|
def save_settings() -> None:
|
||||||
try:
|
try:
|
||||||
new_config = PortfolioConfig(
|
new_config = build_preview_config()
|
||||||
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),
|
|
||||||
)
|
|
||||||
|
|
||||||
repo.save(new_config)
|
repo.save(new_config)
|
||||||
|
render_alert_state()
|
||||||
|
|
||||||
status.set_text(
|
status.set_text(save_status_text(new_config))
|
||||||
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"
|
|
||||||
)
|
|
||||||
ui.notify("Settings saved successfully", color="positive")
|
ui.notify("Settings saved successfully", color="positive")
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
ui.notify(f"Validation error: {e}", color="negative")
|
ui.notify(f"Validation error: {e}", color="negative")
|
||||||
|
|||||||
101
app/services/alerts.py
Normal file
101
app/services/alerts.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Alert evaluation and history persistence."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from app.models.alerts import AlertEvent, AlertHistoryRepository, AlertStatus
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
|
||||||
|
|
||||||
|
def build_portfolio_alert_context(
|
||||||
|
config: PortfolioConfig,
|
||||||
|
*,
|
||||||
|
spot_price: float,
|
||||||
|
source: str,
|
||||||
|
updated_at: str,
|
||||||
|
) -> dict[str, float | str]:
|
||||||
|
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)
|
||||||
|
return {
|
||||||
|
"spot_price": float(spot_price),
|
||||||
|
"gold_units": gold_units,
|
||||||
|
"gold_value": live_gold_value,
|
||||||
|
"loan_amount": loan_amount,
|
||||||
|
"ltv_ratio": loan_amount / live_gold_value if live_gold_value > 0 else 0.0,
|
||||||
|
"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,
|
||||||
|
"quote_source": source,
|
||||||
|
"quote_updated_at": updated_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AlertService:
|
||||||
|
def __init__(self, history_path=None) -> None:
|
||||||
|
self.repository = AlertHistoryRepository(history_path=history_path)
|
||||||
|
|
||||||
|
def evaluate(
|
||||||
|
self, config: PortfolioConfig, portfolio: Mapping[str, object], *, persist: bool = True
|
||||||
|
) -> AlertStatus:
|
||||||
|
history = self.repository.load() if persist else []
|
||||||
|
ltv_ratio = float(portfolio.get("ltv_ratio", 0.0))
|
||||||
|
spot_price = float(portfolio.get("spot_price", 0.0))
|
||||||
|
updated_at = str(portfolio.get("quote_updated_at", ""))
|
||||||
|
|
||||||
|
if ltv_ratio >= float(config.margin_threshold):
|
||||||
|
severity = "critical"
|
||||||
|
message = (
|
||||||
|
f"Current LTV {ltv_ratio:.1%} is above the critical threshold of "
|
||||||
|
f"{float(config.margin_threshold):.1%}."
|
||||||
|
)
|
||||||
|
elif ltv_ratio >= float(config.ltv_warning):
|
||||||
|
severity = "warning"
|
||||||
|
message = (
|
||||||
|
f"Current LTV {ltv_ratio:.1%} is above the warning threshold of " f"{float(config.ltv_warning):.1%}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
severity = "ok"
|
||||||
|
message = "LTV is within configured thresholds."
|
||||||
|
|
||||||
|
preview_history: list[AlertEvent] = []
|
||||||
|
if severity != "ok":
|
||||||
|
event = AlertEvent(
|
||||||
|
severity=severity,
|
||||||
|
message=message,
|
||||||
|
ltv_ratio=ltv_ratio,
|
||||||
|
warning_threshold=float(config.ltv_warning),
|
||||||
|
critical_threshold=float(config.margin_threshold),
|
||||||
|
spot_price=spot_price,
|
||||||
|
updated_at=updated_at,
|
||||||
|
email_alerts_enabled=bool(config.email_alerts),
|
||||||
|
)
|
||||||
|
if persist:
|
||||||
|
if self._should_record(history, event):
|
||||||
|
history.append(event)
|
||||||
|
self.repository.save(history)
|
||||||
|
else:
|
||||||
|
preview_history = [event]
|
||||||
|
|
||||||
|
return AlertStatus(
|
||||||
|
severity=severity,
|
||||||
|
message=message,
|
||||||
|
ltv_ratio=ltv_ratio,
|
||||||
|
warning_threshold=float(config.ltv_warning),
|
||||||
|
critical_threshold=float(config.margin_threshold),
|
||||||
|
email_alerts_enabled=bool(config.email_alerts),
|
||||||
|
history=(
|
||||||
|
preview_history
|
||||||
|
if not persist
|
||||||
|
else list(reversed(self.repository.load() if severity != "ok" else history))
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_record(history: list[AlertEvent], event: AlertEvent) -> bool:
|
||||||
|
if not history:
|
||||||
|
return True
|
||||||
|
latest = history[-1]
|
||||||
|
return latest.severity != event.severity
|
||||||
27
app/services/settings_status.py
Normal file
27
app/services/settings_status.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
|
||||||
|
|
||||||
|
class _SaveStatusConfig(Protocol):
|
||||||
|
entry_basis_mode: str
|
||||||
|
gold_value: float | None
|
||||||
|
entry_price: float | None
|
||||||
|
gold_ounces: float | None
|
||||||
|
current_ltv: float
|
||||||
|
margin_call_price: object
|
||||||
|
|
||||||
|
|
||||||
|
def margin_call_price_value(config: PortfolioConfig | _SaveStatusConfig) -> float:
|
||||||
|
margin_call_price = config.margin_call_price
|
||||||
|
return float(margin_call_price() if callable(margin_call_price) else margin_call_price)
|
||||||
|
|
||||||
|
|
||||||
|
def save_status_text(config: PortfolioConfig | _SaveStatusConfig) -> str:
|
||||||
|
return (
|
||||||
|
f"Saved: basis={config.entry_basis_mode}, start=${config.gold_value:,.0f}, "
|
||||||
|
f"entry=${config.entry_price:,.2f}/oz, weight={config.gold_ounces:,.2f} oz, "
|
||||||
|
f"LTV={config.current_ltv:.1%}, trigger=${margin_call_price_value(config):,.2f}/oz"
|
||||||
|
)
|
||||||
140
tests/test_alerts.py
Normal file
140
tests/test_alerts.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
from app.services.alerts import AlertService, build_portfolio_alert_context
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def alert_service(tmp_path: Path) -> AlertService:
|
||||||
|
return AlertService(history_path=tmp_path / "alert_history.json")
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_service_reports_ok_when_ltv_is_below_warning(alert_service: AlertService) -> None:
|
||||||
|
config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=120_000.0)
|
||||||
|
portfolio = build_portfolio_alert_context(
|
||||||
|
config, spot_price=215.0, source="test", updated_at="2026-03-24T12:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
status = alert_service.evaluate(config, portfolio)
|
||||||
|
|
||||||
|
assert status.severity == "ok"
|
||||||
|
assert status.message == "LTV is within configured thresholds."
|
||||||
|
assert status.history == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_service_preview_does_not_persist_history(alert_service: AlertService) -> None:
|
||||||
|
config = PortfolioConfig(
|
||||||
|
gold_value=215_000.0,
|
||||||
|
entry_price=215.0,
|
||||||
|
loan_amount=151_000.0,
|
||||||
|
ltv_warning=0.70,
|
||||||
|
margin_threshold=0.75,
|
||||||
|
)
|
||||||
|
portfolio = build_portfolio_alert_context(config, spot_price=215.0, source="settings-preview", updated_at="")
|
||||||
|
|
||||||
|
preview_status = alert_service.evaluate(config, portfolio, persist=False)
|
||||||
|
|
||||||
|
assert preview_status.severity == "warning"
|
||||||
|
assert [event.severity for event in preview_status.history] == ["warning"]
|
||||||
|
assert alert_service.repository.load() == []
|
||||||
|
|
||||||
|
persisted_status = alert_service.evaluate(config, portfolio)
|
||||||
|
|
||||||
|
assert [event.severity for event in persisted_status.history] == ["warning"]
|
||||||
|
assert len(alert_service.repository.load()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_service_logs_warning_once_when_warning_threshold_is_crossed(alert_service: AlertService) -> None:
|
||||||
|
config = PortfolioConfig(
|
||||||
|
gold_value=215_000.0,
|
||||||
|
entry_price=215.0,
|
||||||
|
loan_amount=151_000.0,
|
||||||
|
ltv_warning=0.70,
|
||||||
|
margin_threshold=0.75,
|
||||||
|
email_alerts=True,
|
||||||
|
)
|
||||||
|
portfolio = build_portfolio_alert_context(
|
||||||
|
config, spot_price=215.0, source="test", updated_at="2026-03-24T12:00:00Z"
|
||||||
|
)
|
||||||
|
|
||||||
|
first_status = alert_service.evaluate(config, portfolio)
|
||||||
|
second_status = alert_service.evaluate(config, portfolio)
|
||||||
|
|
||||||
|
assert first_status.severity == "warning"
|
||||||
|
assert first_status.email_alerts_enabled is True
|
||||||
|
assert len(first_status.history) == 1
|
||||||
|
assert first_status.history[0].severity == "warning"
|
||||||
|
assert "70.2%" in first_status.history[0].message
|
||||||
|
assert len(second_status.history) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_service_escalates_to_critical_and_keeps_history(alert_service: AlertService) -> None:
|
||||||
|
config = PortfolioConfig(
|
||||||
|
gold_value=215_000.0,
|
||||||
|
entry_price=215.0,
|
||||||
|
loan_amount=150_500.0,
|
||||||
|
ltv_warning=0.70,
|
||||||
|
margin_threshold=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
warning_portfolio = build_portfolio_alert_context(
|
||||||
|
config,
|
||||||
|
spot_price=215.0,
|
||||||
|
source="test",
|
||||||
|
updated_at="2026-03-24T12:00:00Z",
|
||||||
|
)
|
||||||
|
critical_portfolio = build_portfolio_alert_context(
|
||||||
|
config,
|
||||||
|
spot_price=200.0,
|
||||||
|
source="test",
|
||||||
|
updated_at="2026-03-24T13:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
alert_service.evaluate(config, warning_portfolio)
|
||||||
|
critical_status = alert_service.evaluate(config, critical_portfolio)
|
||||||
|
|
||||||
|
assert critical_status.severity == "critical"
|
||||||
|
assert "above the critical threshold" in critical_status.message
|
||||||
|
assert [event.severity for event in critical_status.history] == ["critical", "warning"]
|
||||||
|
assert critical_status.history[0].ltv_ratio == pytest.approx(0.7525, rel=1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_alert_service_preserves_persisted_history_during_ok_evaluation(alert_service: AlertService) -> None:
|
||||||
|
warning_config = PortfolioConfig(
|
||||||
|
gold_value=215_000.0,
|
||||||
|
entry_price=215.0,
|
||||||
|
loan_amount=151_000.0,
|
||||||
|
ltv_warning=0.70,
|
||||||
|
margin_threshold=0.75,
|
||||||
|
)
|
||||||
|
ok_config = PortfolioConfig(
|
||||||
|
gold_value=215_000.0,
|
||||||
|
entry_price=215.0,
|
||||||
|
loan_amount=120_000.0,
|
||||||
|
ltv_warning=0.70,
|
||||||
|
margin_threshold=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
warning_portfolio = build_portfolio_alert_context(
|
||||||
|
warning_config,
|
||||||
|
spot_price=215.0,
|
||||||
|
source="test",
|
||||||
|
updated_at="2026-03-24T12:00:00Z",
|
||||||
|
)
|
||||||
|
ok_portfolio = build_portfolio_alert_context(
|
||||||
|
ok_config,
|
||||||
|
spot_price=215.0,
|
||||||
|
source="test",
|
||||||
|
updated_at="2026-03-24T13:00:00Z",
|
||||||
|
)
|
||||||
|
|
||||||
|
alert_service.evaluate(warning_config, warning_portfolio)
|
||||||
|
ok_status = alert_service.evaluate(ok_config, ok_portfolio)
|
||||||
|
|
||||||
|
assert ok_status.severity == "ok"
|
||||||
|
assert [event.severity for event in ok_status.history] == ["warning"]
|
||||||
|
assert len(alert_service.repository.load()) == 1
|
||||||
@@ -19,6 +19,7 @@ def test_homepage_and_options_page_render() -> None:
|
|||||||
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
|
expect(page.locator("text=Vault Dashboard").first).to_be_visible(timeout=10000)
|
||||||
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
|
expect(page.locator("text=Overview").first).to_be_visible(timeout=10000)
|
||||||
expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000)
|
expect(page.locator("text=Live quote source:").first).to_be_visible(timeout=15000)
|
||||||
|
expect(page.locator("text=Alert Status").first).to_be_visible(timeout=15000)
|
||||||
page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
|
page.screenshot(path=str(ARTIFACTS / "overview.png"), full_page=True)
|
||||||
|
|
||||||
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)
|
page.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000)
|
||||||
|
|||||||
30
tests/test_settings.py
Normal file
30
tests/test_settings.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.portfolio import PortfolioConfig
|
||||||
|
from app.services.settings_status import save_status_text
|
||||||
|
|
||||||
|
|
||||||
|
class CallableMarginCallPriceConfig:
|
||||||
|
def __init__(self, config: PortfolioConfig) -> None:
|
||||||
|
self.entry_basis_mode = config.entry_basis_mode
|
||||||
|
self.gold_value = config.gold_value
|
||||||
|
self.entry_price = config.entry_price
|
||||||
|
self.gold_ounces = config.gold_ounces
|
||||||
|
self.current_ltv = config.current_ltv
|
||||||
|
self._margin_call_price = config.margin_call_price
|
||||||
|
|
||||||
|
def margin_call_price(self) -> float:
|
||||||
|
return self._margin_call_price
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_status_text_uses_margin_call_price_api() -> None:
|
||||||
|
config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=145_000.0)
|
||||||
|
|
||||||
|
status = save_status_text(CallableMarginCallPriceConfig(config))
|
||||||
|
|
||||||
|
assert "Saved: basis=value_price" in status
|
||||||
|
assert "start=$215,000" in status
|
||||||
|
assert "entry=$215.00/oz" in status
|
||||||
|
assert "weight=1,000.00 oz" in status
|
||||||
|
assert "LTV=67.4%" in status
|
||||||
|
assert "trigger=$193.33/oz" in status
|
||||||
Reference in New Issue
Block a user