diff --git a/.env.example b/.env.example index 87455bc..d934571 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,17 @@ ACCOUNT_REGION=US NOTIFY_LANG=en TELEGRAM_TOKEN= GLOBAL_TELEGRAM_CHAT_ID= +FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON= + +# Optional independent email channel for escalated strategy plugin alerts. +CRISIS_ALERT_EMAIL_TO= +CRISIS_ALERT_EMAIL_FROM= +CRISIS_ALERT_SMTP_HOST= +CRISIS_ALERT_SMTP_PORT=587 +CRISIS_ALERT_SMTP_USERNAME= +CRISIS_ALERT_SMTP_PASSWORD= +CRISIS_ALERT_SMTP_STARTTLS=true +CRISIS_ALERT_SMTP_SSL=false # Runtime safety controls. FIRSTRADE_COOKIE_DIR=.runtime/firstrade-cookies diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 4bc7d9a..d2cf4ab 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -61,6 +61,14 @@ jobs: FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }} FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }} FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }} + CRISIS_ALERT_EMAIL_TO: ${{ vars.CRISIS_ALERT_EMAIL_TO }} + CRISIS_ALERT_EMAIL_FROM: ${{ vars.CRISIS_ALERT_EMAIL_FROM }} + CRISIS_ALERT_SMTP_HOST: ${{ vars.CRISIS_ALERT_SMTP_HOST }} + CRISIS_ALERT_SMTP_PORT: ${{ vars.CRISIS_ALERT_SMTP_PORT }} + CRISIS_ALERT_SMTP_USERNAME: ${{ vars.CRISIS_ALERT_SMTP_USERNAME }} + CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME: ${{ vars.CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME }} + CRISIS_ALERT_SMTP_STARTTLS: ${{ vars.CRISIS_ALERT_SMTP_STARTTLS }} + CRISIS_ALERT_SMTP_SSL: ${{ vars.CRISIS_ALERT_SMTP_SSL }} FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }} FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }} INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} @@ -69,6 +77,7 @@ jobs: GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }} + CRISIS_ALERT_SMTP_PASSWORD: ${{ secrets.CRISIS_ALERT_SMTP_PASSWORD }} FIRSTRADE_USERNAME: ${{ secrets.FIRSTRADE_USERNAME }} FIRSTRADE_PASSWORD: ${{ secrets.FIRSTRADE_PASSWORD }} FIRSTRADE_MFA_SECRET: ${{ secrets.FIRSTRADE_MFA_SECRET }} @@ -424,6 +433,13 @@ jobs: add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON + add_optional_env CRISIS_ALERT_EMAIL_TO + add_optional_env CRISIS_ALERT_EMAIL_FROM + add_optional_env CRISIS_ALERT_SMTP_HOST + add_optional_env CRISIS_ALERT_SMTP_PORT + add_optional_env CRISIS_ALERT_SMTP_USERNAME + add_optional_env CRISIS_ALERT_SMTP_STARTTLS + add_optional_env CRISIS_ALERT_SMTP_SSL add_optional_env FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS add_optional_env INCOME_THRESHOLD_USD @@ -433,6 +449,7 @@ jobs: add_optional_env NOTIFY_LANG add_optional_secret TELEGRAM_TOKEN TELEGRAM_TOKEN_SECRET_NAME TELEGRAM_TOKEN + add_optional_secret CRISIS_ALERT_SMTP_PASSWORD CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME CRISIS_ALERT_SMTP_PASSWORD add_optional_secret FIRSTRADE_USERNAME FIRSTRADE_USERNAME_SECRET_NAME FIRSTRADE_USERNAME add_optional_secret FIRSTRADE_PASSWORD FIRSTRADE_PASSWORD_SECRET_NAME FIRSTRADE_PASSWORD add_optional_secret FIRSTRADE_MFA_SECRET FIRSTRADE_MFA_SECRET_SECRET_NAME FIRSTRADE_MFA_SECRET diff --git a/README.md b/README.md index acea97d..e445fc5 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,15 @@ commit credentials. | `NOTIFY_LANG` | Optional | Notification language, `en` or `zh` | | `TELEGRAM_TOKEN` | Optional | Telegram bot token for strategy-cycle summaries | | `GLOBAL_TELEGRAM_CHAT_ID` | Optional | Telegram chat ID for strategy-cycle summaries | +| `FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON` | Optional | JSON sidecar plugin mount config. Overrides global `STRATEGY_PLUGIN_MOUNTS_JSON` for this platform | +| `CRISIS_ALERT_EMAIL_TO` | Optional | Comma, semicolon, or newline separated recipients for escalated strategy plugin email alerts | +| `CRISIS_ALERT_EMAIL_FROM` | Optional | SMTP sender address for escalated strategy plugin email alerts | +| `CRISIS_ALERT_SMTP_HOST` | Optional | SMTP host for escalated strategy plugin email alerts | +| `CRISIS_ALERT_SMTP_PORT` | Optional | SMTP port. Defaults to `587` | +| `CRISIS_ALERT_SMTP_USERNAME` | Optional | SMTP username when authentication is required | +| `CRISIS_ALERT_SMTP_PASSWORD` | Optional | SMTP password, preferably supplied from Secret Manager in Cloud Run | +| `CRISIS_ALERT_SMTP_STARTTLS` | Optional | Enable STARTTLS for SMTP. Defaults to `true` | +| `CRISIS_ALERT_SMTP_SSL` | Optional | Use SMTP over SSL. Defaults to `false` | | `FIRSTRADE_COOKIE_DIR` | Optional | Cookie cache directory, default `.runtime/firstrade-cookies` | | `FIRSTRADE_ENABLE_LIVE_TRADING` | Optional | Must be `true` before any live order can be submitted | | `FIRSTRADE_RUN_SMOKE_ON_HTTP` | Optional | Must be `true` before `/smoke` performs a real login/quote | @@ -165,10 +174,13 @@ full guarded strategy cycle: - connect to Firstrade with the unofficial client - read the selected account, balances, positions, quotes, and OHLC history - load the selected shared `UsEquityStrategies` runtime +- load configured shared strategy plugin signal artifacts without changing core strategy logic - map the strategy decision into a value-target Firstrade plan - route generated orders through the local safety layer - publish a compact Telegram summary when `TELEGRAM_TOKEN` and `GLOBAL_TELEGRAM_CHAT_ID` are configured +- send independent SMTP email alerts for escalated strategy plugin signals when + `CRISIS_ALERT_*` is configured The default mode remains dry-run. A live HTTP-triggered strategy order requires all of these gates: @@ -299,6 +311,8 @@ Firstrade 登录、账户/行情读取、下单转换、安全闸和部署 wirin - dry-run / preview 下单验证 - `/run` 执行通用美股策略的 dry-run 调仓闭环 - 配置 `TELEGRAM_TOKEN` 和 `GLOBAL_TELEGRAM_CHAT_ID` 后发送运行摘要 +- 读取通用策略插件信号,并在危机类插件触发时通过 `CRISIS_ALERT_*` + 配置发送独立邮件告警 - 在你再次确认后,才允许极小金额实盘验证 - 通用 `us_equity` 策略 profile 的平台层接入 diff --git a/application/rebalance_service.py b/application/rebalance_service.py index e66d142..cb0e62c 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -44,6 +44,14 @@ build_semiconductor_rotation_indicators_from_history, required_semiconductor_rotation_history_lookback, ) +from quant_platform_kit.common.strategy_plugins import ( + build_strategy_plugin_alert_messages, + build_strategy_plugin_notification_lines, + build_strategy_plugin_report_payload, + load_configured_strategy_plugin_signals, + parse_strategy_plugin_mounts, +) +from quant_platform_kit.notifications.email import send_smtp_email from quant_platform_kit.notifications.events import NotificationPublisher, RenderedNotification from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings @@ -162,6 +170,101 @@ def publish_log(text: str) -> None: return True +def load_strategy_plugin_signals( + raw_mounts, + *, + strategy_profile: str, + parse_mounts_fn=parse_strategy_plugin_mounts, + load_signals_fn=load_configured_strategy_plugin_signals, +): + if not raw_mounts: + return (), None + try: + mounts = parse_mounts_fn(raw_mounts) + if not mounts: + return (), None + return load_signals_fn(mounts, strategy_profile=strategy_profile), None + except Exception as exc: + return (), f"{type(exc).__name__}: {exc}" + + +def attach_strategy_plugin_result( + result: dict[str, Any], + *, + signals, + error: str | None, + translator: Callable[..., str], +) -> dict[str, Any]: + if signals: + result.update(build_strategy_plugin_report_payload(signals)) + notification_lines = build_strategy_plugin_notification_lines( + signals, + translator=translator, + ) + if notification_lines: + result["strategy_plugin_lines"] = notification_lines + if error: + result["strategy_plugin_error"] = error + return result + + +def _call_log_message(log_message: Callable[..., Any], text: str) -> None: + try: + log_message(text, flush=True) + except TypeError: + log_message(text) + + +def send_crisis_alert_email( + alert_message, + *, + settings: PlatformRuntimeSettings, + smtp_module=None, + log_message: Callable[..., Any] = print, +) -> bool: + send_kwargs: dict[str, Any] = {} + if smtp_module is not None: + send_kwargs["smtp_module"] = smtp_module + return send_smtp_email( + subject=alert_message.subject, + body=alert_message.body, + smtp_host=getattr(settings, "crisis_alert_smtp_host", None), + smtp_port=getattr(settings, "crisis_alert_smtp_port", 587), + sender=getattr(settings, "crisis_alert_email_from", None), + recipients=getattr(settings, "crisis_alert_email_to", ()), + username=getattr(settings, "crisis_alert_smtp_username", None), + password=getattr(settings, "crisis_alert_smtp_password", None), + use_starttls=getattr(settings, "crisis_alert_smtp_starttls", True), + use_ssl=getattr(settings, "crisis_alert_smtp_ssl", False), + printer=lambda text, **_kwargs: _call_log_message(log_message, text), + **send_kwargs, + ) + + +def publish_strategy_plugin_alerts( + signals, + *, + settings: PlatformRuntimeSettings, + translator: Callable[..., str], + log_message: Callable[..., Any] = print, +) -> int: + sent_count = 0 + for alert_message in build_strategy_plugin_alert_messages( + signals, + translator=translator, + strategy_label=settings.strategy_profile, + ): + if send_crisis_alert_email( + alert_message, + settings=settings, + log_message=log_message, + ): + sent_count += 1 + if sent_count: + _call_log_message(log_message, f"strategy_plugin_alert_email_sent count={sent_count}") + return sent_count + + def _runtime_metadata_with_execution_policy( metadata: Mapping[str, Any] | None, *, @@ -186,6 +289,11 @@ def run_strategy_cycle( ) -> dict[str, Any]: now = _utcnow() settings = runtime_settings or load_platform_runtime_settings(project_id_resolver=get_project_id) + translator = build_translator(settings.notify_lang) + strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals( + settings.strategy_plugin_mounts_json, + strategy_profile=settings.strategy_profile, + ) resolved_credentials = credentials or FirstradeCredentials.from_env(env_reader) store = state_store or build_gcs_state_store_from_env(env_reader) persist_strategy_runs = bool(settings.persist_strategy_runs and store is not None) @@ -227,7 +335,7 @@ def run_strategy_cycle( available_inputs=available_inputs, market_inputs=market_inputs, portfolio_snapshot=snapshot, - translator=build_translator(settings.notify_lang), + translator=translator, ) evaluation = strategy_runtime.evaluate(**evaluation_inputs) plan = map_strategy_decision_to_plan( @@ -258,7 +366,7 @@ def run_strategy_cycle( run_period=run_period, ) if is_duplicate_live_run(existing_run): - return { + result = { "ok": True, "api_kind": "unofficial-reverse-engineered", "account": masked_account, @@ -280,7 +388,24 @@ def run_strategy_cycle( } ], "action_done": False, + "strategy_plugin_alert_email_sent_count": 0, } + return attach_strategy_plugin_result( + result, + signals=strategy_plugin_signals, + error=strategy_plugin_error, + translator=translator, + ) + strategy_plugin_alert_email_sent_count = 0 + strategy_plugin_alert_email_error = None + try: + strategy_plugin_alert_email_sent_count = publish_strategy_plugin_alerts( + strategy_plugin_signals, + settings=settings, + translator=translator, + ) + except Exception as exc: + strategy_plugin_alert_email_error = f"{type(exc).__name__}: {exc}" strategy_run_persisted = False strategy_run_persistence_error = None if persist_strategy_runs: @@ -357,6 +482,15 @@ def run_strategy_cycle( result["funding_blocked"] = True if strategy_run_persistence_error: result["strategy_run_persistence_error"] = strategy_run_persistence_error + result["strategy_plugin_alert_email_sent_count"] = strategy_plugin_alert_email_sent_count + if strategy_plugin_alert_email_error: + result["strategy_plugin_alert_email_error"] = strategy_plugin_alert_email_error + attach_strategy_plugin_result( + result, + signals=strategy_plugin_signals, + error=strategy_plugin_error, + translator=translator, + ) if persist_strategy_runs: completed_state = build_strategy_run_state( stage=strategy_run_stage, diff --git a/notifications/telegram.py b/notifications/telegram.py index d12c8d2..172d112 100644 --- a/notifications/telegram.py +++ b/notifications/telegram.py @@ -36,6 +36,24 @@ "quantity_share": "{quantity}股", "quantity_shares": "{quantity}股", "signal_label": "信号", + "strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}", + "strategy_plugin_alert_subject": "🚨 危机插件告警:{plugin} | {route}", + "strategy_plugin_alert_title": "🚨 【危机插件告警】", + "strategy_plugin_alert_strategy": "策略:{strategy}", + "strategy_plugin_alert_as_of": "信号时间:{as_of}", + "strategy_plugin_alert_would_trade": "若启用交易会操作:{value}", + "strategy_plugin_alert_source": "来源:{source}", + "strategy_plugin_name_crisis_response_shadow": "危机观察通知", + "strategy_plugin_mode_shadow": "影子观察", + "strategy_plugin_route_no_action": "未触发危机", + "strategy_plugin_route_true_crisis": "真危机", + "strategy_plugin_route_unknown_route": "未知状态", + "strategy_plugin_action_no_action": "不操作", + "strategy_plugin_action_watch_only": "仅通知", + "strategy_plugin_action_defend": "防守", + "strategy_plugin_action_blocked": "已阻断", + "strategy_plugin_action_monitor": "持续观察", + "strategy_plugin_action_unknown_action": "未知提醒", "separator": SEPARATOR, "same_trading_day": "当日执行", "next_trading_day": "次一交易日执行", @@ -118,6 +136,24 @@ "quantity_share": "{quantity} share", "quantity_shares": "{quantity} shares", "signal_label": "Signal", + "strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}", + "strategy_plugin_alert_subject": "🚨 Crisis plugin alert: {plugin} | {route}", + "strategy_plugin_alert_title": "🚨 【Crisis Plugin Alert】", + "strategy_plugin_alert_strategy": "Strategy: {strategy}", + "strategy_plugin_alert_as_of": "Signal as-of: {as_of}", + "strategy_plugin_alert_would_trade": "Would trade if enabled: {value}", + "strategy_plugin_alert_source": "Source: {source}", + "strategy_plugin_name_crisis_response_shadow": "Crisis Watch Notice", + "strategy_plugin_mode_shadow": "shadow", + "strategy_plugin_route_no_action": "no crisis detected", + "strategy_plugin_route_true_crisis": "true crisis", + "strategy_plugin_route_unknown_route": "unknown status", + "strategy_plugin_action_no_action": "no action", + "strategy_plugin_action_watch_only": "notify only", + "strategy_plugin_action_defend": "defend", + "strategy_plugin_action_blocked": "blocked", + "strategy_plugin_action_monitor": "watch", + "strategy_plugin_action_unknown_action": "unknown notice", "separator": SEPARATOR, "same_trading_day": "same trading day", "next_trading_day": "next trading day", @@ -575,6 +611,7 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str: lines.extend(dashboard_lines) lines.extend(_format_timing_lines(execution, translator=translator)) lines.extend(_format_signal_lines(execution, translator=translator)) + lines.extend(str(line).strip() for line in result.get("strategy_plugin_lines") or ()) lines.append(SEPARATOR) lines.extend(target_diff_lines) if submitted: diff --git a/pyproject.toml b/pyproject.toml index cb7ecb9..0695615 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,8 +14,9 @@ authors = [ ] dependencies = [ "firstrade==0.0.38", - "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9c4ea7878a08fb2f518c74c99bda68d8ef8fd0bb", - "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ce7887482eeab7f519484610ee8b20cb7bc886a0", + "quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1b6febbba7df81179ad7579f430c26a811c0e1a8", + "us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@1636271a3e0c17fc0c5da363f67eabe114eeff48", + "google-cloud-storage", "requests", ] diff --git a/requirements.txt b/requirements.txt index 5526db4..4e0396f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,8 @@ flask gunicorn firstrade==0.0.38 -quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9c4ea7878a08fb2f518c74c99bda68d8ef8fd0bb -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ce7887482eeab7f519484610ee8b20cb7bc886a0 +quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1b6febbba7df81179ad7579f430c26a811c0e1a8 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@1636271a3e0c17fc0c5da363f67eabe114eeff48 +google-cloud-storage requests pytest diff --git a/runtime_config_support.py b/runtime_config_support.py index 61a604e..f33794d 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -58,6 +58,14 @@ class PlatformRuntimeSettings: strategy_config_path: str | None = None strategy_config_source: str | None = None strategy_plugin_mounts_json: str | None = None + crisis_alert_email_to: tuple[str, ...] = () + crisis_alert_email_from: str | None = None + crisis_alert_smtp_host: str | None = None + crisis_alert_smtp_port: int = 587 + crisis_alert_smtp_username: str | None = None + crisis_alert_smtp_password: str | None = None + crisis_alert_smtp_starttls: bool = True + crisis_alert_smtp_ssl: bool = False runtime_target: RuntimeTarget | None = None @@ -145,6 +153,14 @@ def load_platform_runtime_settings( os.getenv("FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON") or os.getenv("STRATEGY_PLUGIN_MOUNTS_JSON") ), + crisis_alert_email_to=_split_env_list(os.getenv("CRISIS_ALERT_EMAIL_TO")), + crisis_alert_email_from=_first_non_empty(os.getenv("CRISIS_ALERT_EMAIL_FROM")), + crisis_alert_smtp_host=_first_non_empty(os.getenv("CRISIS_ALERT_SMTP_HOST")), + crisis_alert_smtp_port=_resolve_positive_int_env("CRISIS_ALERT_SMTP_PORT", default=587), + crisis_alert_smtp_username=_first_non_empty(os.getenv("CRISIS_ALERT_SMTP_USERNAME")), + crisis_alert_smtp_password=_first_non_empty(os.getenv("CRISIS_ALERT_SMTP_PASSWORD")), + crisis_alert_smtp_starttls=_resolve_bool_env("CRISIS_ALERT_SMTP_STARTTLS", default=True), + crisis_alert_smtp_ssl=_resolve_bool_env("CRISIS_ALERT_SMTP_SSL", default=False), runtime_target=runtime_target, ) @@ -203,6 +219,45 @@ def _resolve_ratio_env(name: str, *, default: float) -> float: return value +def _first_non_empty(raw_value: str | None) -> str | None: + value = str(raw_value or "").strip() + return value or None + + +def _resolve_bool_env(name: str, *, default: bool) -> bool: + raw_value = os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return bool(default) + return resolve_bool_value(raw_value) + + +def _resolve_positive_int_env(name: str, *, default: int) -> int: + raw_value = os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return int(default) + try: + value = int(raw_value) + except (TypeError, ValueError): + raise ValueError(f"{name} must be a positive integer, got {raw_value!r}") from None + if value <= 0: + raise ValueError(f"{name} must be a positive integer, got {raw_value!r}") + return value + + +def _split_env_list(raw_value: str | None) -> tuple[str, ...]: + if raw_value is None: + return () + items = [] + seen = set() + for value in str(raw_value).replace(";", ",").replace("\n", ",").split(","): + item = value.strip() + if not item or item in seen: + continue + items.append(item) + seen.add(item) + return tuple(items) + + def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None: raw_value = os.getenv("FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS") env_name = "FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS" diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 9b98785..8cd8244 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json from datetime import datetime, timezone from types import SimpleNamespace @@ -191,6 +192,98 @@ def fake_client_factory(*args, **kwargs): assert "🧪 Dry-run limit buy AAA: 2 shares @ $10.05" in messages[0] +def test_run_strategy_cycle_loads_strategy_plugin_report_and_sends_email( + monkeypatch, + tmp_path, +): + signal_path = tmp_path / "latest_signal.json" + signal_path.write_text( + json.dumps( + { + "strategy": "tqqq_growth_income", + "plugin": "crisis_response_shadow", + "mode": "shadow", + "configured_mode": "shadow", + "effective_mode": "shadow", + "schema_version": "1.0", + "as_of": "2026-05-24", + "canonical_route": "true_crisis", + "suggested_action": "defend", + "would_trade_if_enabled": True, + "execution_controls": {}, + } + ), + encoding="utf-8", + ) + mount_config = json.dumps( + { + "strategy_plugins": [ + { + "strategy": "tqqq_growth_income", + "plugin": "crisis_response_shadow", + "signal_path": str(signal_path), + } + ] + } + ) + settings = _runtime_settings_with_persistence( + strategy_plugin_mounts_json=mount_config, + crisis_alert_email_to=("risk@example.com",), + crisis_alert_email_from="bot@example.com", + crisis_alert_smtp_host="smtp.example.com", + ) + messages = [] + observed_alerts = [] + + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: FakeStrategyRuntime(), + ) + monkeypatch.setattr( + "application.rebalance_service.send_crisis_alert_email", + lambda alert_message, **_kwargs: observed_alerts.append(alert_message) or True, + ) + + result = run_strategy_cycle( + runtime_settings=settings, + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=FakeFirstradeClient, + notification_sender=messages.append, + env_reader=lambda _name, default=None: default, + ) + + assert result["strategy_plugins"][0]["canonical_route"] == "true_crisis" + assert result["strategy_plugin_alert_email_sent_count"] == 1 + assert result["strategy_plugin_lines"] == ( + "🧩 Plugin: Crisis Watch Notice | status: true crisis | notice: defend", + ) + assert len(observed_alerts) == 1 + assert observed_alerts[0].subject == "🚨 Crisis plugin alert: Crisis Watch Notice | true crisis" + assert "Would trade if enabled: true" in observed_alerts[0].body + assert "🧩 Plugin: Crisis Watch Notice | status: true crisis | notice: defend" in messages[0] + + +def test_run_strategy_cycle_strategy_plugin_load_error_is_non_blocking(monkeypatch): + settings = _runtime_settings_with_persistence(strategy_plugin_mounts_json="{bad-json") + + monkeypatch.setattr( + "application.rebalance_service.load_strategy_runtime", + lambda *_args, **_kwargs: FakeStrategyRuntime(), + ) + + result = run_strategy_cycle( + runtime_settings=settings, + credentials=FirstradeCredentials(username="user", password="pass"), + client_factory=FakeFirstradeClient, + env_reader=lambda _name, default=None: default, + ) + + assert result["ok"] is True + assert result["action_done"] is True + assert result["strategy_plugin_error"].startswith("JSONDecodeError:") + assert result["strategy_plugin_alert_email_sent_count"] == 0 + + def test_run_strategy_cycle_persists_strategy_run_state(monkeypatch): store = FakeStateStore() diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 2af0190..21c757b 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -53,6 +53,10 @@ def test_reserved_cash_policy_defaults_to_zero(monkeypatch): assert settings.reserved_cash_floor_usd == 0.0 assert settings.reserved_cash_ratio == 0.0 + assert settings.crisis_alert_email_to == () + assert settings.crisis_alert_smtp_port == 587 + assert settings.crisis_alert_smtp_starttls is True + assert settings.crisis_alert_smtp_ssl is False def test_reserved_cash_policy_loads_from_env(monkeypatch): @@ -66,6 +70,29 @@ def test_reserved_cash_policy_loads_from_env(monkeypatch): assert settings.reserved_cash_ratio == 0.025 +def test_crisis_alert_email_settings_load_from_env(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json()) + monkeypatch.setenv("CRISIS_ALERT_EMAIL_TO", "risk@example.com;ops@example.com,risk@example.com") + monkeypatch.setenv("CRISIS_ALERT_EMAIL_FROM", "bot@example.com") + monkeypatch.setenv("CRISIS_ALERT_SMTP_HOST", "smtp.example.com") + monkeypatch.setenv("CRISIS_ALERT_SMTP_PORT", "465") + monkeypatch.setenv("CRISIS_ALERT_SMTP_USERNAME", "bot") + monkeypatch.setenv("CRISIS_ALERT_SMTP_PASSWORD", "secret") + monkeypatch.setenv("CRISIS_ALERT_SMTP_STARTTLS", "false") + monkeypatch.setenv("CRISIS_ALERT_SMTP_SSL", "true") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.crisis_alert_email_to == ("risk@example.com", "ops@example.com") + assert settings.crisis_alert_email_from == "bot@example.com" + assert settings.crisis_alert_smtp_host == "smtp.example.com" + assert settings.crisis_alert_smtp_port == 465 + assert settings.crisis_alert_smtp_username == "bot" + assert settings.crisis_alert_smtp_password == "secret" + assert settings.crisis_alert_smtp_starttls is False + assert settings.crisis_alert_smtp_ssl is True + + def test_reserved_cash_ratio_rejects_invalid_env(monkeypatch): monkeypatch.setenv("FIRSTRADE_RESERVED_CASH_RATIO", "1.25") diff --git a/tests/test_sync_cloud_run_env_workflow.py b/tests/test_sync_cloud_run_env_workflow.py new file mode 100644 index 0000000..1f12c6c --- /dev/null +++ b/tests/test_sync_cloud_run_env_workflow.py @@ -0,0 +1,30 @@ +from __future__ import annotations + +from pathlib import Path + + +def test_sync_cloud_run_env_workflow_syncs_crisis_alert_settings(): + workflow_path = Path(__file__).resolve().parents[1] / ".github/workflows/sync-cloud-run-env.yml" + workflow = workflow_path.read_text(encoding="utf-8") + + for name in ( + "CRISIS_ALERT_EMAIL_TO", + "CRISIS_ALERT_EMAIL_FROM", + "CRISIS_ALERT_SMTP_HOST", + "CRISIS_ALERT_SMTP_PORT", + "CRISIS_ALERT_SMTP_USERNAME", + "CRISIS_ALERT_SMTP_STARTTLS", + "CRISIS_ALERT_SMTP_SSL", + ): + assert f"{name}: ${{{{ vars.{name} }}}}" in workflow + assert f"add_optional_env {name}" in workflow + + assert ( + "CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME: " + "${{ vars.CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME }}" + ) in workflow + assert "CRISIS_ALERT_SMTP_PASSWORD: ${{ secrets.CRISIS_ALERT_SMTP_PASSWORD }}" in workflow + assert ( + "add_optional_secret CRISIS_ALERT_SMTP_PASSWORD " + "CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME CRISIS_ALERT_SMTP_PASSWORD" + ) in workflow