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
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@

_DEFAULT_TELEGRAM_BODY_MAX_CHARS = 3900
_MARKET_REGIME_CONTROL_PLUGIN = "market_regime_control"
_AUTOMATED_POSITION_ACTIONS = frozenset({"defend", "delever"})
_MANUAL_REVIEW_ACTIONS = frozenset({"notify_manual_review"})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Include watch-only signals in Telegram filtering

The runtime contract in docs/strategy_plugin_runtime_contract.md:203-205 lists watch_only as remaining in the plugin-alert stream, but this new action set only allows notify_manual_review. For a strategy-mounted watch signal with canonical_route='watch', suggested_action='watch_only', and the default controls (no notification_profile/capital_impact), build_strategy_plugin_alert_messages() would create an alert because the route is not no_action, yet the Telegram prefilter rejects it before the builder runs, so documented watch-only Telegram alerts disappear.

Useful? React with 👍 / 👎.

_ZH_MARKET_REGIME_ROUTE_LABELS = {
"true_crisis": "黑天鹅",
"crisis": "黑天鹅",
Expand Down Expand Up @@ -164,11 +166,15 @@ def publish_strategy_plugin_telegram_alerts(
log_message: Callable[..., Any] = print,
) -> StrategyPluginTelegramAlertPublishResult:
settings = StrategyPluginTelegramSettings.from_object(telegram_settings)
del context_label # The plugin Telegram bot is a unified manual-review channel.
manual_review_signals = tuple(
signal for signal in signals if _is_manual_review_telegram_signal(signal)
)
messages = build_strategy_plugin_alert_messages(
signals,
manual_review_signals,
Comment on lines +170 to +174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep ordinary Telegram channel alerts dispatchable

When publish_strategy_plugin_alerts() is configured with channels=("telegram",) for a regular alerting plugin signal such as the existing crisis_response_shadow true_crisis/defend signal with no notification-only controls, this prefilter drops the signal before the shared alert-builder logic can send it. The other channels still deliver it and the dispatcher tests now lose the Telegram send/count, so Telegram-only alert deployments silently miss these ordinary plugin alerts.

Useful? React with 👍 / 👎.

translator=translator,
strategy_label=strategy_label,
context_label=context_label,
context_label=None,
alert_namespace="strategy_plugin_telegram_alert",
)
deliveries: list[StrategyPluginTelegramAlertDelivery] = []
Expand Down Expand Up @@ -210,6 +216,63 @@ def publish_strategy_plugin_telegram_alerts(
return result


def _is_manual_review_telegram_signal(signal: object) -> bool:
if _is_auto_consumable_signal(signal):
return False
target_type = str(getattr(signal, "target_type", None) or "strategy").strip().lower()
if target_type == "notification_target":
return True
action = str(getattr(signal, "suggested_action", None) or "").strip().lower()
if action in _MANUAL_REVIEW_ACTIONS:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve blocked guard alerts in Telegram filtering

When a guard/data-quality signal arrives with suggested_action='blocked' and no notification_only controls, the shared alert builder still treats it as an alert (blocked is in STRATEGY_PLUGIN_ALERT_ACTIONS), but this new prefilter only lets notify_manual_review or notification-only signals reach build_strategy_plugin_alert_messages(). That makes market_regime_control/blocked or crisis guard-blocked alerts produce zero Telegram deliveries, so operators miss the alert that tells them to fix the blocked data/source condition.

Useful? React with 👍 / 👎.

return True
controls = _signal_execution_controls(signal)
policy = _signal_consumption_policy(signal)
status = str(
controls.get("consumption_evidence_status")
or policy.get("evidence_status")
or ""
).strip().lower()
if status == "notification_only":
return True
for key in ("capital_impact", "notification_profile"):
value = str(controls.get(key) or policy.get(key) or "").strip().lower()
if value in {"notification_only", "shadow_only"}:
return True
return False


def _is_auto_consumable_signal(signal: object) -> bool:
action = str(getattr(signal, "suggested_action", None) or "").strip().lower()
if action not in _AUTOMATED_POSITION_ACTIONS:
return False
controls = _signal_execution_controls(signal)
policy = _signal_consumption_policy(signal)
status = str(
controls.get("consumption_evidence_status")
or policy.get("evidence_status")
or ""
).strip().lower()
if status != "automation_approved":
return False
return (
_coerce_bool(controls.get("position_control_allowed"), default=False)
or _coerce_bool(policy.get("position_control_allowed"), default=False)
)
Comment on lines +257 to +260

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require runtime metadata before suppressing Telegram alerts

For defend/delever signals where consumption_evidence_status='automation_approved' and position_control_allowed=True but strategy_runtime_metadata_allowed=False, should_alert_strategy_plugin_signal() still emits an alert because _is_strategy_position_control_notice() only hands signals to the platform cycle when runtime metadata is allowed. This new predicate suppresses Telegram based only on position-control permission, so those defensive/delever signals are neither carried by the platform cycle notification nor sent to the plugin Telegram channel.

Useful? React with 👍 / 👎.



def _signal_execution_controls(signal: object) -> Mapping[str, Any]:
controls = getattr(signal, "execution_controls", {}) or {}
return controls if isinstance(controls, Mapping) else {}


def _signal_consumption_policy(signal: object) -> Mapping[str, Any]:
payload = getattr(signal, "payload", {}) or {}
if not isinstance(payload, Mapping):
return {}
policy = payload.get("consumption_policy") or {}
return policy if isinstance(policy, Mapping) else {}


def _delivery(
message: StrategyPluginAlertMessage,
*,
Expand Down Expand Up @@ -277,6 +340,7 @@ def _build_compact_market_regime_body(message: StrategyPluginAlertMessage) -> st
if use_zh:
return "\n".join(
(
"统一人工复核信号",
f"日期:{as_of}",
f"市场状态:{_market_regime_route_label(route, use_zh=True)}",
f"背景情况:{background}",
Expand All @@ -285,6 +349,7 @@ def _build_compact_market_regime_body(message: StrategyPluginAlertMessage) -> st
)
return "\n".join(
(
"Unified manual-review signal",
f"Date: {as_of}",
f"Market state: {_market_regime_route_label(route, use_zh=False)}",
f"Background: {background}",
Expand Down Expand Up @@ -420,5 +485,3 @@ def _coerce_int(value: Any, default: int) -> int:

def _fallback_alert_key(message: StrategyPluginAlertMessage) -> str:
return "strategy_plugin_telegram_alert/" + _clean_relative_key(message.subject or "unknown")


11 changes: 7 additions & 4 deletions tests/test_strategy_plugin_alert_dispatcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,13 @@
def _alert_signal():
return SimpleNamespace(
strategy="tqqq_growth_income",
plugin="crisis_response_shadow",
plugin="market_regime_control",
effective_mode="shadow",
as_of="2026-05-24",
canonical_route="true_crisis",
suggested_action="defend",
would_trade_if_enabled=True,
canonical_route="opportunity_watch",
suggested_action="notify_manual_review",
would_trade_if_enabled=False,
execution_controls={"notification_profile": "shadow_only"},
)


Expand Down Expand Up @@ -199,6 +200,8 @@ def test_publish_strategy_plugin_alerts_can_target_telegram_channel(self):
self.assertIsNotNone(result.telegram_result)
self.assertEqual(result.sent_count, 1)
self.assertEqual(telegram_messages[0]["chat_ids"], ("123456",))
self.assertIn("Unified manual-review signal", telegram_messages[0]["body"])
self.assertNotIn("ibkr / live-slot-a", telegram_messages[0]["body"])

def test_publish_strategy_plugin_alerts_reads_channels_from_settings(self):
settings = _NotificationSettings()
Expand Down
43 changes: 40 additions & 3 deletions tests/test_strategy_plugin_telegram_notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,30 @@
def _alert_signal():
return SimpleNamespace(
strategy="tqqq_growth_income",
plugin="crisis_response_shadow",
plugin="market_regime_control",
effective_mode="shadow",
as_of="2026-05-24",
canonical_route="true_crisis",
canonical_route="opportunity_watch",
suggested_action="notify_manual_review",
would_trade_if_enabled=False,
execution_controls={"notification_profile": "shadow_only"},
)


def _auto_consumable_signal():
return SimpleNamespace(
strategy="tqqq_growth_income",
plugin="market_regime_control",
effective_mode="shadow",
as_of="2026-05-24",
canonical_route="risk_off",
suggested_action="defend",
would_trade_if_enabled=True,
execution_controls={
"strategy_runtime_metadata_allowed": True,
"position_control_allowed": True,
"consumption_evidence_status": "automation_approved",
},
)


Expand Down Expand Up @@ -172,9 +190,27 @@ def test_publish_strategy_plugin_telegram_alerts_sends_and_dedupes(self):
self.assertEqual(second.skipped_count, 1)
self.assertEqual(second.deliveries[0].reason, "duplicate_alert")
self.assertEqual(calls[0]["chat_ids"], ("123",))
self.assertIn("ibkr / live-slot-a", calls[0]["body"])
self.assertIn("Unified manual-review signal", calls[0]["body"])
self.assertNotIn("ibkr / live-slot-a", calls[0]["body"])
self.assertTrue(tmp_dir)

def test_publish_strategy_plugin_telegram_alerts_skips_auto_consumable_signals(self):
calls = []

result = publish_strategy_plugin_telegram_alerts(
[_auto_consumable_signal()],
telegram_settings=StrategyPluginTelegramSettings(
chat_ids=("123",),
bot_token="bot-token",
),
send_notification=lambda **kwargs: calls.append(kwargs) or True,
log_message=lambda *_args, **_kwargs: None,
)

self.assertEqual(result.attempted_count, 0)
self.assertEqual(result.sent_count, 0)
self.assertEqual(calls, [])

def test_publish_strategy_plugin_telegram_alerts_compacts_market_regime_body(self):
calls = []
translations = STRATEGY_PLUGIN_I18N["zh"]
Expand Down Expand Up @@ -218,6 +254,7 @@ def test_publish_strategy_plugin_telegram_alerts_compacts_market_regime_body(sel
calls[0]["body"],
"\n".join(
(
"统一人工复核信号",
"日期:2026-06-19",
"市场状态:抄底机会",
"背景情况:机会观察:出现可能的反弹或低吸窗口,当前证据只够人工复核。",
Expand Down
Loading