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
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ quant_platform_kit = ["schemas/*.json"]
target-version = "py310"

[tool.ruff.lint]
select = ["E4", "E7", "E9", "F"]
ignore = ["E701", "E702", "E741", "F841", "F821", "B008", "F401", "F601", "E402"]

[tool.coverage.run]
Expand Down
6 changes: 3 additions & 3 deletions src/quant_platform_kit/common/port_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,10 @@

@dataclass(frozen=True)
class CallableNotificationPort(NotificationPort):
sender: Callable[[str], None]
sender: Callable[[str], bool | None]

def send_text(self, message: str) -> None:
self.sender(message)
def send_text(self, message: str) -> bool | None:
return self.sender(message)


@dataclass(frozen=True)
Expand Down
2 changes: 1 addition & 1 deletion src/quant_platform_kit/common/ports.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ def submit_order(self, order: OrderIntent) -> ExecutionReport:


class NotificationPort(Protocol):
def send_text(self, message: str) -> None:
def send_text(self, message: str) -> bool | None:
"""Send a plain-text notification."""


Expand Down
51 changes: 51 additions & 0 deletions src/quant_platform_kit/common/runtime_target.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from collections.abc import Iterable
from dataclasses import dataclass, asdict
from typing import Any, Mapping
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError


@dataclass(frozen=True)
Expand All @@ -16,13 +17,20 @@ class RuntimeTarget:
account_scope: str | None = None
service_name: str | None = None
execution_windows: dict[str, Any] | None = None
market: str | None = None
market_calendar: str | None = None
market_timezone: str | None = None
scheduler: dict[str, Any] | None = None
Comment thread
Pigbibi marked this conversation as resolved.

@property
def execution_mode(self) -> str:
return "paper" if self.dry_run_only else "live"

def to_dict(self) -> dict[str, object]:
payload = asdict(self)
for field in ("market", "market_calendar", "market_timezone", "scheduler"):
if payload.get(field) is None:
payload.pop(field, None)
payload["execution_mode"] = self.execution_mode
return payload

Expand Down Expand Up @@ -59,6 +67,29 @@ def _normalize_account_selector(value: Iterable[str] | str | None) -> tuple[str,
return tuple(normalized)


def _normalize_market_metadata(
*,
market: str | None,
market_calendar: str | None,
market_timezone: str | None,
) -> tuple[str | None, str | None, str | None]:
values = (
_normalize_optional_string(market),
_normalize_optional_string(market_calendar),
_normalize_optional_string(market_timezone),
)
if any(values) and not all(values):
raise ValueError(
"market metadata must include market, market_calendar, and market_timezone together"
Comment thread
Pigbibi marked this conversation as resolved.
)
if values[2] is not None:
try:
ZoneInfo(values[2])
except (ZoneInfoNotFoundError, ValueError) as exc:
raise ValueError(f"invalid market_timezone: {values[2]!r}") from exc
return values


def build_runtime_target(
*,
platform_id: str,
Expand All @@ -68,8 +99,17 @@ def build_runtime_target(
account_selector: Iterable[str] | str | None = None,
account_scope: str | None = None,
service_name: str | None = None,
market: str | None = None,
market_calendar: str | None = None,
market_timezone: str | None = None,
scheduler: Mapping[str, Any] | None = None,
execution_windows: Mapping[str, Any] | None = None,
) -> RuntimeTarget:
normalized_market, normalized_calendar, normalized_timezone = _normalize_market_metadata(
market=market,
market_calendar=market_calendar,
market_timezone=market_timezone,
)
return RuntimeTarget(
platform_id=str(platform_id).strip(),
strategy_profile=str(strategy_profile).strip(),
Expand All @@ -78,6 +118,10 @@ def build_runtime_target(
account_selector=_normalize_account_selector(account_selector),
account_scope=_normalize_optional_string(account_scope),
service_name=_normalize_optional_string(service_name),
market=normalized_market,
market_calendar=normalized_calendar,
market_timezone=normalized_timezone,
scheduler=dict(scheduler) if scheduler is not None else None,
execution_windows=dict(execution_windows) if execution_windows is not None else None,
)

Expand Down Expand Up @@ -145,6 +189,9 @@ def resolve_runtime_target_from_env(
execution_windows = payload.get("execution_windows")
if execution_windows is not None and not isinstance(execution_windows, dict):
raise ValueError("RUNTIME_TARGET_JSON.execution_windows must be an object when present")
scheduler = payload.get("scheduler")
if scheduler is not None and not isinstance(scheduler, dict):
raise ValueError("RUNTIME_TARGET_JSON.scheduler must be an object when present")

return build_runtime_target(
platform_id=resolved_platform_id,
Expand All @@ -154,5 +201,9 @@ def resolve_runtime_target_from_env(
account_selector=payload.get("account_selector"),
account_scope=payload.get("account_scope"),
service_name=payload.get("service_name"),
market=payload.get("market"),
market_calendar=payload.get("market_calendar"),
market_timezone=payload.get("market_timezone"),
scheduler=scheduler,
execution_windows=execution_windows,
)
4 changes: 2 additions & 2 deletions src/quant_platform_kit/notifications/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ def send_message(
) -> bool:
from .telegram import send_telegram_message
return send_telegram_message(
chat_id=chat_id,
chat_ids=chat_id,
text=text,
token=token,
bot_token=token,
api_base_url=api_base_url,
parse_mode=parse_mode,
)
Expand Down
42 changes: 24 additions & 18 deletions src/quant_platform_kit/notifications/cycle_channel.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
"""Cycle notification sender factory.

Provides a single entry point for building channel-agnostic notification
senders. Each sender is a ``Callable[[str], None]`` that accepts a fully
rendered message string and delivers it to the configured channel.
senders. Each sender is a ``Callable[[str], bool]`` that accepts a fully
rendered message string and returns whether the transport acknowledged it.

Supported channels:
- telegram — Telegram Bot API (default)
Expand Down Expand Up @@ -58,8 +58,8 @@ def build_cycle_sender(
telegram_chat_id: str | None = None,
webhook_url: str | None = None,
printer: Any = print,
) -> Callable[[str], None]:
"""Build a ``send_message(message: str) -> None`` callback for *channel*.
) -> Callable[[str], bool]:
"""Build a ``send_message(message: str) -> bool`` callback for *channel*.

Args:
channel: One of ``"telegram"``, ``"wecom"``, ``"dingtalk"``,
Expand Down Expand Up @@ -163,23 +163,23 @@ def _build_telegram_sender(
token: str | None,
chat_id: str | None,
printer: Any = print,
) -> Callable[[str], None]:
) -> Callable[[str], bool]:
from .telegram import send_telegram_message

resolved_token = str(token or "").strip()
resolved_chat_id = str(chat_id or "").strip()

def send_message(message: str) -> None:
def send_message(message: str) -> bool:
if not resolved_token:
printer("Cycle sender: telegram token not configured", flush=True)
return
return False
if not resolved_chat_id:
printer("Cycle sender: telegram chat_id not configured", flush=True)
return
return False
text = str(message or "").strip()
if not text:
return
send_telegram_message(
return False
return send_telegram_message(
bot_token=resolved_token,
chat_ids=resolved_chat_id,
text=text,
Expand All @@ -199,7 +199,7 @@ def _build_webhook_sender(
channel: str,
webhook_url: str | None,
printer: Any = print,
) -> Callable[[str], None]:
) -> Callable[[str], bool]:
from .webhook import (
WEBHOOK_PROVIDER_DINGTALK,
WEBHOOK_PROVIDER_FEISHU,
Expand All @@ -213,25 +213,31 @@ def _build_webhook_sender(

resolved_url = str(webhook_url or "").strip()

def send_message(message: str) -> None:
def send_message(message: str) -> bool:
nonlocal resolved_url
if not resolved_url:
printer(f"Cycle sender: webhook URL not configured for {channel}", flush=True)
return
return False
text = str(message or "").strip()
if not text:
return
return False
if channel == WEBHOOK_PROVIDER_WECOM:
send_wecom_webhook(resolved_url, text, printer=printer)
return send_wecom_webhook(resolved_url, text, printer=printer)
elif channel == WEBHOOK_PROVIDER_DINGTALK:
send_dingtalk_webhook(resolved_url, text, printer=printer)
return send_dingtalk_webhook(resolved_url, text, printer=printer)
elif channel == WEBHOOK_PROVIDER_FEISHU:
send_feishu_webhook(resolved_url, text, printer=printer)
return send_feishu_webhook(resolved_url, text, printer=printer)
elif channel == WEBHOOK_PROVIDER_SERVERCHAN:
# ServerChan: first line → title, remainder → body
lines = text.split("\n", 1)
title = lines[0]
body = lines[1] if len(lines) > 1 else ""
send_serverchan_webhook(resolved_url, title=title, body=body, printer=printer)
return send_serverchan_webhook(
resolved_url,
title=title,
body=body,
printer=printer,
)
return False

return send_message
13 changes: 7 additions & 6 deletions src/quant_platform_kit/notifications/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ class NotificationPublisher:
"""Publish rendered notifications to the configured sinks."""

log_message: Callable[[str], None]
send_message: Callable[[str], None]
send_message: Callable[[str], bool | None]

def publish(self, notification: RenderedNotification) -> None:
publish_rendered_notification(
def publish(self, notification: RenderedNotification) -> bool:
return publish_rendered_notification(
notification,
log_message=self.log_message,
send_message=self.send_message,
Expand All @@ -33,12 +33,13 @@ def publish_rendered_notification(
notification: RenderedNotification,
*,
log_message: Callable[[str], None],
send_message: Callable[[str], None],
) -> None:
send_message: Callable[[str], bool | None],
) -> bool:
"""Write the detailed log copy and send the compact user notification."""
detailed = str(notification.detailed_text or "").strip()
compact = str(notification.compact_text or "").strip()
if detailed:
log_message(detailed)
if compact:
send_message(compact)
return send_message(compact) is not False
return True
22 changes: 21 additions & 1 deletion src/quant_platform_kit/notifications/telegram.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,19 +122,39 @@ def _request_succeeded(
printer,
chat_id: str,
) -> bool:
raw_response = b""
try:
with request_opener(request, timeout=timeout) as response:
status = getattr(response, "status", None)
if status is None:
status = response.getcode()
status = int(status)
read_response = getattr(response, "read", None)
if callable(read_response):
raw_response = read_response()
except Exception as exc:
printer(f"Telegram send failed for {chat_id}: {redact_sensitive_text(exc)}", flush=True)
return False
if status < 200 or status >= 300:
printer(f"Telegram send failed for {chat_id}: HTTP {status}", flush=True)
return False
return True
try:
response_payload = json.loads(raw_response.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
response_payload = None
if isinstance(response_payload, dict) and response_payload.get("ok") is True:
return True
description = (
response_payload.get("description")
if isinstance(response_payload, dict)
else None
)
printer(
"Telegram send failed for "
f"{chat_id}: {redact_sensitive_text(description or 'missing affirmative API acknowledgement')}",
flush=True,
)
return False


def _telegram_send_message_endpoint(api_base_url: str | None, bot_token: str) -> str:
Expand Down
9 changes: 8 additions & 1 deletion tests/test_common_port_adapters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import unittest

from quant_platform_kit.common.models import PricePoint, PriceSeries, QuoteSnapshot
from quant_platform_kit.common.port_adapters import CallableMarketDataPort
from quant_platform_kit.common.port_adapters import CallableMarketDataPort, CallableNotificationPort


class CallableMarketDataPortTests(unittest.TestCase):
Expand Down Expand Up @@ -59,5 +59,12 @@ def test_get_price_series_raises_when_not_configured(self) -> None:
port.get_price_series("SOXL")


class CallableNotificationPortTests(unittest.TestCase):
def test_send_text_propagates_delivery_result(self) -> None:
port = CallableNotificationPort(lambda _message: False)

self.assertIs(port.send_text("rebalance"), False)


if __name__ == "__main__":
unittest.main()
22 changes: 22 additions & 0 deletions tests/test_cycle_channel.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from quant_platform_kit.notifications import cycle_channel
from quant_platform_kit.notifications import telegram


def test_telegram_cycle_sender_propagates_transport_failure(monkeypatch):
monkeypatch.setattr(
telegram,
"send_telegram_message",
lambda **_kwargs: False,
)
sender = cycle_channel.build_cycle_sender(
telegram_token="token",
telegram_chat_id="chat",
)

assert sender("rebalance") is False


def test_telegram_cycle_sender_fails_when_credentials_are_missing():
sender = cycle_channel.build_cycle_sender()

assert sender("rebalance") is False
22 changes: 22 additions & 0 deletions tests/test_notification_channels.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from quant_platform_kit.notifications import telegram
from quant_platform_kit.notifications.channel import TelegramChatChannel


def test_telegram_chat_channel_uses_current_sender_contract(monkeypatch):
observed = {}

def fake_send(**kwargs):
observed.update(kwargs)
return False

monkeypatch.setattr(telegram, "send_telegram_message", fake_send)

sent = TelegramChatChannel().send_message(
"chat",
"message",
token="token",
)

assert sent is False
assert observed["bot_token"] == "token"
assert observed["chat_ids"] == "chat"
Loading
Loading