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
14 changes: 14 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,9 @@ jobs:
IBKR_MIN_RESERVED_CASH_USD: ${{ vars.IBKR_MIN_RESERVED_CASH_USD }}
IBKR_RESERVED_CASH_RATIO: ${{ vars.IBKR_RESERVED_CASH_RATIO }}
IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}
CRISIS_ALERT_GOOGLE_VOICE_TO: ${{ vars.CRISIS_ALERT_GOOGLE_VOICE_TO }}
CRISIS_ALERT_EMAIL_TO: ${{ vars.CRISIS_ALERT_EMAIL_TO }}
CRISIS_ALERT_SMTP_FROM: ${{ vars.CRISIS_ALERT_SMTP_FROM }}
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 }}
Expand Down Expand Up @@ -364,12 +366,24 @@ jobs:
remove_env_vars+=("IBKR_RESERVED_CASH_RATIO")
fi

if [ -n "${CRISIS_ALERT_GOOGLE_VOICE_TO:-}" ]; then
env_pairs+=("CRISIS_ALERT_GOOGLE_VOICE_TO=${CRISIS_ALERT_GOOGLE_VOICE_TO}")
else
remove_env_vars+=("CRISIS_ALERT_GOOGLE_VOICE_TO")
fi

if [ -n "${CRISIS_ALERT_EMAIL_TO:-}" ]; then
env_pairs+=("CRISIS_ALERT_EMAIL_TO=${CRISIS_ALERT_EMAIL_TO}")
else
remove_env_vars+=("CRISIS_ALERT_EMAIL_TO")
fi

if [ -n "${CRISIS_ALERT_SMTP_FROM:-}" ]; then
env_pairs+=("CRISIS_ALERT_SMTP_FROM=${CRISIS_ALERT_SMTP_FROM}")
else
remove_env_vars+=("CRISIS_ALERT_SMTP_FROM")
fi

if [ -n "${CRISIS_ALERT_EMAIL_FROM:-}" ]; then
env_pairs+=("CRISIS_ALERT_EMAIL_FROM=${CRISIS_ALERT_EMAIL_FROM}")
else
Expand Down
28 changes: 16 additions & 12 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,9 +112,11 @@ For IBKR, keep `paper` as a single account-group entry. If you later add live ac
| `TELEGRAM_TOKEN` | Yes | Telegram bot token. For Cloud Run, prefer a Secret Manager reference instead of a literal env var. |
| `GLOBAL_TELEGRAM_CHAT_ID` | Yes | Telegram chat ID used by this service. |
| `NOTIFY_LANG` | No | `en` (default) or `zh` |
| `CRISIS_ALERT_EMAIL_TO` | No | Comma/semicolon/newline-separated recipients for escalated crisis-plugin email alerts. Email is skipped when unset. |
| `CRISIS_ALERT_EMAIL_FROM` | No | Sender address for crisis-plugin email alerts. |
| `CRISIS_ALERT_SMTP_HOST` | No | SMTP host for crisis-plugin email alerts. |
| `CRISIS_ALERT_GOOGLE_VOICE_TO` | No | Comma/semicolon/newline-separated Google Voice SMS gateway recipients, usually ending in `@txt.voice.google.com`. |
| `CRISIS_ALERT_EMAIL_TO` | No | Optional ordinary email recipients that receive the same escalated alert; also accepted as a legacy recipient list. |
| `CRISIS_ALERT_SMTP_FROM` | No | SMTP sender address for Google Voice alerts. Falls back to `CRISIS_ALERT_EMAIL_FROM`. |
| `CRISIS_ALERT_EMAIL_FROM` | No | Legacy SMTP sender alias; prefer `CRISIS_ALERT_SMTP_FROM`. |
| `CRISIS_ALERT_SMTP_HOST` | No | SMTP host for Google Voice alerts. |
| `CRISIS_ALERT_SMTP_PORT` | No | SMTP port; defaults to `587`. |
| `CRISIS_ALERT_SMTP_USERNAME` | No | Optional SMTP username. |
| `CRISIS_ALERT_SMTP_PASSWORD` | No | Optional SMTP password. For Cloud Run, prefer `CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME` in env sync. |
Expand Down Expand Up @@ -215,8 +217,8 @@ Current behavior is fail-fast:
- missing account-group config source → startup error
- missing key fields in the selected group (`ib_gateway_instance_name`, `ib_gateway_mode`, `ib_client_id`) → startup error

When `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` includes the `crisis_response_shadow` plugin, the normal strategy-cycle Telegram message still includes the compact plugin line. If the plugin signal escalates beyond `no_action` (for example `canonical_route=true_crisis`, `suggested_action=defend`/`blocked`, or `would_trade_if_enabled=true`), the service also sends an independent crisis email when the `CRISIS_ALERT_*` SMTP settings are complete.
Email alert results are written into the runtime report. Duplicate suppression uses stable plugin alert keys and stores markers under `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI` when set, otherwise `EXECUTION_REPORT_GCS_URI`, with a local `/tmp` marker fallback.
When `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` includes the `crisis_response_shadow` plugin, the normal strategy-cycle Telegram message still includes the compact plugin line. If the plugin signal escalates beyond `no_action` (for example `canonical_route=true_crisis`, `suggested_action=defend`/`blocked`, or `would_trade_if_enabled=true`), the service also sends an independent crisis Google Voice notification when the `CRISIS_ALERT_*` SMTP settings are complete.
Google Voice alert results are written into the runtime report. Duplicate suppression uses stable plugin alert keys and stores markers under `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI` when set, otherwise `EXECUTION_REPORT_GCS_URI`, with a local `/tmp` marker fallback.

### GitHub-managed Cloud Run env sync

Expand All @@ -233,7 +235,7 @@ Recommended setup:
- `ACCOUNT_GROUP` (recommended: `paper`)
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
- Optional: `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`, `IBKR_MIN_RESERVED_CASH_USD`, `IBKR_RESERVED_CASH_RATIO`, `IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`
- Optional crisis email alerts: `CRISIS_ALERT_EMAIL_TO`, `CRISIS_ALERT_EMAIL_FROM`, `CRISIS_ALERT_SMTP_HOST`, `CRISIS_ALERT_SMTP_PORT`, `CRISIS_ALERT_SMTP_USERNAME`, `CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME`, `CRISIS_ALERT_SMTP_STARTTLS`, `CRISIS_ALERT_SMTP_SSL`
- Optional crisis Google Voice alerts: `CRISIS_ALERT_GOOGLE_VOICE_TO`, `CRISIS_ALERT_EMAIL_TO`, `CRISIS_ALERT_SMTP_FROM`, `CRISIS_ALERT_EMAIL_FROM`, `CRISIS_ALERT_SMTP_HOST`, `CRISIS_ALERT_SMTP_PORT`, `CRISIS_ALERT_SMTP_USERNAME`, `CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME`, `CRISIS_ALERT_SMTP_STARTTLS`, `CRISIS_ALERT_SMTP_SSL`
- `GLOBAL_TELEGRAM_CHAT_ID`
- `NOTIFY_LANG`
- **Repository Secrets**
Expand Down Expand Up @@ -364,9 +366,11 @@ IBKR 账户
| `TELEGRAM_TOKEN` | 是 | Telegram 机器人 Token。Cloud Run 上更推荐走 Secret Manager 引用,不要直接写成明文 env。 |
| `GLOBAL_TELEGRAM_CHAT_ID` | 是 | 这个服务使用的 Telegram Chat ID。 |
| `NOTIFY_LANG` | 否 | `en`(默认)或 `zh` |
| `CRISIS_ALERT_EMAIL_TO` | 否 | 危机插件升级邮件收件人,支持逗号、分号或换行分隔。不配置则跳过邮件。 |
| `CRISIS_ALERT_EMAIL_FROM` | 否 | 危机插件升级邮件发件人。 |
| `CRISIS_ALERT_SMTP_HOST` | 否 | 危机插件升级邮件的 SMTP host。 |
| `CRISIS_ALERT_GOOGLE_VOICE_TO` | 否 | Google Voice 短信网关收件人,通常以 `@txt.voice.google.com` 结尾,支持逗号、分号或换行分隔。 |
| `CRISIS_ALERT_EMAIL_TO` | 否 | 可选普通邮件收件人,会收到同一份升级告警;也作为旧版收件人配置兼容。 |
| `CRISIS_ALERT_SMTP_FROM` | 否 | Google Voice 告警的 SMTP 发件人;未设置时回退到 `CRISIS_ALERT_EMAIL_FROM`。 |
| `CRISIS_ALERT_EMAIL_FROM` | 否 | 旧版 SMTP 发件人别名;优先使用 `CRISIS_ALERT_SMTP_FROM`。 |
| `CRISIS_ALERT_SMTP_HOST` | 否 | Google Voice 告警的 SMTP host。 |
| `CRISIS_ALERT_SMTP_PORT` | 否 | SMTP 端口,默认 `587`。 |
| `CRISIS_ALERT_SMTP_USERNAME` | 否 | 可选 SMTP 用户名。 |
| `CRISIS_ALERT_SMTP_PASSWORD` | 否 | 可选 SMTP 密码。Cloud Run env sync 建议配置 `CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME`。 |
Expand Down Expand Up @@ -433,8 +437,8 @@ IB_GATEWAY_IP_MODE=internal
- 没有账号组配置来源 → 启动直接报错
- 选中的账号组缺少关键字段(`ib_gateway_instance_name`、`ib_gateway_mode`、`ib_client_id`)→ 启动直接报错

如果 `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` 挂载了 `crisis_response_shadow` 插件,常规策略周期 Telegram 仍会包含插件摘要行。当插件信号升级到非 `no_action`(例如 `canonical_route=true_crisis`、`suggested_action=defend`/`blocked`,或 `would_trade_if_enabled=true`)时,只要 `CRISIS_ALERT_*` SMTP 配置完整,服务还会额外发一封独立危机邮件
邮件告警结果会写入 runtime report。重复发送抑制使用稳定的插件告警 key;如配置了 `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI` 则写入该前缀,否则复用 `EXECUTION_REPORT_GCS_URI`,并有本地 `/tmp` marker fallback。
如果 `IBKR_STRATEGY_PLUGIN_MOUNTS_JSON` 挂载了 `crisis_response_shadow` 插件,常规策略周期 Telegram 仍会包含插件摘要行。当插件信号升级到非 `no_action`(例如 `canonical_route=true_crisis`、`suggested_action=defend`/`blocked`,或 `would_trade_if_enabled=true`)时,只要 `CRISIS_ALERT_*` SMTP 配置完整,服务还会额外发一封独立 Google Voice 危机通知
Google Voice 告警结果会写入 runtime report。重复发送抑制使用稳定的插件告警 key;如配置了 `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI` 则写入该前缀,否则复用 `EXECUTION_REPORT_GCS_URI`,并有本地 `/tmp` marker fallback。

### GitHub 统一管理 Cloud Run 环境变量

Expand All @@ -451,7 +455,7 @@ IB_GATEWAY_IP_MODE=internal
- `ACCOUNT_GROUP`(建议设为 `paper`)
- `IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME`
- 可选:`IBKR_STRATEGY_PLUGIN_MOUNTS_JSON`、`IBKR_MIN_RESERVED_CASH_USD`、`IBKR_RESERVED_CASH_RATIO`、`IBKR_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`
- 可选危机插件邮件告警:`CRISIS_ALERT_EMAIL_TO`、`CRISIS_ALERT_EMAIL_FROM`、`CRISIS_ALERT_SMTP_HOST`、`CRISIS_ALERT_SMTP_PORT`、`CRISIS_ALERT_SMTP_USERNAME`、`CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME`、`CRISIS_ALERT_SMTP_STARTTLS`、`CRISIS_ALERT_SMTP_SSL`
- 可选危机插件 Google Voice 告警:`CRISIS_ALERT_GOOGLE_VOICE_TO`、`CRISIS_ALERT_EMAIL_TO`、`CRISIS_ALERT_SMTP_FROM`、`CRISIS_ALERT_EMAIL_FROM`、`CRISIS_ALERT_SMTP_HOST`、`CRISIS_ALERT_SMTP_PORT`、`CRISIS_ALERT_SMTP_USERNAME`、`CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME`、`CRISIS_ALERT_SMTP_STARTTLS`、`CRISIS_ALERT_SMTP_SSL`
- `GLOBAL_TELEGRAM_CHAT_ID`
- `NOTIFY_LANG`
- **仓库级 Secrets**
Expand Down
22 changes: 11 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,10 +25,10 @@
from decision_mapper import map_strategy_decision
from entrypoints.cloud_run import is_market_open_today
from notifications.telegram import build_strategy_display_name, build_translator, send_telegram_message
from quant_platform_kit.notifications.strategy_plugin_email import (
StrategyPluginEmailAlertMarkerStore,
build_strategy_plugin_alert_context_label as build_email_alert_context_label,
publish_strategy_plugin_email_alerts,
from quant_platform_kit.notifications.strategy_plugin_google_voice import (
StrategyPluginGoogleVoiceAlertMarkerStore,
build_strategy_plugin_alert_context_label as build_google_voice_alert_context_label,
publish_strategy_plugin_google_voice_alerts,
)
from quant_platform_kit.common.runtime_assembly import build_runtime_assembly
from quant_platform_kit.common.runtime_reports import (
Expand Down Expand Up @@ -478,15 +478,15 @@ def build_strategy_plugin_alert_messages(signals):


def build_strategy_plugin_alert_store():
return StrategyPluginEmailAlertMarkerStore(
return StrategyPluginGoogleVoiceAlertMarkerStore(
local_dir=os.getenv("STRATEGY_PLUGIN_ALERT_STATE_DIR") or "/tmp/quant_strategy_plugin_alerts",
gcs_prefix_uri=os.getenv("STRATEGY_PLUGIN_ALERT_STATE_GCS_URI") or os.getenv("EXECUTION_REPORT_GCS_URI"),
gcp_project_id=PROJECT_ID,
)


def build_strategy_plugin_alert_context_label() -> str:
return build_email_alert_context_label(
return build_google_voice_alert_context_label(
platform_id="ibkr",
strategy_profile=STRATEGY_PROFILE,
account_scope=ACCOUNT_GROUP,
Expand All @@ -495,23 +495,23 @@ def build_strategy_plugin_alert_context_label() -> str:
)


def attach_strategy_plugin_alert_email_result(report, result) -> None:
report.setdefault("summary", {})["strategy_plugin_alert_email_sent_count"] = result.sent_count
def attach_strategy_plugin_alert_google_voice_result(report, result) -> None:
report.setdefault("summary", {})["strategy_plugin_alert_google_voice_sent_count"] = result.sent_count
report.setdefault("diagnostics", {}).update(result.to_report_fields())


def publish_strategy_plugin_alerts(signals, *, report=None):
result = publish_strategy_plugin_email_alerts(
result = publish_strategy_plugin_google_voice_alerts(
signals,
email_settings=RUNTIME_SETTINGS,
google_voice_settings=RUNTIME_SETTINGS,
translator=t,
strategy_label=STRATEGY_PROFILE,
context_label=build_strategy_plugin_alert_context_label(),
alert_store=build_strategy_plugin_alert_store(),
log_message=print,
)
if report is not None:
attach_strategy_plugin_alert_email_result(report, result)
attach_strategy_plugin_alert_google_voice_result(report, result)
return result


Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
flask
gunicorn
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@ba67541711228f5a72a294def0e5cc24cc5479f3
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@305f2cc0748ec08d001deabc3add6c4eff7fe7ba
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8ed13d9122f52c35425b0802d22467bb6664dcd3
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@e89ea43181f687d3454636b4b2d99ab7771546f4
pandas
numpy
requests
Expand Down
7 changes: 7 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ class PlatformRuntimeSettings:
tg_chat_id: str | None = None
notify_lang: str = "en"
strategy_plugin_mounts_json: str | None = None
crisis_alert_google_voice_to: tuple[str, ...] = ()
crisis_alert_smtp_from: str | None = None
crisis_alert_email_to: tuple[str, ...] = ()
crisis_alert_email_from: str | None = None
crisis_alert_smtp_host: str | None = None
Expand Down Expand Up @@ -188,6 +190,11 @@ def load_platform_runtime_settings(
os.getenv("IBKR_STRATEGY_PLUGIN_MOUNTS_JSON")
or os.getenv("STRATEGY_PLUGIN_MOUNTS_JSON")
),
crisis_alert_google_voice_to=split_env_list(os.getenv("CRISIS_ALERT_GOOGLE_VOICE_TO")),
crisis_alert_smtp_from=first_non_empty(
os.getenv("CRISIS_ALERT_SMTP_FROM"),
os.getenv("CRISIS_ALERT_EMAIL_FROM"),
),
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")),
Expand Down
2 changes: 2 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ def load_strategy_module(**env_overrides):
),
"IB_ACCOUNT_GROUP_CONFIG_SECRET_NAME": None,
"GLOBAL_TELEGRAM_CHAT_ID": None,
"CRISIS_ALERT_GOOGLE_VOICE_TO": None,
"CRISIS_ALERT_EMAIL_TO": None,
"CRISIS_ALERT_SMTP_FROM": None,
"CRISIS_ALERT_EMAIL_FROM": None,
"CRISIS_ALERT_SMTP_HOST": None,
"CRISIS_ALERT_SMTP_PORT": None,
Expand Down
12 changes: 6 additions & 6 deletions tests/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,15 @@ def fake_publish(signals, **kwargs):
return types.SimpleNamespace(
sent_count=1,
to_report_fields=lambda: {
"strategy_plugin_alert_email_attempted_count": 1,
"strategy_plugin_alert_email_sent_count": 1,
"strategy_plugin_alert_email_skipped_count": 0,
"strategy_plugin_alert_email_failed_count": 0,
"strategy_plugin_alert_email_deliveries": [],
"strategy_plugin_alert_google_voice_attempted_count": 1,
"strategy_plugin_alert_google_voice_sent_count": 1,
"strategy_plugin_alert_google_voice_skipped_count": 0,
"strategy_plugin_alert_google_voice_failed_count": 0,
"strategy_plugin_alert_google_voice_deliveries": [],
},
)

monkeypatch.setattr(strategy_module, "publish_strategy_plugin_email_alerts", fake_publish)
monkeypatch.setattr(strategy_module, "publish_strategy_plugin_google_voice_alerts", fake_publish)
monkeypatch.setattr(strategy_module, "run_strategy_core", lambda **_kwargs: "OK - executed")

with strategy_module.app.test_request_context("/", method="POST"):
Expand Down
8 changes: 7 additions & 1 deletion tests/test_runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ def test_load_platform_runtime_settings_uses_minimal_group_config(monkeypatch):
assert settings.tg_token is None
assert settings.tg_chat_id is None
assert settings.strategy_plugin_mounts_json is None
assert settings.crisis_alert_google_voice_to == ()
assert settings.crisis_alert_smtp_from is None
assert settings.crisis_alert_email_to == ()
assert settings.crisis_alert_email_from is None
assert settings.crisis_alert_smtp_host is None
Expand Down Expand Up @@ -208,11 +210,13 @@ def test_load_platform_runtime_settings_supports_explicit_group_config_values(mo
assert settings.notify_lang == "zh"


def test_load_platform_runtime_settings_reads_crisis_alert_email_config(monkeypatch):
def test_load_platform_runtime_settings_reads_crisis_alert_google_voice_config(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json(SAMPLE_STRATEGY_PROFILE))
monkeypatch.setenv("ACCOUNT_GROUP", "paper")
monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON)
monkeypatch.setenv("CRISIS_ALERT_GOOGLE_VOICE_TO", "gateway@txt.voice.google.com")
monkeypatch.setenv("CRISIS_ALERT_EMAIL_TO", "risk@example.com;ops@example.com,risk@example.com")
monkeypatch.setenv("CRISIS_ALERT_SMTP_FROM", "smtp-from@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")
Expand All @@ -223,6 +227,8 @@ def test_load_platform_runtime_settings_reads_crisis_alert_email_config(monkeypa

settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

assert settings.crisis_alert_google_voice_to == ("gateway@txt.voice.google.com",)
assert settings.crisis_alert_smtp_from == "smtp-from@example.com"
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"
Expand Down
Loading