"""Alert evaluation and history persistence.""" 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, *, spot_price: float, source: str, updated_at: str, ) -> dict[str, float | str]: return build_alert_context( config, spot_price=spot_price, source=source, updated_at=updated_at, ) class AlertService: def __init__(self, history_path=None) -> None: self.repository = AlertHistoryRepository(history_path=history_path) def evaluate( self, config: PortfolioConfig, portfolio: Mapping[str, object], *, persist: bool = True ) -> AlertStatus: history = self.repository.load() if persist else [] evaluation = _normalize_alert_evaluation_input(config, portfolio) if evaluation.ltv_ratio >= evaluation.critical_threshold: severity = "critical" message = ( f"Current LTV {_ratio_text(evaluation.ltv_ratio)} is above the critical threshold of " f"{_ratio_text(evaluation.critical_threshold)}." ) elif evaluation.ltv_ratio >= evaluation.warning_threshold: severity = "warning" message = ( f"Current LTV {_ratio_text(evaluation.ltv_ratio)} is above the warning threshold of " f"{_ratio_text(evaluation.warning_threshold)}." ) else: severity = "ok" message = "LTV is within configured thresholds." preview_history: list[AlertEvent] = [] if severity != "ok": event = AlertEvent( severity=severity, message=message, 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): history.append(event) self.repository.save(history) else: preview_history = [event] return AlertStatus( severity=severity, message=message, 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 else list(reversed(self.repository.load() if severity != "ok" else history)) ), ) @staticmethod def _should_record(history: list[AlertEvent], event: AlertEvent) -> bool: if not history: return True latest = history[-1] return latest.severity != event.severity