Skip to content

Commit 415fe37

Browse files
committed
Add strategy plugin alert guidance
1 parent 5bcbceb commit 415fe37

4 files changed

Lines changed: 138 additions & 1 deletion

File tree

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.31"
7+
version = "0.7.32"
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
@@ -56,8 +56,10 @@
5656
StrategyPluginDefinition,
5757
StrategyPluginMountConfig,
5858
StrategyPluginSignal,
59+
build_strategy_plugin_alert_guidance,
5960
build_strategy_plugin_alert_key,
6061
build_strategy_plugin_alert_messages,
62+
build_strategy_plugin_alert_scope_note,
6163
build_strategy_plugin_notification_lines,
6264
build_strategy_plugin_report_payload,
6365
load_configured_strategy_plugin_signals,
@@ -117,8 +119,10 @@
117119
"StrategyPluginDefinition",
118120
"StrategyPluginMountConfig",
119121
"StrategyPluginSignal",
122+
"build_strategy_plugin_alert_guidance",
120123
"build_strategy_plugin_alert_key",
121124
"build_strategy_plugin_alert_messages",
125+
"build_strategy_plugin_alert_scope_note",
122126
"build_strategy_plugin_notification_lines",
123127
"build_strategy_plugin_report_payload",
124128
"build_runtime_target",

src/quant_platform_kit/common/strategy_plugins.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,35 @@
2828
}
2929
)
3030
TACO_REBOUND_SHADOW_SUPPORTED_STRATEGIES = frozenset({"tqqq_growth_income"})
31+
_DEFAULT_STRATEGY_PLUGIN_ALERT_GUIDANCE: Mapping[tuple[str, str, str], str] = {
32+
(
33+
PLUGIN_CRISIS_RESPONSE_SHADOW,
34+
"true_crisis",
35+
"defend",
36+
): (
37+
"Consider reducing leveraged exposure, moving to defensive or cash-like positions, "
38+
"and pausing new risk additions until the signal de-escalates."
39+
),
40+
(
41+
PLUGIN_CRISIS_RESPONSE_SHADOW,
42+
"no_action",
43+
"blocked",
44+
): (
45+
"The crisis route was blocked by a guard; review data freshness and context before "
46+
"acting on the signal."
47+
),
48+
(
49+
PLUGIN_TACO_REBOUND_SHADOW,
50+
"taco_rebound",
51+
"notify_manual_review",
52+
): (
53+
"Manual review only: consider a small, pre-sized probe or staged entry with a "
54+
"predefined invalidation level; avoid full-size deployment from this alert alone."
55+
),
56+
}
57+
_DEFAULT_STRATEGY_PLUGIN_ALERT_SCOPE_NOTE = (
58+
"Manual review notice only; the plugin does not place orders or change allocations."
59+
)
3160

3261

3362
@dataclass(frozen=True)
@@ -445,6 +474,64 @@ def should_alert_strategy_plugin_signal(signal: StrategyPluginSignal) -> bool:
445474
)
446475

447476

477+
def build_strategy_plugin_alert_guidance(
478+
signal: StrategyPluginSignal,
479+
*,
480+
translator: Callable[..., str] | None = None,
481+
) -> str | None:
482+
plugin = _normalize_strategy_plugin_field(getattr(signal, "plugin", None))
483+
route = _normalize_strategy_plugin_field(getattr(signal, "canonical_route", None))
484+
action = _normalize_strategy_plugin_field(getattr(signal, "suggested_action", None))
485+
translated = _translate_first(
486+
translator,
487+
(
488+
f"strategy_plugin_guidance_{plugin}_{route}_{action}",
489+
f"strategy_plugin_guidance_{plugin}_{route}",
490+
f"strategy_plugin_guidance_{plugin}_{action}",
491+
f"strategy_plugin_guidance_{plugin}",
492+
f"strategy_plugin_guidance_{route}_{action}",
493+
f"strategy_plugin_guidance_{action}",
494+
),
495+
)
496+
if translated:
497+
return translated
498+
return _DEFAULT_STRATEGY_PLUGIN_ALERT_GUIDANCE.get((plugin, route, action))
499+
500+
501+
def build_strategy_plugin_alert_scope_note(
502+
signal: StrategyPluginSignal,
503+
*,
504+
translator: Callable[..., str] | None = None,
505+
) -> str | None:
506+
controls = getattr(signal, "execution_controls", {}) or {}
507+
if not isinstance(controls, Mapping):
508+
controls = {}
509+
notification_profile = str(controls.get("notification_profile") or "").strip().lower()
510+
if notification_profile != "shadow_only" and any(
511+
_as_bool(controls.get(field), default=False)
512+
for field in (
513+
"broker_order_allowed",
514+
"repository_broker_write_allowed",
515+
"live_allocation_mutation_allowed",
516+
"repository_allocation_mutation_allowed",
517+
"allocation_recommendation_allowed",
518+
"position_sizing_allowed",
519+
"selection_allowed",
520+
)
521+
):
522+
return None
523+
return (
524+
_translate_first(
525+
translator,
526+
(
527+
f"strategy_plugin_alert_scope_{_normalize_strategy_plugin_field(getattr(signal, 'plugin', None))}",
528+
"strategy_plugin_alert_scope",
529+
),
530+
)
531+
or _DEFAULT_STRATEGY_PLUGIN_ALERT_SCOPE_NOTE
532+
)
533+
534+
448535
def build_strategy_plugin_alert_key(
449536
signal: StrategyPluginSignal,
450537
*,
@@ -504,6 +591,8 @@ def build_strategy_plugin_alert_messages(
504591
translated_route = translate_strategy_plugin_value("route", route, translator=translator)
505592
translated_action = translate_strategy_plugin_value("action", action, translator=translator)
506593
strategy = str(strategy_label or getattr(signal, "strategy", None) or "").strip() or "unknown"
594+
guidance = build_strategy_plugin_alert_guidance(signal, translator=translator)
595+
scope_note = build_strategy_plugin_alert_scope_note(signal, translator=translator)
507596
subject = _translate(
508597
translator,
509598
"strategy_plugin_alert_subject",
@@ -567,6 +656,24 @@ def build_strategy_plugin_alert_messages(
567656
),
568657
]
569658
)
659+
if guidance:
660+
body_lines.append(
661+
_translate(
662+
translator,
663+
"strategy_plugin_alert_guidance",
664+
fallback="Manual guidance: {guidance}",
665+
guidance=guidance,
666+
)
667+
)
668+
if scope_note:
669+
body_lines.append(
670+
_translate(
671+
translator,
672+
"strategy_plugin_alert_scope_note",
673+
fallback="Scope: {scope_note}",
674+
scope_note=scope_note,
675+
)
676+
)
570677
metadata = {
571678
"strategy": getattr(signal, "strategy", None),
572679
"strategy_label": strategy,
@@ -577,6 +684,8 @@ def build_strategy_plugin_alert_messages(
577684
"suggested_action": getattr(signal, "suggested_action", None),
578685
"would_trade_if_enabled": bool(getattr(signal, "would_trade_if_enabled", False)),
579686
"context_label": context or None,
687+
"guidance": guidance,
688+
"scope_note": scope_note,
580689
}
581690
messages.append(
582691
StrategyPluginAlertMessage(
@@ -687,6 +796,21 @@ def _translate(
687796
return translated if translated != key else fallback.format(**kwargs)
688797

689798

799+
def _translate_first(
800+
translator: Callable[..., str] | None,
801+
keys: Sequence[str],
802+
) -> str | None:
803+
if translator is None:
804+
return None
805+
for key in keys:
806+
translated = translator(key)
807+
if translated != key:
808+
text = str(translated).strip()
809+
if text:
810+
return text
811+
return None
812+
813+
690814
def _required_string(value: Any, *, field_name: str) -> str:
691815
text = _optional_string(value)
692816
if text is None:

tests/test_strategy_plugins.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,10 +334,14 @@ def test_strategy_plugin_true_crisis_builds_generic_alert_message(self):
334334
"strategy_plugin_alert_action": "action={action}",
335335
"strategy_plugin_alert_mode": "mode={mode}",
336336
"strategy_plugin_alert_as_of": "as_of={as_of}",
337+
"strategy_plugin_alert_guidance": "guidance={guidance}",
338+
"strategy_plugin_alert_scope_note": "scope={scope_note}",
337339
"strategy_plugin_name_crisis_response_shadow": "Crisis",
338340
"strategy_plugin_mode_shadow": "shadow",
339341
"strategy_plugin_route_true_crisis": "true crisis",
340342
"strategy_plugin_action_defend": "defend",
343+
"strategy_plugin_guidance_crisis_response_shadow_true_crisis_defend": "reduce leverage or move to cash",
344+
"strategy_plugin_alert_scope": "manual review only",
341345
}
342346

343347
alerts = build_strategy_plugin_alert_messages(
@@ -355,9 +359,12 @@ def test_strategy_plugin_true_crisis_builds_generic_alert_message(self):
355359
self.assertIn("status=true crisis", alerts[0].body)
356360
self.assertIn("action=defend", alerts[0].body)
357361
self.assertIn("mode=shadow", alerts[0].body)
362+
self.assertIn("guidance=reduce leverage or move to cash", alerts[0].body)
363+
self.assertIn("scope=manual review only", alerts[0].body)
358364
self.assertNotIn("would_trade=", alerts[0].body)
359365
self.assertNotIn("source=", alerts[0].body)
360366
self.assertTrue(alerts[0].metadata["would_trade_if_enabled"])
367+
self.assertEqual(alerts[0].metadata["guidance"], "reduce leverage or move to cash")
361368

362369
def test_taco_rebound_notification_alerts_without_trade_flag(self):
363370
signal = validate_strategy_plugin_signal_payload(
@@ -377,6 +384,8 @@ def test_taco_rebound_notification_alerts_without_trade_flag(self):
377384

378385
self.assertEqual(len(alerts), 1)
379386
self.assertIn("taco_rebound_shadow", alerts[0].subject)
387+
self.assertIn("small, pre-sized probe", alerts[0].body)
388+
self.assertIn("does not place orders", alerts[0].body)
380389
self.assertFalse(alerts[0].metadata["would_trade_if_enabled"])
381390

382391

0 commit comments

Comments
 (0)