feat(PORT-002): add alert status and history
This commit is contained in:
101
app/services/alerts.py
Normal file
101
app/services/alerts.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Alert evaluation and history persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Mapping
|
||||
|
||||
from app.models.alerts import AlertEvent, AlertHistoryRepository, AlertStatus
|
||||
from app.models.portfolio import PortfolioConfig
|
||||
|
||||
|
||||
def build_portfolio_alert_context(
|
||||
config: PortfolioConfig,
|
||||
*,
|
||||
spot_price: float,
|
||||
source: str,
|
||||
updated_at: str,
|
||||
) -> dict[str, float | str]:
|
||||
gold_units = float(config.gold_ounces or 0.0)
|
||||
live_gold_value = gold_units * spot_price
|
||||
loan_amount = float(config.loan_amount)
|
||||
margin_call_ltv = float(config.margin_threshold)
|
||||
return {
|
||||
"spot_price": float(spot_price),
|
||||
"gold_units": gold_units,
|
||||
"gold_value": live_gold_value,
|
||||
"loan_amount": loan_amount,
|
||||
"ltv_ratio": loan_amount / live_gold_value if live_gold_value > 0 else 0.0,
|
||||
"net_equity": live_gold_value - loan_amount,
|
||||
"margin_call_ltv": margin_call_ltv,
|
||||
"margin_call_price": loan_amount / (margin_call_ltv * gold_units) if gold_units > 0 else 0.0,
|
||||
"quote_source": source,
|
||||
"quote_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 []
|
||||
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", ""))
|
||||
|
||||
if ltv_ratio >= float(config.margin_threshold):
|
||||
severity = "critical"
|
||||
message = (
|
||||
f"Current LTV {ltv_ratio:.1%} is above the critical threshold of "
|
||||
f"{float(config.margin_threshold):.1%}."
|
||||
)
|
||||
elif ltv_ratio >= float(config.ltv_warning):
|
||||
severity = "warning"
|
||||
message = (
|
||||
f"Current LTV {ltv_ratio:.1%} is above the warning threshold of " f"{float(config.ltv_warning):.1%}."
|
||||
)
|
||||
else:
|
||||
severity = "ok"
|
||||
message = "LTV is within configured thresholds."
|
||||
|
||||
preview_history: list[AlertEvent] = []
|
||||
if severity != "ok":
|
||||
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),
|
||||
)
|
||||
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=ltv_ratio,
|
||||
warning_threshold=float(config.ltv_warning),
|
||||
critical_threshold=float(config.margin_threshold),
|
||||
email_alerts_enabled=bool(config.email_alerts),
|
||||
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
|
||||
Reference in New Issue
Block a user