feat(PORT-002): add alert status and history

This commit is contained in:
Bu5hm4nn
2026-03-24 11:04:32 +01:00
parent 7c6b8ef2c6
commit d0b1304b71
9 changed files with 525 additions and 59 deletions

101
app/services/alerts.py Normal file
View 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

View File

@@ -0,0 +1,27 @@
from __future__ import annotations
from typing import Protocol
from app.models.portfolio import PortfolioConfig
class _SaveStatusConfig(Protocol):
entry_basis_mode: str
gold_value: float | None
entry_price: float | None
gold_ounces: float | None
current_ltv: float
margin_call_price: object
def margin_call_price_value(config: PortfolioConfig | _SaveStatusConfig) -> float:
margin_call_price = config.margin_call_price
return float(margin_call_price() if callable(margin_call_price) else margin_call_price)
def save_status_text(config: PortfolioConfig | _SaveStatusConfig) -> str:
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"
)