|
8 | 8 | from collections.abc import Mapping, Sequence |
9 | 9 | from dataclasses import dataclass |
10 | 10 | from pathlib import Path |
11 | | -from typing import Any |
| 11 | +from typing import Any, Callable |
12 | 12 |
|
13 | 13 | PLUGIN_MODE_SHADOW = "shadow" |
14 | 14 | SUPPORTED_STRATEGY_PLUGIN_MODES = frozenset({PLUGIN_MODE_SHADOW}) |
15 | 15 | DEFAULT_PLUGIN_ARTIFACT_CACHE_DIR = Path(tempfile.gettempdir()) / "quant_strategy_plugin_artifacts" |
| 16 | +STRATEGY_PLUGIN_NON_ALERT_ROUTES = frozenset({"no_action"}) |
| 17 | +STRATEGY_PLUGIN_ALERT_ACTIONS = frozenset({"defend", "blocked"}) |
16 | 18 |
|
17 | 19 |
|
18 | 20 | @dataclass(frozen=True) |
@@ -59,6 +61,12 @@ def report_summary(self) -> dict[str, Any]: |
59 | 61 | } |
60 | 62 |
|
61 | 63 |
|
| 64 | +@dataclass(frozen=True) |
| 65 | +class StrategyPluginAlertMessage: |
| 66 | + subject: str |
| 67 | + body: str |
| 68 | + |
| 69 | + |
62 | 70 | def normalize_strategy_plugin_mode(value: Any, *, field_name: str = "mode") -> str: |
63 | 71 | mode = str(value or "").strip().lower() |
64 | 72 | if mode not in SUPPORTED_STRATEGY_PLUGIN_MODES: |
@@ -229,6 +237,121 @@ def build_strategy_plugin_report_payload(signals: Sequence[StrategyPluginSignal] |
229 | 237 | } |
230 | 238 |
|
231 | 239 |
|
| 240 | +def translate_strategy_plugin_value( |
| 241 | + category: str, |
| 242 | + raw_value: str | None, |
| 243 | + *, |
| 244 | + translator: Callable[..., str] | None = None, |
| 245 | +) -> str: |
| 246 | + value = str(raw_value or "").strip() or "unknown" |
| 247 | + if translator is None: |
| 248 | + return value |
| 249 | + key = f"strategy_plugin_{category}_{value}" |
| 250 | + translated = translator(key) |
| 251 | + return translated if translated != key else value |
| 252 | + |
| 253 | + |
| 254 | +def build_strategy_plugin_notification_lines( |
| 255 | + signals: Sequence[StrategyPluginSignal], |
| 256 | + *, |
| 257 | + translator: Callable[..., str] | None = None, |
| 258 | +) -> tuple[str, ...]: |
| 259 | + lines: list[str] = [] |
| 260 | + for signal in signals: |
| 261 | + route = getattr(signal, "canonical_route", None) or "unknown_route" |
| 262 | + action = getattr(signal, "suggested_action", None) or "unknown_action" |
| 263 | + lines.append( |
| 264 | + _translate( |
| 265 | + translator, |
| 266 | + "strategy_plugin_line", |
| 267 | + fallback="Plugin: {plugin} | status: {route} | notice: {action}", |
| 268 | + plugin=translate_strategy_plugin_value("name", getattr(signal, "plugin", None), translator=translator), |
| 269 | + mode=translate_strategy_plugin_value("mode", getattr(signal, "effective_mode", None), translator=translator), |
| 270 | + route=translate_strategy_plugin_value("route", route, translator=translator), |
| 271 | + action=translate_strategy_plugin_value("action", action, translator=translator), |
| 272 | + ) |
| 273 | + ) |
| 274 | + return tuple(lines) |
| 275 | + |
| 276 | + |
| 277 | +def should_alert_strategy_plugin_signal(signal: StrategyPluginSignal) -> bool: |
| 278 | + route = _normalize_strategy_plugin_field(getattr(signal, "canonical_route", None)) |
| 279 | + action = _normalize_strategy_plugin_field(getattr(signal, "suggested_action", None)) |
| 280 | + return ( |
| 281 | + bool(getattr(signal, "would_trade_if_enabled", False)) |
| 282 | + or route not in STRATEGY_PLUGIN_NON_ALERT_ROUTES |
| 283 | + or action in STRATEGY_PLUGIN_ALERT_ACTIONS |
| 284 | + ) |
| 285 | + |
| 286 | + |
| 287 | +def build_strategy_plugin_alert_messages( |
| 288 | + signals: Sequence[StrategyPluginSignal], |
| 289 | + *, |
| 290 | + translator: Callable[..., str] | None = None, |
| 291 | + strategy_label: str | None = None, |
| 292 | +) -> tuple[StrategyPluginAlertMessage, ...]: |
| 293 | + messages: list[StrategyPluginAlertMessage] = [] |
| 294 | + for signal in signals: |
| 295 | + if not should_alert_strategy_plugin_signal(signal): |
| 296 | + continue |
| 297 | + route = getattr(signal, "canonical_route", None) or "unknown_route" |
| 298 | + action = getattr(signal, "suggested_action", None) or "unknown_action" |
| 299 | + plugin = translate_strategy_plugin_value("name", getattr(signal, "plugin", None), translator=translator) |
| 300 | + translated_route = translate_strategy_plugin_value("route", route, translator=translator) |
| 301 | + translated_action = translate_strategy_plugin_value("action", action, translator=translator) |
| 302 | + strategy = str(strategy_label or getattr(signal, "strategy", None) or "").strip() or "unknown" |
| 303 | + subject = _translate( |
| 304 | + translator, |
| 305 | + "strategy_plugin_alert_subject", |
| 306 | + fallback="Strategy plugin alert: {plugin} | {route}", |
| 307 | + strategy=strategy, |
| 308 | + plugin=plugin, |
| 309 | + route=translated_route, |
| 310 | + ) |
| 311 | + body_lines = [ |
| 312 | + _translate(translator, "strategy_plugin_alert_title", fallback="Strategy Plugin Alert"), |
| 313 | + _translate( |
| 314 | + translator, |
| 315 | + "strategy_plugin_line", |
| 316 | + fallback="Plugin: {plugin} | status: {route} | notice: {action}", |
| 317 | + plugin=plugin, |
| 318 | + mode=translate_strategy_plugin_value("mode", getattr(signal, "effective_mode", None), translator=translator), |
| 319 | + route=translated_route, |
| 320 | + action=translated_action, |
| 321 | + ), |
| 322 | + _translate( |
| 323 | + translator, |
| 324 | + "strategy_plugin_alert_strategy", |
| 325 | + fallback="Strategy: {strategy}", |
| 326 | + strategy=strategy, |
| 327 | + ), |
| 328 | + _translate( |
| 329 | + translator, |
| 330 | + "strategy_plugin_alert_as_of", |
| 331 | + fallback="Signal as-of: {as_of}", |
| 332 | + as_of=getattr(signal, "as_of", None) or "unknown", |
| 333 | + ), |
| 334 | + _translate( |
| 335 | + translator, |
| 336 | + "strategy_plugin_alert_would_trade", |
| 337 | + fallback="Would trade if enabled: {value}", |
| 338 | + value=str(bool(getattr(signal, "would_trade_if_enabled", False))).lower(), |
| 339 | + ), |
| 340 | + ] |
| 341 | + source = getattr(signal, "source_uri", None) or getattr(signal, "local_path", None) |
| 342 | + if source: |
| 343 | + body_lines.append( |
| 344 | + _translate( |
| 345 | + translator, |
| 346 | + "strategy_plugin_alert_source", |
| 347 | + fallback="Source: {source}", |
| 348 | + source=source, |
| 349 | + ) |
| 350 | + ) |
| 351 | + messages.append(StrategyPluginAlertMessage(subject=subject, body="\n".join(body_lines))) |
| 352 | + return tuple(messages) |
| 353 | + |
| 354 | + |
232 | 355 | def _materialize_artifact_path(reference: str, *, client_factory: Any = None) -> tuple[Path, dict[str, str | None]]: |
233 | 356 | raw_reference = _required_string(reference, field_name="reference") |
234 | 357 | if not raw_reference.startswith("gs://"): |
@@ -283,6 +406,23 @@ def _optional_string(value: Any) -> str | None: |
283 | 406 | return text or None |
284 | 407 |
|
285 | 408 |
|
| 409 | +def _normalize_strategy_plugin_field(value: str | None) -> str: |
| 410 | + return str(value or "").strip().lower() or "unknown" |
| 411 | + |
| 412 | + |
| 413 | +def _translate( |
| 414 | + translator: Callable[..., str] | None, |
| 415 | + key: str, |
| 416 | + *, |
| 417 | + fallback: str, |
| 418 | + **kwargs: Any, |
| 419 | +) -> str: |
| 420 | + if translator is None: |
| 421 | + return fallback.format(**kwargs) |
| 422 | + translated = translator(key, **kwargs) |
| 423 | + return translated if translated != key else fallback.format(**kwargs) |
| 424 | + |
| 425 | + |
286 | 426 | def _required_string(value: Any, *, field_name: str) -> str: |
287 | 427 | text = _optional_string(value) |
288 | 428 | if text is None: |
|
0 commit comments