Skip to content

Commit 625f26c

Browse files
authored
Merge pull request #47 from QuantStrategyLab/qsl/plugin-email-alert-publisher
Add strategy plugin email alert publisher
2 parents d3e9582 + ba67541 commit 625f26c

8 files changed

Lines changed: 618 additions & 9 deletions

File tree

docs/strategy_plugin_runtime_contract.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,10 @@ when any of the following is true:
107107
- `suggested_action` is `defend` or `blocked`
108108
- `would_trade_if_enabled` is `true`
109109

110-
Platforms may still choose their delivery sinks, but should use
111-
`build_strategy_plugin_alert_messages()` for the subject/body and
112-
`quant_platform_kit.notifications.email.send_smtp_email()` when SMTP email is
113-
configured. This keeps the Crisis Response plugin behavior consistent across
114-
IBKR, Schwab, LongBridge, Firstrade, and future platform runtimes.
110+
Platforms may still choose their delivery sinks, but SMTP escalation should use
111+
`quant_platform_kit.notifications.strategy_plugin_email.publish_strategy_plugin_email_alerts()`.
112+
The publisher builds the shared subject/body, prefixes platform context, returns
113+
structured sent/skipped/failed diagnostics, and can use
114+
`StrategyPluginEmailAlertMarkerStore` to skip alert keys that were already sent.
115+
This keeps the Crisis Response plugin behavior consistent across IBKR, Schwab,
116+
LongBridge, Firstrade, and future platform runtimes.

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.24"
7+
version = "0.7.25"
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/common/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
StrategyPluginAlertMessage,
4747
StrategyPluginMountConfig,
4848
StrategyPluginSignal,
49+
build_strategy_plugin_alert_key,
4950
build_strategy_plugin_alert_messages,
5051
build_strategy_plugin_notification_lines,
5152
build_strategy_plugin_report_payload,
@@ -94,6 +95,7 @@
9495
"StrategyPluginAlertMessage",
9596
"StrategyPluginMountConfig",
9697
"StrategyPluginSignal",
98+
"build_strategy_plugin_alert_key",
9799
"build_strategy_plugin_alert_messages",
98100
"build_strategy_plugin_notification_lines",
99101
"build_strategy_plugin_report_payload",

src/quant_platform_kit/common/strategy_plugins.py

Lines changed: 103 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import json
77
import tempfile
88
from collections.abc import Mapping, Sequence
9-
from dataclasses import dataclass
9+
from dataclasses import dataclass, field
1010
from pathlib import Path
1111
from typing import Any, Callable
1212

@@ -65,6 +65,8 @@ def report_summary(self) -> dict[str, Any]:
6565
class StrategyPluginAlertMessage:
6666
subject: str
6767
body: str
68+
alert_key: str = ""
69+
metadata: Mapping[str, Any] = field(default_factory=dict)
6870

6971

7072
def normalize_strategy_plugin_mode(value: Any, *, field_name: str = "mode") -> str:
@@ -284,13 +286,51 @@ def should_alert_strategy_plugin_signal(signal: StrategyPluginSignal) -> bool:
284286
)
285287

286288

289+
def build_strategy_plugin_alert_key(
290+
signal: StrategyPluginSignal,
291+
*,
292+
strategy_label: str | None = None,
293+
context_label: str | None = None,
294+
namespace: str = "strategy_plugin_alert",
295+
) -> str:
296+
payload = {
297+
"namespace": _optional_key_part(namespace) or "strategy_plugin_alert",
298+
"context": _optional_key_part(context_label) or "default",
299+
"strategy": _optional_key_part(getattr(signal, "strategy", None)) or _optional_key_part(strategy_label) or "unknown",
300+
"plugin": _optional_key_part(getattr(signal, "plugin", None)) or "unknown",
301+
"mode": _optional_key_part(getattr(signal, "effective_mode", None)) or "unknown",
302+
"as_of": _optional_key_part(getattr(signal, "as_of", None)) or "unknown",
303+
"route": _optional_key_part(getattr(signal, "canonical_route", None)) or "unknown",
304+
"action": _optional_key_part(getattr(signal, "suggested_action", None)) or "unknown",
305+
"would_trade_if_enabled": bool(getattr(signal, "would_trade_if_enabled", False)),
306+
}
307+
digest = hashlib.sha256(
308+
json.dumps(payload, ensure_ascii=False, sort_keys=True).encode("utf-8")
309+
).hexdigest()[:16]
310+
return "/".join(
311+
(
312+
_sanitize_key_part(payload["namespace"]),
313+
_sanitize_key_part(payload["context"]),
314+
_sanitize_key_part(payload["strategy"]),
315+
_sanitize_key_part(payload["plugin"]),
316+
_sanitize_key_part(payload["as_of"]),
317+
_sanitize_key_part(payload["route"]),
318+
_sanitize_key_part(payload["action"]),
319+
digest,
320+
)
321+
)
322+
323+
287324
def build_strategy_plugin_alert_messages(
288325
signals: Sequence[StrategyPluginSignal],
289326
*,
290327
translator: Callable[..., str] | None = None,
291328
strategy_label: str | None = None,
329+
context_label: str | None = None,
330+
alert_namespace: str = "strategy_plugin_alert",
292331
) -> tuple[StrategyPluginAlertMessage, ...]:
293332
messages: list[StrategyPluginAlertMessage] = []
333+
context = str(context_label or "").strip()
294334
for signal in signals:
295335
if not should_alert_strategy_plugin_signal(signal):
296336
continue
@@ -308,8 +348,22 @@ def build_strategy_plugin_alert_messages(
308348
plugin=plugin,
309349
route=translated_route,
310350
)
351+
if context:
352+
subject = f"[{context}] {subject}"
311353
body_lines = [
312354
_translate(translator, "strategy_plugin_alert_title", fallback="Strategy Plugin Alert"),
355+
]
356+
if context:
357+
body_lines.append(
358+
_translate(
359+
translator,
360+
"strategy_plugin_alert_context",
361+
fallback="Context: {context}",
362+
context=context,
363+
)
364+
)
365+
body_lines.extend(
366+
[
313367
_translate(
314368
translator,
315369
"strategy_plugin_line",
@@ -337,7 +391,8 @@ def build_strategy_plugin_alert_messages(
337391
fallback="Would trade if enabled: {value}",
338392
value=str(bool(getattr(signal, "would_trade_if_enabled", False))).lower(),
339393
),
340-
]
394+
]
395+
)
341396
source = getattr(signal, "source_uri", None) or getattr(signal, "local_path", None)
342397
if source:
343398
body_lines.append(
@@ -348,10 +403,55 @@ def build_strategy_plugin_alert_messages(
348403
source=source,
349404
)
350405
)
351-
messages.append(StrategyPluginAlertMessage(subject=subject, body="\n".join(body_lines)))
406+
metadata = {
407+
"strategy": getattr(signal, "strategy", None),
408+
"strategy_label": strategy,
409+
"plugin": getattr(signal, "plugin", None),
410+
"mode": getattr(signal, "effective_mode", None),
411+
"as_of": getattr(signal, "as_of", None),
412+
"canonical_route": getattr(signal, "canonical_route", None),
413+
"suggested_action": getattr(signal, "suggested_action", None),
414+
"would_trade_if_enabled": bool(getattr(signal, "would_trade_if_enabled", False)),
415+
"context_label": context or None,
416+
}
417+
messages.append(
418+
StrategyPluginAlertMessage(
419+
subject=subject,
420+
body="\n".join(body_lines),
421+
alert_key=build_strategy_plugin_alert_key(
422+
signal,
423+
strategy_label=strategy,
424+
context_label=context,
425+
namespace=alert_namespace,
426+
),
427+
metadata=metadata,
428+
)
429+
)
352430
return tuple(messages)
353431

354432

433+
def _optional_key_part(value: Any) -> str | None:
434+
if value is None:
435+
return None
436+
text = str(value).strip()
437+
return text or None
438+
439+
440+
def _sanitize_key_part(value: Any) -> str:
441+
text = str(value or "").strip().lower()
442+
chars: list[str] = []
443+
previous_dash = False
444+
for char in text:
445+
if char.isalnum() or char in {"_", "."}:
446+
chars.append(char)
447+
previous_dash = False
448+
elif not previous_dash:
449+
chars.append("-")
450+
previous_dash = True
451+
sanitized = "".join(chars).strip("-._")
452+
return sanitized[:80] or "unknown"
453+
454+
355455
def _materialize_artifact_path(reference: str, *, client_factory: Any = None) -> tuple[Path, dict[str, str | None]]:
356456
raw_reference = _required_string(reference, field_name="reference")
357457
if not raw_reference.startswith("gs://"):

src/quant_platform_kit/notifications/__init__.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,25 @@
22

33
from .email import parse_email_recipients, send_smtp_email
44
from .events import NotificationPublisher, RenderedNotification, publish_rendered_notification
5+
from .strategy_plugin_email import (
6+
StrategyPluginEmailAlertDelivery,
7+
StrategyPluginEmailAlertMarkerStore,
8+
StrategyPluginEmailAlertPublishResult,
9+
StrategyPluginEmailSettings,
10+
build_strategy_plugin_alert_context_label,
11+
publish_strategy_plugin_email_alerts,
12+
)
513

614
__all__ = [
715
"NotificationPublisher",
816
"RenderedNotification",
17+
"StrategyPluginEmailAlertDelivery",
18+
"StrategyPluginEmailAlertMarkerStore",
19+
"StrategyPluginEmailAlertPublishResult",
20+
"StrategyPluginEmailSettings",
21+
"build_strategy_plugin_alert_context_label",
922
"parse_email_recipients",
1023
"publish_rendered_notification",
24+
"publish_strategy_plugin_email_alerts",
1125
"send_smtp_email",
1226
]

0 commit comments

Comments
 (0)