1df4b11aef
CI / lint-test-build (push) Failing after 1m7s
- 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.
401 lines
13 KiB
Python
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,
|
|
)
|