feat(CORE-001D3A): normalize alerts and settings service boundaries

This commit is contained in:
Bu5hm4nn
2026-03-26 13:10:30 +01:00
parent 91f67cd414
commit bb557009c7
7 changed files with 231 additions and 27 deletions

View File

@@ -2,13 +2,79 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Mapping from typing import Mapping
from app.domain.portfolio_math import build_alert_context 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.alerts import AlertEvent, AlertHistoryRepository, AlertStatus
from app.models.portfolio import PortfolioConfig 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( def build_portfolio_alert_context(
config: PortfolioConfig, config: PortfolioConfig,
*, *,
@@ -32,20 +98,19 @@ class AlertService:
self, config: PortfolioConfig, portfolio: Mapping[str, object], *, persist: bool = True self, config: PortfolioConfig, portfolio: Mapping[str, object], *, persist: bool = True
) -> AlertStatus: ) -> AlertStatus:
history = self.repository.load() if persist else [] history = self.repository.load() if persist else []
ltv_ratio = float(portfolio.get("ltv_ratio", 0.0)) evaluation = _normalize_alert_evaluation_input(config, portfolio)
spot_price = float(portfolio.get("spot_price", 0.0))
updated_at = str(portfolio.get("quote_updated_at", ""))
if ltv_ratio >= float(config.margin_threshold): if evaluation.ltv_ratio >= evaluation.critical_threshold:
severity = "critical" severity = "critical"
message = ( message = (
f"Current LTV {ltv_ratio:.1%} is above the critical threshold of " f"Current LTV {_ratio_text(evaluation.ltv_ratio)} is above the critical threshold of "
f"{float(config.margin_threshold):.1%}." f"{_ratio_text(evaluation.critical_threshold)}."
) )
elif ltv_ratio >= float(config.ltv_warning): elif evaluation.ltv_ratio >= evaluation.warning_threshold:
severity = "warning" severity = "warning"
message = ( 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: else:
severity = "ok" severity = "ok"
@@ -56,12 +121,12 @@ class AlertService:
event = AlertEvent( event = AlertEvent(
severity=severity, severity=severity,
message=message, message=message,
ltv_ratio=ltv_ratio, ltv_ratio=float(evaluation.ltv_ratio),
warning_threshold=float(config.ltv_warning), warning_threshold=float(evaluation.warning_threshold),
critical_threshold=float(config.margin_threshold), critical_threshold=float(evaluation.critical_threshold),
spot_price=spot_price, spot_price=float(evaluation.spot_price),
updated_at=updated_at, updated_at=evaluation.updated_at,
email_alerts_enabled=bool(config.email_alerts), email_alerts_enabled=evaluation.email_alerts_enabled,
) )
if persist: if persist:
if self._should_record(history, event): if self._should_record(history, event):
@@ -73,10 +138,10 @@ class AlertService:
return AlertStatus( return AlertStatus(
severity=severity, severity=severity,
message=message, message=message,
ltv_ratio=ltv_ratio, ltv_ratio=float(evaluation.ltv_ratio),
warning_threshold=float(config.ltv_warning), warning_threshold=float(evaluation.warning_threshold),
critical_threshold=float(config.margin_threshold), critical_threshold=float(evaluation.critical_threshold),
email_alerts_enabled=bool(config.email_alerts), email_alerts_enabled=evaluation.email_alerts_enabled,
history=( history=(
preview_history preview_history
if not persist if not persist

View File

@@ -1,7 +1,10 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal
from typing import Protocol from typing import Protocol
from app.domain.units import decimal_from_float
from app.models.portfolio import PortfolioConfig from app.models.portfolio import PortfolioConfig
@@ -14,14 +17,62 @@ class _SaveStatusConfig(Protocol):
margin_call_price: object 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 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: def save_status_text(config: PortfolioConfig | _SaveStatusConfig) -> str:
snapshot = _normalize_save_status_snapshot(config)
return ( return (
f"Saved: basis={config.entry_basis_mode}, start=${config.gold_value:,.0f}, " f"Saved: basis={snapshot.entry_basis_mode}, start=${float(snapshot.gold_value):,.0f}, "
f"entry=${config.entry_price:,.2f}/oz, weight={config.gold_ounces:,.2f} oz, " f"entry=${float(snapshot.entry_price):,.2f}/oz, weight={float(snapshot.gold_ounces):,.2f} oz, "
f"LTV={config.current_ltv:.1%}, trigger=${margin_call_price_value(config):,.2f}/oz" f"LTV={float(snapshot.current_ltv):.1%}, trigger=${float(snapshot.margin_call_price):,.2f}/oz"
) )

View File

@@ -25,13 +25,13 @@ priority_queue:
- OPS-001 - OPS-001
- BT-003 - BT-003
recently_completed: recently_completed:
- CORE-001D3A
- UX-001 - UX-001
- CORE-002 - CORE-002
- CORE-002C - CORE-002C
- CORE-001D2B - CORE-001D2B
- CORE-001D2A - CORE-001D2A
- CORE-002B - CORE-002B
- CORE-002A
states: states:
backlog: backlog:
- DATA-002A - DATA-002A
@@ -65,6 +65,7 @@ states:
- CORE-001C - CORE-001C
- CORE-001D2A - CORE-001D2A
- CORE-001D2B - CORE-001D2B
- CORE-001D3A
- CORE-002 - CORE-002
- CORE-002A - CORE-002A
- CORE-002B - CORE-002B

View File

@@ -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-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-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. - `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. - 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. - See `docs/CORE-001D_BOUNDARY_CLEANUP_PLAN.md` for the current hotspot inventory and proposed sub-slices.

View File

@@ -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.

View File

@@ -1,11 +1,12 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest import pytest
from app.models.portfolio import PortfolioConfig 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 @pytest.fixture
@@ -13,6 +14,39 @@ def alert_service(tmp_path: Path) -> AlertService:
return AlertService(history_path=tmp_path / "alert_history.json") 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: 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) config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=120_000.0)
portfolio = build_portfolio_alert_context( portfolio = build_portfolio_alert_context(

View File

@@ -1,7 +1,11 @@
from __future__ import annotations from __future__ import annotations
from decimal import Decimal
import pytest
from app.models.portfolio import PortfolioConfig 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: class CallableMarginCallPriceConfig:
@@ -17,6 +21,34 @@ class CallableMarginCallPriceConfig:
return self._margin_call_price 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: 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) config = PortfolioConfig(gold_value=215_000.0, entry_price=215.0, loan_amount=145_000.0)