Skip to content

Commit d3e9582

Browse files
authored
Add shared strategy plugin alert helpers
Add shared strategy-plugin alert policy, alert message builders, and SMTP email helper for platform-neutral crisis plugin notifications.
1 parent 2757d70 commit d3e9582

8 files changed

Lines changed: 362 additions & 2 deletions

File tree

docs/strategy_plugin_runtime_contract.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ Use `quant_platform_kit.common.strategy_plugins`:
6363

6464
```python
6565
from quant_platform_kit.common.strategy_plugins import (
66+
build_strategy_plugin_alert_messages,
67+
build_strategy_plugin_notification_lines,
6668
build_strategy_plugin_report_payload,
6769
load_configured_strategy_plugin_signals,
6870
parse_strategy_plugin_mounts,
@@ -74,6 +76,8 @@ signals = load_configured_strategy_plugin_signals(
7476
strategy_profile=current_strategy_profile,
7577
)
7678
report_section = build_strategy_plugin_report_payload(signals)
79+
notification_lines = build_strategy_plugin_notification_lines(signals)
80+
alert_messages = build_strategy_plugin_alert_messages(signals)
7781
```
7882

7983
The loader validates:
@@ -93,3 +97,18 @@ notification context.
9397
`paper`, `advisory`, and `live` plugin modes are not supported by the shared
9498
contract. Platforms should not maintain plugin ledgers or execute plugin-driven
9599
allocation changes from this sidecar path.
100+
101+
## Escalated Alerts
102+
103+
The shared kit owns the platform-neutral alert policy. A plugin signal escalates
104+
when any of the following is true:
105+
106+
- `canonical_route` is not `no_action`
107+
- `suggested_action` is `defend` or `blocked`
108+
- `would_trade_if_enabled` is `true`
109+
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.

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.23"
7+
version = "0.7.24"
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: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,14 +40,21 @@
4040
)
4141
from .strategy_plugins import (
4242
PLUGIN_MODE_SHADOW,
43+
STRATEGY_PLUGIN_ALERT_ACTIONS,
44+
STRATEGY_PLUGIN_NON_ALERT_ROUTES,
4345
SUPPORTED_STRATEGY_PLUGIN_MODES,
46+
StrategyPluginAlertMessage,
4447
StrategyPluginMountConfig,
4548
StrategyPluginSignal,
49+
build_strategy_plugin_alert_messages,
50+
build_strategy_plugin_notification_lines,
4651
build_strategy_plugin_report_payload,
4752
load_configured_strategy_plugin_signals,
4853
load_strategy_plugin_signal,
4954
normalize_strategy_plugin_mode,
5055
parse_strategy_plugin_mounts,
56+
should_alert_strategy_plugin_signal,
57+
translate_strategy_plugin_value,
5158
validate_strategy_plugin_signal_payload,
5259
)
5360

@@ -66,6 +73,8 @@
6673
"STAGE_PARTIAL_SUBMITTED",
6774
"STAGE_RECONCILED",
6875
"STAGE_SUBMITTED",
76+
"STRATEGY_PLUGIN_ALERT_ACTIONS",
77+
"STRATEGY_PLUGIN_NON_ALERT_ROUTES",
6978
"SUPPORTED_STRATEGY_PLUGIN_MODES",
7079
"filter_execution_blocking_skips",
7180
"is_terminal_funding_block",
@@ -82,15 +91,20 @@
8291
"RuntimeLogContext",
8392
"RuntimeAssembly",
8493
"build_runtime_assembly",
94+
"StrategyPluginAlertMessage",
8595
"StrategyPluginMountConfig",
8696
"StrategyPluginSignal",
97+
"build_strategy_plugin_alert_messages",
98+
"build_strategy_plugin_notification_lines",
8799
"build_strategy_plugin_report_payload",
88100
"build_runtime_target",
89101
"load_configured_strategy_plugin_signals",
90102
"load_strategy_plugin_signal",
91103
"normalize_strategy_plugin_mode",
92104
"parse_strategy_plugin_mounts",
93105
"resolve_runtime_target_from_env",
106+
"should_alert_strategy_plugin_signal",
107+
"translate_strategy_plugin_value",
94108
"translator_uses_zh",
95109
"validate_strategy_plugin_signal_payload",
96110
]

src/quant_platform_kit/common/strategy_plugins.py

Lines changed: 141 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
from collections.abc import Mapping, Sequence
99
from dataclasses import dataclass
1010
from pathlib import Path
11-
from typing import Any
11+
from typing import Any, Callable
1212

1313
PLUGIN_MODE_SHADOW = "shadow"
1414
SUPPORTED_STRATEGY_PLUGIN_MODES = frozenset({PLUGIN_MODE_SHADOW})
1515
DEFAULT_PLUGIN_ARTIFACT_CACHE_DIR = Path(tempfile.gettempdir()) / "quant_strategy_plugin_artifacts"
16+
STRATEGY_PLUGIN_NON_ALERT_ROUTES = frozenset({"no_action"})
17+
STRATEGY_PLUGIN_ALERT_ACTIONS = frozenset({"defend", "blocked"})
1618

1719

1820
@dataclass(frozen=True)
@@ -59,6 +61,12 @@ def report_summary(self) -> dict[str, Any]:
5961
}
6062

6163

64+
@dataclass(frozen=True)
65+
class StrategyPluginAlertMessage:
66+
subject: str
67+
body: str
68+
69+
6270
def normalize_strategy_plugin_mode(value: Any, *, field_name: str = "mode") -> str:
6371
mode = str(value or "").strip().lower()
6472
if mode not in SUPPORTED_STRATEGY_PLUGIN_MODES:
@@ -229,6 +237,121 @@ def build_strategy_plugin_report_payload(signals: Sequence[StrategyPluginSignal]
229237
}
230238

231239

240+
def translate_strategy_plugin_value(
241+
category: str,
242+
raw_value: str | None,
243+
*,
244+
translator: Callable[..., str] | None = None,
245+
) -> str:
246+
value = str(raw_value or "").strip() or "unknown"
247+
if translator is None:
248+
return value
249+
key = f"strategy_plugin_{category}_{value}"
250+
translated = translator(key)
251+
return translated if translated != key else value
252+
253+
254+
def build_strategy_plugin_notification_lines(
255+
signals: Sequence[StrategyPluginSignal],
256+
*,
257+
translator: Callable[..., str] | None = None,
258+
) -> tuple[str, ...]:
259+
lines: list[str] = []
260+
for signal in signals:
261+
route = getattr(signal, "canonical_route", None) or "unknown_route"
262+
action = getattr(signal, "suggested_action", None) or "unknown_action"
263+
lines.append(
264+
_translate(
265+
translator,
266+
"strategy_plugin_line",
267+
fallback="Plugin: {plugin} | status: {route} | notice: {action}",
268+
plugin=translate_strategy_plugin_value("name", getattr(signal, "plugin", None), translator=translator),
269+
mode=translate_strategy_plugin_value("mode", getattr(signal, "effective_mode", None), translator=translator),
270+
route=translate_strategy_plugin_value("route", route, translator=translator),
271+
action=translate_strategy_plugin_value("action", action, translator=translator),
272+
)
273+
)
274+
return tuple(lines)
275+
276+
277+
def should_alert_strategy_plugin_signal(signal: StrategyPluginSignal) -> bool:
278+
route = _normalize_strategy_plugin_field(getattr(signal, "canonical_route", None))
279+
action = _normalize_strategy_plugin_field(getattr(signal, "suggested_action", None))
280+
return (
281+
bool(getattr(signal, "would_trade_if_enabled", False))
282+
or route not in STRATEGY_PLUGIN_NON_ALERT_ROUTES
283+
or action in STRATEGY_PLUGIN_ALERT_ACTIONS
284+
)
285+
286+
287+
def build_strategy_plugin_alert_messages(
288+
signals: Sequence[StrategyPluginSignal],
289+
*,
290+
translator: Callable[..., str] | None = None,
291+
strategy_label: str | None = None,
292+
) -> tuple[StrategyPluginAlertMessage, ...]:
293+
messages: list[StrategyPluginAlertMessage] = []
294+
for signal in signals:
295+
if not should_alert_strategy_plugin_signal(signal):
296+
continue
297+
route = getattr(signal, "canonical_route", None) or "unknown_route"
298+
action = getattr(signal, "suggested_action", None) or "unknown_action"
299+
plugin = translate_strategy_plugin_value("name", getattr(signal, "plugin", None), translator=translator)
300+
translated_route = translate_strategy_plugin_value("route", route, translator=translator)
301+
translated_action = translate_strategy_plugin_value("action", action, translator=translator)
302+
strategy = str(strategy_label or getattr(signal, "strategy", None) or "").strip() or "unknown"
303+
subject = _translate(
304+
translator,
305+
"strategy_plugin_alert_subject",
306+
fallback="Strategy plugin alert: {plugin} | {route}",
307+
strategy=strategy,
308+
plugin=plugin,
309+
route=translated_route,
310+
)
311+
body_lines = [
312+
_translate(translator, "strategy_plugin_alert_title", fallback="Strategy Plugin Alert"),
313+
_translate(
314+
translator,
315+
"strategy_plugin_line",
316+
fallback="Plugin: {plugin} | status: {route} | notice: {action}",
317+
plugin=plugin,
318+
mode=translate_strategy_plugin_value("mode", getattr(signal, "effective_mode", None), translator=translator),
319+
route=translated_route,
320+
action=translated_action,
321+
),
322+
_translate(
323+
translator,
324+
"strategy_plugin_alert_strategy",
325+
fallback="Strategy: {strategy}",
326+
strategy=strategy,
327+
),
328+
_translate(
329+
translator,
330+
"strategy_plugin_alert_as_of",
331+
fallback="Signal as-of: {as_of}",
332+
as_of=getattr(signal, "as_of", None) or "unknown",
333+
),
334+
_translate(
335+
translator,
336+
"strategy_plugin_alert_would_trade",
337+
fallback="Would trade if enabled: {value}",
338+
value=str(bool(getattr(signal, "would_trade_if_enabled", False))).lower(),
339+
),
340+
]
341+
source = getattr(signal, "source_uri", None) or getattr(signal, "local_path", None)
342+
if source:
343+
body_lines.append(
344+
_translate(
345+
translator,
346+
"strategy_plugin_alert_source",
347+
fallback="Source: {source}",
348+
source=source,
349+
)
350+
)
351+
messages.append(StrategyPluginAlertMessage(subject=subject, body="\n".join(body_lines)))
352+
return tuple(messages)
353+
354+
232355
def _materialize_artifact_path(reference: str, *, client_factory: Any = None) -> tuple[Path, dict[str, str | None]]:
233356
raw_reference = _required_string(reference, field_name="reference")
234357
if not raw_reference.startswith("gs://"):
@@ -283,6 +406,23 @@ def _optional_string(value: Any) -> str | None:
283406
return text or None
284407

285408

409+
def _normalize_strategy_plugin_field(value: str | None) -> str:
410+
return str(value or "").strip().lower() or "unknown"
411+
412+
413+
def _translate(
414+
translator: Callable[..., str] | None,
415+
key: str,
416+
*,
417+
fallback: str,
418+
**kwargs: Any,
419+
) -> str:
420+
if translator is None:
421+
return fallback.format(**kwargs)
422+
translated = translator(key, **kwargs)
423+
return translated if translated != key else fallback.format(**kwargs)
424+
425+
286426
def _required_string(value: Any, *, field_name: str) -> str:
287427
text = _optional_string(value)
288428
if text is None:
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
"""Notification integrations."""
22

3+
from .email import parse_email_recipients, send_smtp_email
34
from .events import NotificationPublisher, RenderedNotification, publish_rendered_notification
45

56
__all__ = [
67
"NotificationPublisher",
78
"RenderedNotification",
9+
"parse_email_recipients",
810
"publish_rendered_notification",
11+
"send_smtp_email",
912
]
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
"""SMTP email notification helpers."""
2+
3+
from __future__ import annotations
4+
5+
import smtplib
6+
from collections.abc import Sequence
7+
from email.message import EmailMessage
8+
9+
10+
def parse_email_recipients(raw_value: str | Sequence[str] | None) -> tuple[str, ...]:
11+
if raw_value is None:
12+
return ()
13+
if isinstance(raw_value, str):
14+
values = raw_value.replace(";", ",").replace("\n", ",").split(",")
15+
else:
16+
values = raw_value
17+
recipients = []
18+
seen = set()
19+
for value in values:
20+
recipient = str(value or "").strip()
21+
if not recipient or recipient in seen:
22+
continue
23+
recipients.append(recipient)
24+
seen.add(recipient)
25+
return tuple(recipients)
26+
27+
28+
def send_smtp_email(
29+
*,
30+
subject: str,
31+
body: str,
32+
smtp_host: str | None,
33+
smtp_port: int,
34+
sender: str | None,
35+
recipients: Sequence[str],
36+
username: str | None = None,
37+
password: str | None = None,
38+
use_starttls: bool = True,
39+
use_ssl: bool = False,
40+
timeout: float = 10.0,
41+
smtp_module=smtplib,
42+
printer=print,
43+
) -> bool:
44+
resolved_recipients = parse_email_recipients(recipients)
45+
host = str(smtp_host or "").strip()
46+
from_addr = str(sender or "").strip()
47+
if not host or not from_addr or not resolved_recipients:
48+
return False
49+
50+
message = EmailMessage()
51+
message["From"] = from_addr
52+
message["To"] = ", ".join(resolved_recipients)
53+
message["Subject"] = str(subject or "").strip() or "strategy alert"
54+
message.set_content(str(body or "").strip())
55+
56+
try:
57+
smtp_cls = smtp_module.SMTP_SSL if use_ssl else smtp_module.SMTP
58+
with smtp_cls(host, int(smtp_port), timeout=timeout) as smtp:
59+
if use_starttls and not use_ssl:
60+
smtp.starttls()
61+
if username:
62+
smtp.login(str(username), str(password or ""))
63+
smtp.send_message(message)
64+
return True
65+
except Exception as exc:
66+
printer(f"Email send failed: {exc}", flush=True)
67+
return False

0 commit comments

Comments
 (0)