Files
arbitrade/build/lib/arbitrade/alerting/notifier.py
T
zwitschi 1df4b11aef
CI / lint-test-build (push) Failing after 1m7s
Add HTML templates for dashboard, metrics, overview, and backtesting
- Introduced new HTML templates for the dashboard, metrics, overview, and backtesting functionalities.
- Implemented partial templates for metrics, overview, audit, controls, and charts to enhance modularity.
- Updated the Jinja2 template resolution logic to support different deployment environments.
- Added a health check template to display the service status.
- Included a test suite to verify the template resolution logic.
- Updated `pyproject.toml` to include new HTML templates in the package data.
2026-06-02 14:16:42 +02:00

401 lines
13 KiB
Python

from __future__ import annotations
import asyncio
import smtplib
from dataclasses import dataclass
from datetime import UTC, datetime
from email.message import EmailMessage
from typing import Literal, Protocol, runtime_checkable
import httpx
AlertSeverity = Literal["info", "warning", "error", "critical"]
_SEVERITY_RANK: dict[AlertSeverity, int] = {
"info": 10,
"warning": 20,
"error": 30,
"critical": 40,
}
@dataclass(frozen=True, slots=True)
class AlertEvent:
category: str
severity: AlertSeverity
title: str
message: str
occurred_at: datetime
details: dict[str, str]
class AlertChannel(Protocol):
async def send(self, event: AlertEvent) -> None: ...
class SupportsAlerts(Protocol):
async def notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> bool: ...
@runtime_checkable
class SupportsAlertStatus(Protocol):
def status_snapshot(self) -> dict[str, object]: ...
class AlertNotifier:
def __init__(
self,
channels: list[AlertChannel],
*,
enabled: bool = True,
min_severity: AlertSeverity = "info",
dedup_seconds: float = 0.0,
category_flags: dict[str, bool] | None = None,
) -> None:
if dedup_seconds < 0.0:
raise ValueError("dedup_seconds must be >= 0.0")
self._channels = channels
self._enabled = enabled
self._min_severity: AlertSeverity = min_severity
self._dedup_seconds = dedup_seconds
self._category_flags = {key.lower(): value for key, value in (category_flags or {}).items()}
self._last_sent_at: dict[str, datetime] = {}
self._last_result: str = "never"
self._last_attempted_at: datetime | None = None
self._last_success_at: datetime | None = None
self._last_error: str | None = None
self._last_event_title: str | None = None
self._last_event_category: str | None = None
self._last_event_severity: AlertSeverity | None = None
self._last_channel_results: list[str] = []
@property
def has_channels(self) -> bool:
return bool(self._channels)
async def notify(
self,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> bool:
if not self._enabled or not self._channels:
self._last_result = "skipped_disabled" if not self._enabled else "skipped_no_channels"
return False
normalized_category = category.strip().lower()
if self._category_flags and not self._category_flags.get(normalized_category, True):
self._last_result = "skipped_category"
return False
if _SEVERITY_RANK[severity] < _SEVERITY_RANK[self._min_severity]:
self._last_result = "skipped_severity"
return False
dedup_key = f"{normalized_category}|{severity}|{title}|{message}"
now = datetime.now(UTC)
if self._dedup_seconds > 0.0:
previous = self._last_sent_at.get(dedup_key)
if previous is not None:
elapsed = (now - previous).total_seconds()
if elapsed < self._dedup_seconds:
self._last_result = "skipped_dedup"
return False
event = AlertEvent(
category=normalized_category,
severity=severity,
title=title,
message=message,
occurred_at=now,
details=details or {},
)
results = await asyncio.gather(
*(channel.send(event) for channel in self._channels),
return_exceptions=True,
)
self._last_attempted_at = now
self._last_event_title = title
self._last_event_category = normalized_category
self._last_event_severity = severity
self._last_channel_results = []
for channel, result in zip(self._channels, results, strict=False):
channel_name = type(channel).__name__
if isinstance(result, Exception):
self._last_channel_results.append(f"{channel_name}: error")
else:
self._last_channel_results.append(f"{channel_name}: ok")
if all(isinstance(result, Exception) for result in results):
self._last_result = "failed"
self._last_error = "all channels failed"
return False
self._last_result = (
"partial_success"
if any(isinstance(result, Exception) for result in results)
else "success"
)
self._last_error = None
self._last_success_at = now
self._last_sent_at[dedup_key] = now
return True
def status_snapshot(self) -> dict[str, object]:
return {
"enabled": self._enabled,
"has_channels": self.has_channels,
"configured_channels": [type(channel).__name__ for channel in self._channels],
"min_severity": self._min_severity,
"dedup_seconds": self._dedup_seconds,
"last_result": self._last_result,
"last_attempted_at": (
self._last_attempted_at.isoformat() if self._last_attempted_at is not None else None
),
"last_success_at": (
self._last_success_at.isoformat() if self._last_success_at is not None else None
),
"last_error": self._last_error,
"last_event": (
None
if self._last_event_title is None
else {
"title": self._last_event_title,
"category": self._last_event_category,
"severity": self._last_event_severity,
}
),
"last_channel_results": self._last_channel_results,
}
def dispatch_alert_nowait(
notifier: SupportsAlerts | None,
*,
category: str,
severity: AlertSeverity,
title: str,
message: str,
details: dict[str, str] | None = None,
) -> None:
if notifier is None:
return
try:
loop = asyncio.get_running_loop()
except RuntimeError:
return
loop.create_task(
notifier.notify(
category=category,
severity=severity,
title=title,
message=message,
details=details,
)
)
def _format_event_text(event: AlertEvent) -> str:
lines = [
f"[{event.severity.upper()}] {event.title}",
f"Category: {event.category}",
f"Time: {event.occurred_at.isoformat()}",
event.message,
]
if event.details:
lines.append("Details:")
for key, value in sorted(event.details.items()):
lines.append(f"- {key}: {value}")
return "\n".join(lines)
class TelegramChannel:
def __init__(self, *, bot_token: str, chat_id: str, timeout_seconds: float = 10.0) -> None:
self._bot_token = bot_token
self._chat_id = chat_id
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
url = f"https://api.telegram.org/bot{self._bot_token}/sendMessage"
payload = {
"chat_id": self._chat_id,
"text": _format_event_text(event),
"disable_web_page_preview": True,
}
timeout = httpx.Timeout(self._timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(url, json=payload)
response.raise_for_status()
class DiscordWebhookChannel:
def __init__(self, *, webhook_url: str, timeout_seconds: float = 10.0) -> None:
self._webhook_url = webhook_url
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
payload = {"content": _format_event_text(event)}
timeout = httpx.Timeout(self._timeout_seconds)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(self._webhook_url, json=payload)
response.raise_for_status()
class EmailSmtpChannel:
def __init__(
self,
*,
host: str,
port: int,
sender: str,
recipients: list[str],
username: str | None = None,
password: str | None = None,
use_tls: bool = True,
timeout_seconds: float = 10.0,
) -> None:
if not recipients:
raise ValueError("recipients must not be empty")
self._host = host
self._port = port
self._sender = sender
self._recipients = recipients
self._username = username
self._password = password
self._use_tls = use_tls
self._timeout_seconds = timeout_seconds
async def send(self, event: AlertEvent) -> None:
message = EmailMessage()
message["From"] = self._sender
message["To"] = ", ".join(self._recipients)
message["Subject"] = f"[{event.severity.upper()}] {event.title}"
message.set_content(_format_event_text(event))
await asyncio.to_thread(self._send_sync, message)
def _send_sync(self, message: EmailMessage) -> None:
with smtplib.SMTP(self._host, self._port, timeout=self._timeout_seconds) as client:
if self._use_tls:
client.starttls()
if self._username and self._password:
client.login(self._username, self._password)
client.send_message(message)
class _AlertSettings(Protocol):
alerts_enabled: bool
alert_min_severity: str
alert_dedup_seconds: float
alert_on_trade_events: bool
alert_on_error_events: bool
alert_on_threshold_events: bool
alert_on_system_events: bool
telegram_alerts_enabled: bool
telegram_bot_token: str | None
telegram_chat_id: str | None
discord_alerts_enabled: bool
discord_webhook_url: str | None
email_alerts_enabled: bool
email_smtp_host: str | None
email_smtp_port: int
email_smtp_username: str | None
email_smtp_password: str | None
email_alert_from: str | None
email_alert_to: str | None
email_smtp_use_tls: bool
def _as_alert_severity(value: str) -> AlertSeverity:
normalized = value.strip().lower()
if normalized == "info":
return "info"
if normalized == "warning":
return "warning"
if normalized == "error":
return "error"
if normalized == "critical":
return "critical"
else:
raise ValueError("alert_min_severity must be one of: info, warning, error, critical")
def build_channels_from_settings(settings: _AlertSettings) -> list[AlertChannel]:
channels: list[AlertChannel] = []
if settings.telegram_alerts_enabled:
if not settings.telegram_bot_token or not settings.telegram_chat_id:
raise ValueError("telegram alerts require bot token and chat id")
channels.append(
TelegramChannel(
bot_token=settings.telegram_bot_token,
chat_id=settings.telegram_chat_id,
)
)
if settings.discord_alerts_enabled:
if not settings.discord_webhook_url:
raise ValueError("discord alerts require webhook url")
channels.append(DiscordWebhookChannel(webhook_url=settings.discord_webhook_url))
if settings.email_alerts_enabled:
if not settings.email_smtp_host:
raise ValueError("email alerts require SMTP host")
if not settings.email_alert_from:
raise ValueError("email alerts require sender address")
if not settings.email_alert_to:
raise ValueError("email alerts require recipient list")
recipients = [
address.strip() for address in settings.email_alert_to.split(",") if address.strip()
]
channels.append(
EmailSmtpChannel(
host=settings.email_smtp_host,
port=settings.email_smtp_port,
sender=settings.email_alert_from,
recipients=recipients,
username=settings.email_smtp_username,
password=settings.email_smtp_password,
use_tls=settings.email_smtp_use_tls,
)
)
return channels
def build_notifier_from_settings(settings: _AlertSettings) -> AlertNotifier:
severity = _as_alert_severity(settings.alert_min_severity)
channels = build_channels_from_settings(settings)
category_flags = {
"trade": settings.alert_on_trade_events,
"error": settings.alert_on_error_events,
"threshold": settings.alert_on_threshold_events,
"system": settings.alert_on_system_events,
}
return AlertNotifier(
channels,
enabled=settings.alerts_enabled,
min_severity=severity,
dedup_seconds=settings.alert_dedup_seconds,
category_flags=category_flags,
)