diff --git a/app/services/alerts.py b/app/services/alerts.py index 8091d09..0183e2c 100644 --- a/app/services/alerts.py +++ b/app/services/alerts.py @@ -2,13 +2,79 @@ from __future__ import annotations +from dataclasses import dataclass +from decimal import Decimal from typing import Mapping from app.domain.portfolio_math import build_alert_context +from app.domain.units import decimal_from_float from app.models.alerts import AlertEvent, AlertHistoryRepository, AlertStatus from app.models.portfolio import PortfolioConfig +@dataclass(frozen=True, slots=True) +class AlertEvaluationInput: + ltv_ratio: Decimal + spot_price: Decimal + updated_at: str + warning_threshold: Decimal + critical_threshold: Decimal + email_alerts_enabled: bool + + +def _decimal_from_boundary_value(value: object, *, field_name: str, default: float = 0.0) -> Decimal: + if value is None: + return decimal_from_float(float(default)) + if isinstance(value, bool): + raise TypeError(f"{field_name} must be numeric, got bool") + if isinstance(value, int): + parsed = float(value) + elif isinstance(value, float): + parsed = value + elif isinstance(value, str): + stripped = value.strip() + if not stripped: + parsed = float(default) + else: + try: + parsed = float(stripped) + except ValueError as exc: + raise ValueError(f"{field_name} must be numeric, got {value!r}") from exc + else: + raise TypeError(f"{field_name} must be numeric, got {type(value)!r}") + return decimal_from_float(float(parsed)) + + +def _normalize_alert_evaluation_input( + config: PortfolioConfig, + portfolio: Mapping[str, object], +) -> AlertEvaluationInput: + return AlertEvaluationInput( + ltv_ratio=_decimal_from_boundary_value( + portfolio.get("ltv_ratio", 0.0), + field_name="portfolio.ltv_ratio", + ), + spot_price=_decimal_from_boundary_value( + portfolio.get("spot_price", 0.0), + field_name="portfolio.spot_price", + ), + updated_at=str(portfolio.get("quote_updated_at", "")), + warning_threshold=_decimal_from_boundary_value( + config.ltv_warning, + field_name="config.ltv_warning", + ), + critical_threshold=_decimal_from_boundary_value( + config.margin_threshold, + field_name="config.margin_threshold", + ), + email_alerts_enabled=bool(config.email_alerts), + ) + + +def _ratio_text(value: Decimal) -> str: + return f"{float(value):.1%}" + + def build_portfolio_alert_context( config: PortfolioConfig, *, @@ -32,20 +98,19 @@ class AlertService: 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", "")) + evaluation = _normalize_alert_evaluation_input(config, portfolio) - if ltv_ratio >= float(config.margin_threshold): + if evaluation.ltv_ratio >= evaluation.critical_threshold: severity = "critical" message = ( - f"Current LTV {ltv_ratio:.1%} is above the critical threshold of " - f"{float(config.margin_threshold):.1%}." + f"Current LTV {_ratio_text(evaluation.ltv_ratio)} is above the critical threshold of " + f"{_ratio_text(evaluation.critical_threshold)}." ) - elif ltv_ratio >= float(config.ltv_warning): + elif evaluation.ltv_ratio >= evaluation.warning_threshold: severity = "warning" message = ( - f"Current LTV {ltv_ratio:.1%} is above the warning threshold of " f"{float(config.ltv_warning):.1%}." + f"Current LTV {_ratio_text(evaluation.ltv_ratio)} is above the warning threshold of " + f"{_ratio_text(evaluation.warning_threshold)}." ) else: severity = "ok" @@ -56,12 +121,12 @@ class AlertService: 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), + ltv_ratio=float(evaluation.ltv_ratio), + warning_threshold=float(evaluation.warning_threshold), + critical_threshold=float(evaluation.critical_threshold), + spot_price=float(evaluation.spot_price), + updated_at=evaluation.updated_at, + email_alerts_enabled=evaluation.email_alerts_enabled, ) if persist: if self._should_record(history, event): @@ -73,10 +138,10 @@ class AlertService: 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), + ltv_ratio=float(evaluation.ltv_ratio), + warning_threshold=float(evaluation.warning_threshold), + critical_threshold=float(evaluation.critical_threshold), + email_alerts_enabled=evaluation.email_alerts_enabled, history=( preview_history if not persist diff --git a/app/services/settings_status.py b/app/services/settings_status.py index 2f972ef..22bb7a7 100644 --- a/app/services/settings_status.py +++ b/app/services/settings_status.py @@ -1,7 +1,10 @@ from __future__ import annotations +from dataclasses import dataclass +from decimal import Decimal from typing import Protocol +from app.domain.units import decimal_from_float from app.models.portfolio import PortfolioConfig @@ -14,14 +17,62 @@ class _SaveStatusConfig(Protocol): margin_call_price: object -def margin_call_price_value(config: PortfolioConfig | _SaveStatusConfig) -> float: +@dataclass(frozen=True, slots=True) +class SaveStatusSnapshot: + entry_basis_mode: str + gold_value: Decimal + entry_price: Decimal + gold_ounces: Decimal + current_ltv: Decimal + margin_call_price: Decimal + + +def _decimal_from_boundary_value(value: object, *, field_name: str) -> Decimal: + if value is None: + raise ValueError(f"{field_name} must be present") + if isinstance(value, bool): + raise TypeError(f"{field_name} must be numeric, got bool") + if isinstance(value, int): + parsed = float(value) + elif isinstance(value, float): + parsed = value + elif isinstance(value, str): + stripped = value.strip() + if not stripped: + raise ValueError(f"{field_name} must be present") + try: + parsed = float(stripped) + except ValueError as exc: + raise ValueError(f"{field_name} must be numeric, got {value!r}") from exc + else: + raise TypeError(f"{field_name} must be numeric, got {type(value)!r}") + return decimal_from_float(float(parsed)) + + +def _normalize_save_status_snapshot(config: PortfolioConfig | _SaveStatusConfig) -> SaveStatusSnapshot: margin_call_price = config.margin_call_price - return float(margin_call_price() if callable(margin_call_price) else margin_call_price) + resolved_margin_call_price = margin_call_price() if callable(margin_call_price) else margin_call_price + return SaveStatusSnapshot( + entry_basis_mode=config.entry_basis_mode, + gold_value=_decimal_from_boundary_value(config.gold_value, field_name="config.gold_value"), + entry_price=_decimal_from_boundary_value(config.entry_price, field_name="config.entry_price"), + gold_ounces=_decimal_from_boundary_value(config.gold_ounces, field_name="config.gold_ounces"), + current_ltv=_decimal_from_boundary_value(config.current_ltv, field_name="config.current_ltv"), + margin_call_price=_decimal_from_boundary_value( + resolved_margin_call_price, + field_name="config.margin_call_price", + ), + ) + + +def margin_call_price_value(config: PortfolioConfig | _SaveStatusConfig) -> float: + return float(_normalize_save_status_snapshot(config).margin_call_price) def save_status_text(config: PortfolioConfig | _SaveStatusConfig) -> str: + snapshot = _normalize_save_status_snapshot(config) 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" + f"Saved: basis={snapshot.entry_basis_mode}, start=${float(snapshot.gold_value):,.0f}, " + f"entry=${float(snapshot.entry_price):,.2f}/oz, weight={float(snapshot.gold_ounces):,.2f} oz, " + f"LTV={float(snapshot.current_ltv):.1%}, trigger=${float(snapshot.margin_call_price):,.2f}/oz" ) diff --git a/docs/roadmap/ROADMAP.yaml b/docs/roadmap/ROADMAP.yaml index 95e00e0..19d364a 100644 --- a/docs/roadmap/ROADMAP.yaml +++ b/docs/roadmap/ROADMAP.yaml @@ -25,13 +25,13 @@ priority_queue: - OPS-001 - BT-003 recently_completed: + - CORE-001D3A - UX-001 - CORE-002 - CORE-002C - CORE-001D2B - CORE-001D2A - CORE-002B - - CORE-002A states: backlog: - DATA-002A @@ -65,6 +65,7 @@ states: - CORE-001C - CORE-001D2A - CORE-001D2B + - CORE-001D3A - CORE-002 - CORE-002A - CORE-002B diff --git a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml b/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml index a814084..2a6ffae 100644 --- a/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml +++ b/docs/roadmap/backlog/CORE-001D-decimal-boundary-cleanup.yaml @@ -17,6 +17,7 @@ technical_notes: - `CORE-001D1` is complete: portfolio/workspace persistence now uses an explicit unit-aware schema with strict validation and atomic saves. - `CORE-001D2A` is complete: DataService quote/provider cache normalization is now a named boundary adapter with explicit symbol mismatch rejection and GLD quote-unit repair. - `CORE-001D2B` is complete: option expirations and options-chain payloads now use explicit normalization boundaries with malformed cached payload discard/retry behavior. - - Remaining focus is the rest of `CORE-001D2` provider/cache normalization plus `CORE-001D3` service entrypoint tightening. + - `CORE-001D3A` is complete: alert evaluation and settings save-status entrypoints now normalize float-heavy boundary values through explicit named adapters. + - Remaining focus is the rest of `CORE-001D2` provider/cache normalization plus follow-on `CORE-001D3` service entrypoint tightening. - Pre-launch policy: unit-aware schema changes may be breaking until persistence is considered live; old flat payloads may fail loudly instead of being migrated. - See `docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md` for the current hotspot inventory and proposed sub-slices. diff --git a/docs/roadmap/done/CORE-001D3A-alerts-settings-entrypoint-normalization.yaml b/docs/roadmap/done/CORE-001D3A-alerts-settings-entrypoint-normalization.yaml new file mode 100644 index 0000000..837f160 --- /dev/null +++ b/docs/roadmap/done/CORE-001D3A-alerts-settings-entrypoint-normalization.yaml @@ -0,0 +1,20 @@ +id: CORE-001D3A +title: Alerts and Settings Entrypoint Normalization +status: done +priority: P2 +effort: S +depends_on: + - CORE-001B + - CORE-001D2B +tags: + - core + - decimal + - alerts + - settings +summary: Alert evaluation and settings save-status formatting now normalize float-heavy inputs through explicit named adapters. +completed_notes: + - Added explicit normalization adapters in `app/services/alerts.py` and `app/services/settings_status.py`. + - Alert evaluation now compares thresholds through normalized Decimal-backed boundary values instead of ad-hoc `float(...)` extraction. + - Settings save-status formatting now snapshots numeric boundary values through a named adapter before rendering. + - Added focused regression tests for numeric-string coercion and bool fail-closed behavior in `tests/test_alerts.py` and `tests/test_settings.py`. + - Validated with focused pytest coverage, browser-visible Playwright coverage, and `make build` on local Docker. diff --git a/tests/test_alerts.py b/tests/test_alerts.py index d726d6d..096daf5 100644 --- a/tests/test_alerts.py +++ b/tests/test_alerts.py @@ -1,11 +1,12 @@ from __future__ import annotations +from decimal import Decimal from pathlib import Path import pytest from app.models.portfolio import PortfolioConfig -from app.services.alerts import AlertService, build_portfolio_alert_context +from app.services.alerts import AlertService, _normalize_alert_evaluation_input, build_portfolio_alert_context @pytest.fixture @@ -13,6 +14,39 @@ def alert_service(tmp_path: Path) -> AlertService: return AlertService(history_path=tmp_path / "alert_history.json") +def test_normalize_alert_evaluation_input_coerces_numeric_boundary_values() -> None: + config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) + + normalized = _normalize_alert_evaluation_input( + config, + { + "ltv_ratio": "0.7023", + "spot_price": "215.0", + "quote_updated_at": 123, + }, + ) + + assert normalized.ltv_ratio == Decimal("0.7023") + assert normalized.spot_price == Decimal("215.0") + assert normalized.updated_at == "123" + assert normalized.warning_threshold == Decimal("0.7") + assert normalized.critical_threshold == Decimal("0.75") + + +def test_normalize_alert_evaluation_input_rejects_bool_values() -> None: + config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=151_000.0) + + with pytest.raises(TypeError, match="portfolio.ltv_ratio must be numeric, got bool"): + _normalize_alert_evaluation_input( + config, + { + "ltv_ratio": True, + "spot_price": 215.0, + "quote_updated_at": "2026-03-24T12:00:00Z", + }, + ) + + 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( diff --git a/tests/test_settings.py b/tests/test_settings.py index e4ead60..74aa6ec 100644 --- a/tests/test_settings.py +++ b/tests/test_settings.py @@ -1,7 +1,11 @@ from __future__ import annotations +from decimal import Decimal + +import pytest + from app.models.portfolio import PortfolioConfig -from app.services.settings_status import save_status_text +from app.services.settings_status import _normalize_save_status_snapshot, save_status_text class CallableMarginCallPriceConfig: @@ -17,6 +21,34 @@ class CallableMarginCallPriceConfig: return self._margin_call_price +class StringBoundaryConfig: + entry_basis_mode = "weight" + gold_value = "215000" + entry_price = "215.0" + gold_ounces = "1000" + current_ltv = "0.6744186046511628" + margin_call_price = "193.33333333333334" + + +def test_normalize_save_status_snapshot_coerces_numeric_boundary_values() -> None: + snapshot = _normalize_save_status_snapshot(StringBoundaryConfig()) + + assert snapshot.entry_basis_mode == "weight" + assert snapshot.gold_value == Decimal("215000") + assert snapshot.entry_price == Decimal("215.0") + assert snapshot.gold_ounces == Decimal("1000") + assert snapshot.current_ltv == Decimal("0.6744186046511628") + assert snapshot.margin_call_price == Decimal("193.33333333333334") + + +def test_normalize_save_status_snapshot_rejects_bool_values() -> None: + class InvalidBoundaryConfig(StringBoundaryConfig): + current_ltv = True + + with pytest.raises(TypeError, match="config.current_ltv must be numeric, got bool"): + _normalize_save_status_snapshot(InvalidBoundaryConfig()) + + 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)