Skip to content

Commit 3a96a07

Browse files
committed
Add strategy plugin alert dispatcher
1 parent d438001 commit 3a96a07

4 files changed

Lines changed: 422 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "quant-platform-kit"
7-
version = "0.7.25"
7+
version = "0.7.26"
88
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
99
readme = "README.md"
1010
requires-python = ">=3.9"

src/quant_platform_kit/notifications/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,12 @@
33
from .email import parse_email_recipients, send_smtp_email
44
from .events import NotificationPublisher, RenderedNotification, publish_rendered_notification
55
from .sms import normalize_sms_recipient, parse_sms_recipients, send_twilio_sms
6+
from .strategy_plugin_alerts import (
7+
StrategyPluginAlertChannelStores,
8+
StrategyPluginAlertPublishResult,
9+
StrategyPluginAlertStateSettings,
10+
publish_strategy_plugin_alerts,
11+
)
612
from .strategy_plugin_email import (
713
StrategyPluginEmailAlertDelivery,
814
StrategyPluginEmailAlertMarkerStore,
@@ -22,6 +28,9 @@
2228
__all__ = [
2329
"NotificationPublisher",
2430
"RenderedNotification",
31+
"StrategyPluginAlertChannelStores",
32+
"StrategyPluginAlertPublishResult",
33+
"StrategyPluginAlertStateSettings",
2534
"StrategyPluginEmailAlertDelivery",
2635
"StrategyPluginEmailAlertMarkerStore",
2736
"StrategyPluginEmailAlertPublishResult",
@@ -35,6 +44,7 @@
3544
"parse_email_recipients",
3645
"parse_sms_recipients",
3746
"publish_rendered_notification",
47+
"publish_strategy_plugin_alerts",
3848
"publish_strategy_plugin_email_alerts",
3949
"publish_strategy_plugin_sms_alerts",
4050
"send_smtp_email",
Lines changed: 235 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,235 @@
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

Comments
 (0)