Skip to content

Commit b960bff

Browse files
committed
Add push channel for strategy plugin alerts
1 parent 7183145 commit b960bff

12 files changed

Lines changed: 1077 additions & 16 deletions

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ It contains:
1414
- narrow ports for market data, portfolio snapshots, order execution, notifications, and state
1515
- reusable broker adapter utilities
1616
- strategy loading, strategy-plugin, and alert-message contracts
17+
- optional strategy-plugin alert channels for email, SMS, and push providers
1718
- synthetic-data tests for public behavior
1819

1920
It does not contain private runtime wiring or generated strategy outputs.
@@ -44,10 +45,12 @@ Strategy code should not branch on a broker platform, and platform code should n
4445

4546
## Strategy Plugins
4647

47-
Strategy plugins are sidecar artifacts that platform repositories may read when a strategy profile opts in. This repository defines the public plugin contract, compatibility checks, alert-message building, and duplicate-suppression helpers.
48+
Strategy plugins are sidecar artifacts that platform repositories may read when a strategy profile opts in. This repository defines the public plugin contract, compatibility checks, alert-message building, optional alert delivery helpers, and duplicate-suppression helpers.
4849

4950
Generated plugin artifacts and platform-specific notification routing stay with the producing pipeline or consuming platform repository. Tests in this repository use synthetic price history and synthetic payloads only.
5051

52+
Plugin alert delivery is provider-neutral at the platform boundary. Platform repositories pass runtime settings into `publish_strategy_plugin_alerts`; this repository handles configured `email`, `sms`, and `push` channels without coupling plugin logic to a broker platform.
53+
5154
## Package Layout
5255

5356
```text

README.zh-CN.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
- 市场数据、持仓快照、订单执行、通知、状态存储等窄接口
1515
- 可复用的券商适配工具
1616
- 策略加载、策略插件、告警消息契约
17+
- 可选的策略插件 email、SMS 和 push 告警通道
1718
- 使用合成数据的公开测试
1819

1920
它不包含私有运行时接线和生成的策略输出。
@@ -44,10 +45,12 @@ QuantPlatformKit
4445

4546
## 策略插件
4647

47-
策略插件是平台仓库按需读取的 sidecar artifact。这个仓库只定义公开插件契约、兼容性校验、告警消息构造和重复告警抑制 helper。
48+
策略插件是平台仓库按需读取的 sidecar artifact。这个仓库只定义公开插件契约、兼容性校验、告警消息构造、可选告警发送 helper 和重复告警抑制 helper。
4849

4950
生成的插件 artifact 和平台专属通知路由由生成它的 pipeline 或消费它的平台仓库管理。这个仓库的测试只使用合成价格历史和合成 payload。
5051

52+
插件告警发送在平台边界保持 provider-neutral。平台仓库只把 runtime settings 传入 `publish_strategy_plugin_alerts`;这个仓库负责按配置发送 `email``sms``push`,不让插件逻辑耦合某个券商平台。
53+
5154
## 目录结构
5255

5356
```text

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.28"
7+
version = "0.7.29"
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: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@
4444
PLUGIN_CRISIS_RESPONSE_SHADOW,
4545
PLUGIN_MODE_SHADOW,
4646
STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL,
47+
STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH,
48+
STRATEGY_PLUGIN_ALERT_CHANNEL_SMS,
4749
STRATEGY_PLUGIN_ALERT_ACTIONS,
4850
STRATEGY_PLUGIN_NON_ALERT_ROUTES,
4951
SUPPORTED_STRATEGY_PLUGIN_MODES,
@@ -85,6 +87,8 @@
8587
"STAGE_RECONCILED",
8688
"STAGE_SUBMITTED",
8789
"STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL",
90+
"STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH",
91+
"STRATEGY_PLUGIN_ALERT_CHANNEL_SMS",
8892
"STRATEGY_PLUGIN_ALERT_ACTIONS",
8993
"STRATEGY_PLUGIN_NON_ALERT_ROUTES",
9094
"SUPPORTED_STRATEGY_PLUGIN_MODES",

src/quant_platform_kit/common/strategy_plugins.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@
1313
PLUGIN_CRISIS_RESPONSE_SHADOW = "crisis_response_shadow"
1414
PLUGIN_MODE_SHADOW = "shadow"
1515
STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL = "email"
16+
STRATEGY_PLUGIN_ALERT_CHANNEL_SMS = "sms"
17+
STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH = "push"
1618
SUPPORTED_STRATEGY_PLUGIN_MODES = frozenset({PLUGIN_MODE_SHADOW})
1719
DEFAULT_PLUGIN_ARTIFACT_CACHE_DIR = Path(tempfile.gettempdir()) / "quant_strategy_plugin_artifacts"
1820
STRATEGY_PLUGIN_NON_ALERT_ROUTES = frozenset({"no_action"})
@@ -67,7 +69,11 @@ def supports_strategy(self, strategy: str) -> bool:
6769
plugin=PLUGIN_CRISIS_RESPONSE_SHADOW,
6870
supported_strategies=CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES,
6971
supported_modes=SUPPORTED_STRATEGY_PLUGIN_MODES,
70-
alert_channels=(STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL,),
72+
alert_channels=(
73+
STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL,
74+
STRATEGY_PLUGIN_ALERT_CHANNEL_SMS,
75+
STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH,
76+
),
7177
)
7278
}
7379

src/quant_platform_kit/notifications/__init__.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
from .email import parse_email_recipients, send_smtp_email
44
from .events import NotificationPublisher, RenderedNotification, publish_rendered_notification
5+
from .push import parse_push_recipients, send_ntfy_push, send_pushover_push, send_strategy_plugin_push
56
from .sms import normalize_sms_recipient, parse_sms_recipients, send_twilio_sms
67
from .strategy_plugin_alerts import (
78
StrategyPluginAlertChannelStores,
@@ -24,6 +25,13 @@
2425
StrategyPluginSmsSettings,
2526
publish_strategy_plugin_sms_alerts,
2627
)
28+
from .strategy_plugin_push import (
29+
StrategyPluginPushAlertDelivery,
30+
StrategyPluginPushAlertMarkerStore,
31+
StrategyPluginPushAlertPublishResult,
32+
StrategyPluginPushSettings,
33+
publish_strategy_plugin_push_alerts,
34+
)
2735

2836
__all__ = [
2937
"NotificationPublisher",
@@ -35,18 +43,27 @@
3543
"StrategyPluginEmailAlertMarkerStore",
3644
"StrategyPluginEmailAlertPublishResult",
3745
"StrategyPluginEmailSettings",
46+
"StrategyPluginPushAlertDelivery",
47+
"StrategyPluginPushAlertMarkerStore",
48+
"StrategyPluginPushAlertPublishResult",
49+
"StrategyPluginPushSettings",
3850
"StrategyPluginSmsAlertDelivery",
3951
"StrategyPluginSmsAlertMarkerStore",
4052
"StrategyPluginSmsAlertPublishResult",
4153
"StrategyPluginSmsSettings",
4254
"build_strategy_plugin_alert_context_label",
4355
"normalize_sms_recipient",
4456
"parse_email_recipients",
57+
"parse_push_recipients",
4558
"parse_sms_recipients",
4659
"publish_rendered_notification",
4760
"publish_strategy_plugin_alerts",
4861
"publish_strategy_plugin_email_alerts",
62+
"publish_strategy_plugin_push_alerts",
4963
"publish_strategy_plugin_sms_alerts",
64+
"send_ntfy_push",
65+
"send_pushover_push",
5066
"send_smtp_email",
67+
"send_strategy_plugin_push",
5168
"send_twilio_sms",
5269
]
Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
"""Mobile push notification helpers."""
2+
3+
from __future__ import annotations
4+
5+
import urllib.parse
6+
import urllib.request
7+
from collections.abc import Sequence
8+
from email.header import Header
9+
from typing import Any
10+
11+
12+
PUSH_PROVIDER_NTFY = "ntfy"
13+
PUSH_PROVIDER_PUSHOVER = "pushover"
14+
DEFAULT_NTFY_API_BASE_URL = "https://ntfy.sh"
15+
DEFAULT_PUSHOVER_API_BASE_URL = "https://api.pushover.net"
16+
17+
18+
def parse_push_recipients(raw_value: str | Sequence[str] | None) -> tuple[str, ...]:
19+
if raw_value is None:
20+
return ()
21+
if isinstance(raw_value, str):
22+
values = raw_value.replace(";", ",").replace("\n", ",").split(",")
23+
else:
24+
values = raw_value
25+
recipients = []
26+
seen = set()
27+
for value in values:
28+
recipient = str(value or "").strip()
29+
if not recipient or recipient in seen:
30+
continue
31+
recipients.append(recipient)
32+
seen.add(recipient)
33+
return tuple(recipients)
34+
35+
36+
def send_strategy_plugin_push(
37+
*,
38+
provider: str,
39+
title: str,
40+
body: str,
41+
recipients: Sequence[str],
42+
app_token: str | None = None,
43+
access_token: str | None = None,
44+
api_base_url: str | None = None,
45+
device: str | None = None,
46+
priority: str | int | None = None,
47+
tags: str | None = None,
48+
timeout: float = 10.0,
49+
opener: Any = None,
50+
printer=print,
51+
) -> bool:
52+
normalized_provider = str(provider or "").strip().lower()
53+
if normalized_provider == PUSH_PROVIDER_PUSHOVER:
54+
return send_pushover_push(
55+
title=title,
56+
body=body,
57+
recipients=recipients,
58+
app_token=app_token,
59+
api_base_url=api_base_url or DEFAULT_PUSHOVER_API_BASE_URL,
60+
device=device,
61+
priority=priority,
62+
timeout=timeout,
63+
opener=opener,
64+
printer=printer,
65+
)
66+
if normalized_provider == PUSH_PROVIDER_NTFY:
67+
return send_ntfy_push(
68+
title=title,
69+
body=body,
70+
recipients=recipients,
71+
access_token=access_token,
72+
api_base_url=api_base_url or DEFAULT_NTFY_API_BASE_URL,
73+
priority=priority,
74+
tags=tags,
75+
timeout=timeout,
76+
opener=opener,
77+
printer=printer,
78+
)
79+
printer(f"Push send failed: unsupported provider {provider!r}", flush=True)
80+
return False
81+
82+
83+
def send_pushover_push(
84+
*,
85+
title: str,
86+
body: str,
87+
recipients: Sequence[str],
88+
app_token: str | None,
89+
api_base_url: str = DEFAULT_PUSHOVER_API_BASE_URL,
90+
device: str | None = None,
91+
priority: str | int | None = None,
92+
timeout: float = 10.0,
93+
opener: Any = None,
94+
printer=print,
95+
) -> bool:
96+
resolved_recipients = parse_push_recipients(recipients)
97+
token = str(app_token or "").strip()
98+
message = str(body or "").strip()
99+
if not resolved_recipients or not token or not message:
100+
return False
101+
102+
request_opener = opener or urllib.request.urlopen
103+
endpoint = _pushover_messages_endpoint(api_base_url)
104+
all_sent = True
105+
for recipient in resolved_recipients:
106+
payload = {
107+
"token": token,
108+
"user": recipient,
109+
"message": message,
110+
}
111+
text_title = str(title or "").strip()
112+
if text_title:
113+
payload["title"] = text_title
114+
text_device = str(device or "").strip()
115+
if text_device:
116+
payload["device"] = text_device
117+
text_priority = str(priority or "").strip()
118+
if text_priority:
119+
payload["priority"] = text_priority
120+
data = urllib.parse.urlencode(payload).encode("utf-8")
121+
request = urllib.request.Request(
122+
endpoint,
123+
data=data,
124+
headers={"Content-Type": "application/x-www-form-urlencoded"},
125+
method="POST",
126+
)
127+
if not _request_succeeded(request_opener, request, timeout, printer, recipient):
128+
all_sent = False
129+
return all_sent
130+
131+
132+
def send_ntfy_push(
133+
*,
134+
title: str,
135+
body: str,
136+
recipients: Sequence[str],
137+
access_token: str | None = None,
138+
api_base_url: str = DEFAULT_NTFY_API_BASE_URL,
139+
priority: str | int | None = None,
140+
tags: str | None = None,
141+
timeout: float = 10.0,
142+
opener: Any = None,
143+
printer=print,
144+
) -> bool:
145+
resolved_recipients = parse_push_recipients(recipients)
146+
message = str(body or "").strip()
147+
if not resolved_recipients or not message:
148+
return False
149+
150+
request_opener = opener or urllib.request.urlopen
151+
token = str(access_token or "").strip()
152+
all_sent = True
153+
for recipient in resolved_recipients:
154+
headers = {
155+
"Content-Type": "text/plain; charset=utf-8",
156+
}
157+
text_title = str(title or "").strip()
158+
if text_title:
159+
headers["Title"] = _encode_http_header(text_title)
160+
text_priority = str(priority or "").strip()
161+
if text_priority:
162+
headers["Priority"] = text_priority
163+
text_tags = str(tags or "").strip()
164+
if text_tags:
165+
headers["Tags"] = _encode_http_header(text_tags)
166+
if token:
167+
headers["Authorization"] = f"Bearer {token}"
168+
request = urllib.request.Request(
169+
_ntfy_topic_endpoint(api_base_url, recipient),
170+
data=message.encode("utf-8"),
171+
headers=headers,
172+
method="POST",
173+
)
174+
if not _request_succeeded(request_opener, request, timeout, printer, recipient):
175+
all_sent = False
176+
return all_sent
177+
178+
179+
def _request_succeeded(
180+
request_opener: Any,
181+
request: urllib.request.Request,
182+
timeout: float,
183+
printer,
184+
recipient: str,
185+
) -> bool:
186+
try:
187+
with request_opener(request, timeout=timeout) as response:
188+
status = getattr(response, "status", None)
189+
if status is None:
190+
status = response.getcode()
191+
status = int(status)
192+
except Exception as exc:
193+
printer(f"Push send failed for {recipient}: {exc}", flush=True)
194+
return False
195+
if status < 200 or status >= 300:
196+
printer(f"Push send failed for {recipient}: HTTP {status}", flush=True)
197+
return False
198+
return True
199+
200+
201+
def _pushover_messages_endpoint(api_base_url: str) -> str:
202+
base_url = str(api_base_url or DEFAULT_PUSHOVER_API_BASE_URL).rstrip("/")
203+
if base_url.endswith("/1/messages.json"):
204+
return base_url
205+
return f"{base_url}/1/messages.json"
206+
207+
208+
def _ntfy_topic_endpoint(api_base_url: str, recipient: str) -> str:
209+
target = str(recipient or "").strip()
210+
if target.startswith(("https://", "http://")):
211+
return target
212+
base_url = str(api_base_url or DEFAULT_NTFY_API_BASE_URL).rstrip("/")
213+
path = "/".join(
214+
urllib.parse.quote(part.strip(), safe="")
215+
for part in target.strip("/").split("/")
216+
if part.strip()
217+
)
218+
return f"{base_url}/{path}"
219+
220+
221+
def _encode_http_header(value: str) -> str:
222+
text = str(value or "")
223+
try:
224+
text.encode("latin-1")
225+
except UnicodeEncodeError:
226+
return Header(text, "utf-8").encode()
227+
return text

0 commit comments

Comments
 (0)