diff --git a/app/models/alerts.py b/app/models/alerts.py new file mode 100644 index 0000000..81b4fe8 --- /dev/null +++ b/app/models/alerts.py @@ -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) diff --git a/app/models/portfolio.py b/app/models/portfolio.py index 2751176..4bec44e 100644 --- a/app/models/portfolio.py +++ b/app/models/portfolio.py @@ -154,6 +154,8 @@ class PortfolioConfig: raise ValueError("Margin threshold must be between 10% and 95%") if not 0.1 <= self.ltv_warning <= 0.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: raise ValueError("Refresh interval must be at least 1 second") diff --git a/app/pages/overview.py b/app/pages/overview.py index ca07698..276bf11 100644 --- a/app/pages/overview.py +++ b/app/pages/overview.py @@ -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"): diff --git a/app/pages/settings.py b/app/pages/settings.py index f9f7f69..b6f117d 100644 --- a/app/pages/settings.py +++ b/app/pages/settings.py @@ -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") diff --git a/app/services/alerts.py b/app/services/alerts.py new file mode 100644 index 0000000..394adbf --- /dev/null +++ b/app/services/alerts.py @@ -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 diff --git a/app/services/settings_status.py b/app/services/settings_status.py new file mode 100644 index 0000000..2f972ef --- /dev/null +++ b/app/services/settings_status.py @@ -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" + ) diff --git a/tests/test_alerts.py b/tests/test_alerts.py new file mode 100644 index 0000000..d726d6d --- /dev/null +++ b/tests/test_alerts.py @@ -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 diff --git a/tests/test_e2e_playwright.py b/tests/test_e2e_playwright.py index 288a44b..051accc 100644 --- a/tests/test_e2e_playwright.py +++ b/tests/test_e2e_playwright.py @@ -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=Overview").first).to_be_visible(timeout=10000) 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.goto(f"{BASE_URL}/options", wait_until="domcontentloaded", timeout=30000) diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 0000000..e4ead60 --- /dev/null +++ b/tests/test_settings.py @@ -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