feat(PORT-002): add alert status and history
This commit is contained in:
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=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)
|
||||
|
||||
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