feat(CORE-001D3A): normalize alerts and settings service boundaries
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user