Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ It contains:
- narrow ports for market data, portfolio snapshots, order execution, notifications, and state
- reusable broker adapter utilities
- strategy loading, strategy-plugin, and alert-message contracts
- optional strategy-plugin alert channels for email, SMS, and push providers
- synthetic-data tests for public behavior

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

## Strategy Plugins

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.
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.

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.

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.

## Package Layout

```text
Expand Down
5 changes: 4 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
- 市场数据、持仓快照、订单执行、通知、状态存储等窄接口
- 可复用的券商适配工具
- 策略加载、策略插件、告警消息契约
- 可选的策略插件 email、SMS 和 push 告警通道
- 使用合成数据的公开测试

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

## 策略插件

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

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

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

## 目录结构

```text
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "quant-platform-kit"
version = "0.7.28"
version = "0.7.29"
description = "Shared broker adapters, domain models, execution ports, and notification utilities for QuantStrategyLab strategies."
readme = "README.md"
requires-python = ">=3.9"
Expand Down
4 changes: 4 additions & 0 deletions src/quant_platform_kit/common/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@
PLUGIN_CRISIS_RESPONSE_SHADOW,
PLUGIN_MODE_SHADOW,
STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL,
STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH,
STRATEGY_PLUGIN_ALERT_CHANNEL_SMS,
STRATEGY_PLUGIN_ALERT_ACTIONS,
STRATEGY_PLUGIN_NON_ALERT_ROUTES,
SUPPORTED_STRATEGY_PLUGIN_MODES,
Expand Down Expand Up @@ -85,6 +87,8 @@
"STAGE_RECONCILED",
"STAGE_SUBMITTED",
"STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL",
"STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH",
"STRATEGY_PLUGIN_ALERT_CHANNEL_SMS",
"STRATEGY_PLUGIN_ALERT_ACTIONS",
"STRATEGY_PLUGIN_NON_ALERT_ROUTES",
"SUPPORTED_STRATEGY_PLUGIN_MODES",
Expand Down
8 changes: 7 additions & 1 deletion src/quant_platform_kit/common/strategy_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@
PLUGIN_CRISIS_RESPONSE_SHADOW = "crisis_response_shadow"
PLUGIN_MODE_SHADOW = "shadow"
STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL = "email"
STRATEGY_PLUGIN_ALERT_CHANNEL_SMS = "sms"
STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH = "push"
SUPPORTED_STRATEGY_PLUGIN_MODES = frozenset({PLUGIN_MODE_SHADOW})
DEFAULT_PLUGIN_ARTIFACT_CACHE_DIR = Path(tempfile.gettempdir()) / "quant_strategy_plugin_artifacts"
STRATEGY_PLUGIN_NON_ALERT_ROUTES = frozenset({"no_action"})
Expand Down Expand Up @@ -67,7 +69,11 @@ def supports_strategy(self, strategy: str) -> bool:
plugin=PLUGIN_CRISIS_RESPONSE_SHADOW,
supported_strategies=CRISIS_RESPONSE_SHADOW_SUPPORTED_STRATEGIES,
supported_modes=SUPPORTED_STRATEGY_PLUGIN_MODES,
alert_channels=(STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL,),
alert_channels=(
STRATEGY_PLUGIN_ALERT_CHANNEL_EMAIL,
STRATEGY_PLUGIN_ALERT_CHANNEL_SMS,
STRATEGY_PLUGIN_ALERT_CHANNEL_PUSH,
),
)
}

Expand Down
17 changes: 17 additions & 0 deletions src/quant_platform_kit/notifications/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .email import parse_email_recipients, send_smtp_email
from .events import NotificationPublisher, RenderedNotification, publish_rendered_notification
from .push import parse_push_recipients, send_ntfy_push, send_pushover_push, send_strategy_plugin_push
from .sms import normalize_sms_recipient, parse_sms_recipients, send_twilio_sms
from .strategy_plugin_alerts import (
StrategyPluginAlertChannelStores,
Expand All @@ -24,6 +25,13 @@
StrategyPluginSmsSettings,
publish_strategy_plugin_sms_alerts,
)
from .strategy_plugin_push import (
StrategyPluginPushAlertDelivery,
StrategyPluginPushAlertMarkerStore,
StrategyPluginPushAlertPublishResult,
StrategyPluginPushSettings,
publish_strategy_plugin_push_alerts,
)

__all__ = [
"NotificationPublisher",
Expand All @@ -35,18 +43,27 @@
"StrategyPluginEmailAlertMarkerStore",
"StrategyPluginEmailAlertPublishResult",
"StrategyPluginEmailSettings",
"StrategyPluginPushAlertDelivery",
"StrategyPluginPushAlertMarkerStore",
"StrategyPluginPushAlertPublishResult",
"StrategyPluginPushSettings",
"StrategyPluginSmsAlertDelivery",
"StrategyPluginSmsAlertMarkerStore",
"StrategyPluginSmsAlertPublishResult",
"StrategyPluginSmsSettings",
"build_strategy_plugin_alert_context_label",
"normalize_sms_recipient",
"parse_email_recipients",
"parse_push_recipients",
"parse_sms_recipients",
"publish_rendered_notification",
"publish_strategy_plugin_alerts",
"publish_strategy_plugin_email_alerts",
"publish_strategy_plugin_push_alerts",
"publish_strategy_plugin_sms_alerts",
"send_ntfy_push",
"send_pushover_push",
"send_smtp_email",
"send_strategy_plugin_push",
"send_twilio_sms",
]
227 changes: 227 additions & 0 deletions src/quant_platform_kit/notifications/push.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
"""Mobile push notification helpers."""

from __future__ import annotations

import urllib.parse
import urllib.request
from collections.abc import Sequence
from email.header import Header
from typing import Any


PUSH_PROVIDER_NTFY = "ntfy"
PUSH_PROVIDER_PUSHOVER = "pushover"
DEFAULT_NTFY_API_BASE_URL = "https://ntfy.sh"
DEFAULT_PUSHOVER_API_BASE_URL = "https://api.pushover.net"


def parse_push_recipients(raw_value: str | Sequence[str] | None) -> tuple[str, ...]:
if raw_value is None:
return ()
if isinstance(raw_value, str):
values = raw_value.replace(";", ",").replace("\n", ",").split(",")
else:
values = raw_value
recipients = []
seen = set()
for value in values:
recipient = str(value or "").strip()
if not recipient or recipient in seen:
continue
recipients.append(recipient)
seen.add(recipient)
return tuple(recipients)


def send_strategy_plugin_push(
*,
provider: str,
title: str,
body: str,
recipients: Sequence[str],
app_token: str | None = None,
access_token: str | None = None,
api_base_url: str | None = None,
device: str | None = None,
priority: str | int | None = None,
tags: str | None = None,
timeout: float = 10.0,
opener: Any = None,
printer=print,
) -> bool:
normalized_provider = str(provider or "").strip().lower()
if normalized_provider == PUSH_PROVIDER_PUSHOVER:
return send_pushover_push(
title=title,
body=body,
recipients=recipients,
app_token=app_token,
api_base_url=api_base_url or DEFAULT_PUSHOVER_API_BASE_URL,
device=device,
priority=priority,
timeout=timeout,
opener=opener,
printer=printer,
)
if normalized_provider == PUSH_PROVIDER_NTFY:
return send_ntfy_push(
title=title,
body=body,
recipients=recipients,
access_token=access_token,
api_base_url=api_base_url or DEFAULT_NTFY_API_BASE_URL,
priority=priority,
tags=tags,
timeout=timeout,
opener=opener,
printer=printer,
)
printer(f"Push send failed: unsupported provider {provider!r}", flush=True)
return False


def send_pushover_push(
*,
title: str,
body: str,
recipients: Sequence[str],
app_token: str | None,
api_base_url: str = DEFAULT_PUSHOVER_API_BASE_URL,
device: str | None = None,
priority: str | int | None = None,
timeout: float = 10.0,
opener: Any = None,
printer=print,
) -> bool:
resolved_recipients = parse_push_recipients(recipients)
token = str(app_token or "").strip()
message = str(body or "").strip()
if not resolved_recipients or not token or not message:
return False

request_opener = opener or urllib.request.urlopen
endpoint = _pushover_messages_endpoint(api_base_url)
all_sent = True
for recipient in resolved_recipients:
payload = {
"token": token,
"user": recipient,
"message": message,
}
text_title = str(title or "").strip()
if text_title:
payload["title"] = text_title
text_device = str(device or "").strip()
if text_device:
payload["device"] = text_device
text_priority = str(priority or "").strip()
if text_priority:
payload["priority"] = text_priority
data = urllib.parse.urlencode(payload).encode("utf-8")
request = urllib.request.Request(
endpoint,
data=data,
headers={"Content-Type": "application/x-www-form-urlencoded"},
method="POST",
)
if not _request_succeeded(request_opener, request, timeout, printer, recipient):
all_sent = False
return all_sent


def send_ntfy_push(
*,
title: str,
body: str,
recipients: Sequence[str],
access_token: str | None = None,
api_base_url: str = DEFAULT_NTFY_API_BASE_URL,
priority: str | int | None = None,
tags: str | None = None,
timeout: float = 10.0,
opener: Any = None,
printer=print,
) -> bool:
resolved_recipients = parse_push_recipients(recipients)
message = str(body or "").strip()
if not resolved_recipients or not message:
return False

request_opener = opener or urllib.request.urlopen
token = str(access_token or "").strip()
all_sent = True
for recipient in resolved_recipients:
headers = {
"Content-Type": "text/plain; charset=utf-8",
}
text_title = str(title or "").strip()
if text_title:
headers["Title"] = _encode_http_header(text_title)
text_priority = str(priority or "").strip()
if text_priority:
headers["Priority"] = text_priority
text_tags = str(tags or "").strip()
if text_tags:
headers["Tags"] = _encode_http_header(text_tags)
if token:
headers["Authorization"] = f"Bearer {token}"
request = urllib.request.Request(
_ntfy_topic_endpoint(api_base_url, recipient),
data=message.encode("utf-8"),
headers=headers,
method="POST",
)
if not _request_succeeded(request_opener, request, timeout, printer, recipient):
all_sent = False
return all_sent


def _request_succeeded(
request_opener: Any,
request: urllib.request.Request,
timeout: float,
printer,
recipient: str,
) -> bool:
try:
with request_opener(request, timeout=timeout) as response:
status = getattr(response, "status", None)
if status is None:
status = response.getcode()
status = int(status)
except Exception as exc:
printer(f"Push send failed for {recipient}: {exc}", flush=True)
return False
if status < 200 or status >= 300:
printer(f"Push send failed for {recipient}: HTTP {status}", flush=True)
return False
return True


def _pushover_messages_endpoint(api_base_url: str) -> str:
base_url = str(api_base_url or DEFAULT_PUSHOVER_API_BASE_URL).rstrip("/")
if base_url.endswith("/1/messages.json"):
return base_url
return f"{base_url}/1/messages.json"


def _ntfy_topic_endpoint(api_base_url: str, recipient: str) -> str:
target = str(recipient or "").strip()
if target.startswith(("https://", "http://")):
return target
base_url = str(api_base_url or DEFAULT_NTFY_API_BASE_URL).rstrip("/")
path = "/".join(
urllib.parse.quote(part.strip(), safe="")
for part in target.strip("/").split("/")
if part.strip()
)
return f"{base_url}/{path}"


def _encode_http_header(value: str) -> str:
text = str(value or "")
try:
text.encode("latin-1")
except UnicodeEncodeError:
return Header(text, "utf-8").encode()
return text
Loading