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

140
tests/test_alerts.py Normal file
View 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