|
| 1 | +"""Channel dispatcher for strategy plugin alerts.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import os |
| 6 | +from collections.abc import Callable, Mapping, Sequence |
| 7 | +from dataclasses import dataclass |
| 8 | +from pathlib import Path |
| 9 | +from typing import Any |
| 10 | + |
| 11 | +from .email import send_smtp_email |
| 12 | +from .sms import send_twilio_sms |
| 13 | +from .strategy_plugin_email import ( |
| 14 | + StrategyPluginEmailAlertMarkerStore, |
| 15 | + StrategyPluginEmailAlertPublishResult, |
| 16 | + StrategyPluginEmailSettings, |
| 17 | + build_strategy_plugin_alert_context_label, |
| 18 | + publish_strategy_plugin_email_alerts, |
| 19 | +) |
| 20 | +from .strategy_plugin_sms import ( |
| 21 | + StrategyPluginSmsAlertMarkerStore, |
| 22 | + StrategyPluginSmsAlertPublishResult, |
| 23 | + StrategyPluginSmsSettings, |
| 24 | + publish_strategy_plugin_sms_alerts, |
| 25 | +) |
| 26 | + |
| 27 | +_DEFAULT_ALERT_STATE_DIR = "/tmp/quant_strategy_plugin_alerts" |
| 28 | +_CHANNEL_EMAIL = "email" |
| 29 | +_CHANNEL_SMS = "sms" |
| 30 | +_SUPPORTED_CHANNELS = frozenset({_CHANNEL_EMAIL, _CHANNEL_SMS}) |
| 31 | + |
| 32 | + |
| 33 | +@dataclass(frozen=True) |
| 34 | +class StrategyPluginAlertChannelStores: |
| 35 | + """Marker stores used by each alert channel.""" |
| 36 | + |
| 37 | + email: StrategyPluginEmailAlertMarkerStore | object | None = None |
| 38 | + sms: StrategyPluginSmsAlertMarkerStore | object | None = None |
| 39 | + |
| 40 | + @classmethod |
| 41 | + def from_mapping( |
| 42 | + cls, |
| 43 | + value: Mapping[str, object | None] | None, |
| 44 | + ) -> "StrategyPluginAlertChannelStores": |
| 45 | + if value is None: |
| 46 | + return cls() |
| 47 | + return cls(email=value.get(_CHANNEL_EMAIL), sms=value.get(_CHANNEL_SMS)) |
| 48 | + |
| 49 | + |
| 50 | +@dataclass(frozen=True) |
| 51 | +class StrategyPluginAlertStateSettings: |
| 52 | + """Shared marker-store location for strategy plugin alert channels.""" |
| 53 | + |
| 54 | + local_dir: str | Path | None = _DEFAULT_ALERT_STATE_DIR |
| 55 | + gcs_prefix_uri: str | None = None |
| 56 | + gcp_project_id: str | None = None |
| 57 | + client_factory: Any = None |
| 58 | + |
| 59 | + @classmethod |
| 60 | + def from_env( |
| 61 | + cls, |
| 62 | + *, |
| 63 | + env_reader: Callable[[str, str | None], str | None] = os.getenv, |
| 64 | + gcp_project_id: str | None = None, |
| 65 | + fallback_gcs_prefix_uri: str | None = None, |
| 66 | + default_local_dir: str | Path | None = _DEFAULT_ALERT_STATE_DIR, |
| 67 | + ) -> "StrategyPluginAlertStateSettings": |
| 68 | + explicit_gcs_uri = env_reader("STRATEGY_PLUGIN_ALERT_STATE_GCS_URI", None) |
| 69 | + report_gcs_uri = env_reader("EXECUTION_REPORT_GCS_URI", None) |
| 70 | + local_dir = env_reader("STRATEGY_PLUGIN_ALERT_STATE_DIR", None) |
| 71 | + return cls( |
| 72 | + local_dir=local_dir or default_local_dir, |
| 73 | + gcs_prefix_uri=explicit_gcs_uri or report_gcs_uri or fallback_gcs_prefix_uri, |
| 74 | + gcp_project_id=gcp_project_id, |
| 75 | + ) |
| 76 | + |
| 77 | + def build_channel_stores(self) -> StrategyPluginAlertChannelStores: |
| 78 | + return StrategyPluginAlertChannelStores( |
| 79 | + email=StrategyPluginEmailAlertMarkerStore( |
| 80 | + local_dir=self.local_dir, |
| 81 | + gcs_prefix_uri=self.gcs_prefix_uri, |
| 82 | + gcp_project_id=self.gcp_project_id, |
| 83 | + client_factory=self.client_factory, |
| 84 | + ), |
| 85 | + sms=StrategyPluginSmsAlertMarkerStore( |
| 86 | + local_dir=self.local_dir, |
| 87 | + gcs_prefix_uri=self.gcs_prefix_uri, |
| 88 | + gcp_project_id=self.gcp_project_id, |
| 89 | + client_factory=self.client_factory, |
| 90 | + ), |
| 91 | + ) |
| 92 | + |
| 93 | + |
| 94 | +@dataclass(frozen=True) |
| 95 | +class StrategyPluginAlertPublishResult: |
| 96 | + """Combined delivery result across strategy plugin alert channels.""" |
| 97 | + |
| 98 | + email_result: StrategyPluginEmailAlertPublishResult | None = None |
| 99 | + sms_result: StrategyPluginSmsAlertPublishResult | None = None |
| 100 | + |
| 101 | + @property |
| 102 | + def attempted_count(self) -> int: |
| 103 | + return sum(result.attempted_count for result in self._results()) |
| 104 | + |
| 105 | + @property |
| 106 | + def sent_count(self) -> int: |
| 107 | + return sum(result.sent_count for result in self._results()) |
| 108 | + |
| 109 | + @property |
| 110 | + def skipped_count(self) -> int: |
| 111 | + return sum(result.skipped_count for result in self._results()) |
| 112 | + |
| 113 | + @property |
| 114 | + def failed_count(self) -> int: |
| 115 | + return sum(result.failed_count for result in self._results()) |
| 116 | + |
| 117 | + def to_report_fields(self) -> dict[str, Any]: |
| 118 | + fields: dict[str, Any] = { |
| 119 | + "strategy_plugin_alert_attempted_count": self.attempted_count, |
| 120 | + "strategy_plugin_alert_sent_count": self.sent_count, |
| 121 | + "strategy_plugin_alert_skipped_count": self.skipped_count, |
| 122 | + "strategy_plugin_alert_failed_count": self.failed_count, |
| 123 | + } |
| 124 | + if self.email_result is not None: |
| 125 | + fields.update(self.email_result.to_report_fields()) |
| 126 | + if self.sms_result is not None: |
| 127 | + fields.update(self.sms_result.to_report_fields()) |
| 128 | + return fields |
| 129 | + |
| 130 | + def to_summary_fields(self) -> dict[str, int]: |
| 131 | + fields = { |
| 132 | + "strategy_plugin_alert_sent_count": self.sent_count, |
| 133 | + } |
| 134 | + if self.email_result is not None: |
| 135 | + fields["strategy_plugin_alert_email_sent_count"] = self.email_result.sent_count |
| 136 | + if self.sms_result is not None: |
| 137 | + fields["strategy_plugin_alert_sms_sent_count"] = self.sms_result.sent_count |
| 138 | + return fields |
| 139 | + |
| 140 | + def attach_to_report(self, report: dict[str, Any]) -> None: |
| 141 | + report.setdefault("summary", {}).update(self.to_summary_fields()) |
| 142 | + report.setdefault("diagnostics", {}).update(self.to_report_fields()) |
| 143 | + |
| 144 | + def _results( |
| 145 | + self, |
| 146 | + ) -> tuple[StrategyPluginEmailAlertPublishResult | StrategyPluginSmsAlertPublishResult, ...]: |
| 147 | + return tuple( |
| 148 | + result |
| 149 | + for result in (self.email_result, self.sms_result) |
| 150 | + if result is not None |
| 151 | + ) |
| 152 | + |
| 153 | + |
| 154 | +def publish_strategy_plugin_alerts( |
| 155 | + signals: Sequence[object], |
| 156 | + *, |
| 157 | + notification_settings: StrategyPluginEmailSettings | StrategyPluginSmsSettings | object, |
| 158 | + translator: Callable[..., str] | None = None, |
| 159 | + strategy_label: str | None = None, |
| 160 | + context_label: str | None = None, |
| 161 | + channels: Sequence[str] | str = (_CHANNEL_EMAIL, _CHANNEL_SMS), |
| 162 | + state_settings: StrategyPluginAlertStateSettings | None = None, |
| 163 | + alert_stores: StrategyPluginAlertChannelStores | Mapping[str, object | None] | None = None, |
| 164 | + send_email_notification: Callable[..., bool] = send_smtp_email, |
| 165 | + send_sms_notification: Callable[..., bool] = send_twilio_sms, |
| 166 | + log_message: Callable[..., Any] = print, |
| 167 | +) -> StrategyPluginAlertPublishResult: |
| 168 | + """Publish strategy plugin alerts through the configured notification channels.""" |
| 169 | + |
| 170 | + selected_channels = _normalize_channels(channels) |
| 171 | + stores = _resolve_alert_stores(alert_stores=alert_stores, state_settings=state_settings) |
| 172 | + email_result = None |
| 173 | + sms_result = None |
| 174 | + if _CHANNEL_EMAIL in selected_channels: |
| 175 | + email_result = publish_strategy_plugin_email_alerts( |
| 176 | + signals, |
| 177 | + email_settings=notification_settings, |
| 178 | + translator=translator, |
| 179 | + strategy_label=strategy_label, |
| 180 | + context_label=context_label, |
| 181 | + alert_store=stores.email, |
| 182 | + send_notification=send_email_notification, |
| 183 | + log_message=log_message, |
| 184 | + ) |
| 185 | + if _CHANNEL_SMS in selected_channels: |
| 186 | + sms_result = publish_strategy_plugin_sms_alerts( |
| 187 | + signals, |
| 188 | + sms_settings=notification_settings, |
| 189 | + translator=translator, |
| 190 | + strategy_label=strategy_label, |
| 191 | + context_label=context_label, |
| 192 | + alert_store=stores.sms, |
| 193 | + send_notification=send_sms_notification, |
| 194 | + log_message=log_message, |
| 195 | + ) |
| 196 | + return StrategyPluginAlertPublishResult( |
| 197 | + email_result=email_result, |
| 198 | + sms_result=sms_result, |
| 199 | + ) |
| 200 | + |
| 201 | + |
| 202 | +def _resolve_alert_stores( |
| 203 | + *, |
| 204 | + alert_stores: StrategyPluginAlertChannelStores | Mapping[str, object | None] | None, |
| 205 | + state_settings: StrategyPluginAlertStateSettings | None, |
| 206 | +) -> StrategyPluginAlertChannelStores: |
| 207 | + if isinstance(alert_stores, StrategyPluginAlertChannelStores): |
| 208 | + return alert_stores |
| 209 | + if isinstance(alert_stores, Mapping): |
| 210 | + return StrategyPluginAlertChannelStores.from_mapping(alert_stores) |
| 211 | + return (state_settings or StrategyPluginAlertStateSettings.from_env()).build_channel_stores() |
| 212 | + |
| 213 | + |
| 214 | +def _normalize_channels(channels: Sequence[str] | str) -> tuple[str, ...]: |
| 215 | + raw_channels = (channels,) if isinstance(channels, str) else tuple(channels) |
| 216 | + normalized: list[str] = [] |
| 217 | + for channel in raw_channels: |
| 218 | + name = str(channel or "").strip().lower() |
| 219 | + if not name: |
| 220 | + continue |
| 221 | + if name not in _SUPPORTED_CHANNELS: |
| 222 | + supported = ", ".join(sorted(_SUPPORTED_CHANNELS)) |
| 223 | + raise ValueError(f"unsupported strategy plugin alert channel {name!r}; expected one of: {supported}") |
| 224 | + if name not in normalized: |
| 225 | + normalized.append(name) |
| 226 | + return tuple(normalized) |
| 227 | + |
| 228 | + |
| 229 | +__all__ = [ |
| 230 | + "StrategyPluginAlertChannelStores", |
| 231 | + "StrategyPluginAlertPublishResult", |
| 232 | + "StrategyPluginAlertStateSettings", |
| 233 | + "build_strategy_plugin_alert_context_label", |
| 234 | + "publish_strategy_plugin_alerts", |
| 235 | +] |
0 commit comments