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
4 changes: 3 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ TELEGRAM_TOKEN=
GLOBAL_TELEGRAM_CHAT_ID=
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON=

# Optional independent email channel for escalated strategy plugin alerts.
# Optional Google Voice/SMS channel for escalated strategy plugin 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=587
Expand Down
4 changes: 4 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,9 @@ 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_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 @@ -433,7 +435,9 @@ 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_GOOGLE_VOICE_TO
add_optional_env CRISIS_ALERT_EMAIL_TO
add_optional_env CRISIS_ALERT_SMTP_FROM
add_optional_env CRISIS_ALERT_EMAIL_FROM
add_optional_env CRISIS_ALERT_SMTP_HOST
add_optional_env CRISIS_ALERT_SMTP_PORT
Expand Down
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,11 @@ commit credentials.
| `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_GOOGLE_VOICE_TO` | Optional | Google Voice SMS gateway recipients, usually ending in `@txt.voice.google.com` |
| `CRISIS_ALERT_EMAIL_TO` | Optional | Ordinary email recipients that receive the same escalated alert; also accepted as a legacy recipient list |
| `CRISIS_ALERT_SMTP_FROM` | Optional | SMTP sender address for Google Voice alerts; falls back to `CRISIS_ALERT_EMAIL_FROM` |
| `CRISIS_ALERT_EMAIL_FROM` | Optional | Legacy SMTP sender alias; prefer `CRISIS_ALERT_SMTP_FROM` |
| `CRISIS_ALERT_SMTP_HOST` | Optional | SMTP host for Google Voice 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 |
Expand Down Expand Up @@ -179,9 +181,9 @@ full guarded strategy cycle:
- 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
- send independent Google Voice alerts for escalated strategy plugin signals when
`CRISIS_ALERT_*` is configured
- write email alert results into the response and suppress duplicate plugin
- write Google Voice alert results into the response and suppress duplicate plugin
alert keys through `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI`, `EXECUTION_REPORT_GCS_URI`,
or the configured Firstrade state bucket

Expand Down Expand Up @@ -315,8 +317,8 @@ Firstrade 登录、账户/行情读取、下单转换、安全闸和部署 wirin
- `/run` 执行通用美股策略的 dry-run 调仓闭环
- 配置 `TELEGRAM_TOKEN` 和 `GLOBAL_TELEGRAM_CHAT_ID` 后发送运行摘要
- 读取通用策略插件信号,并在危机类插件触发时通过 `CRISIS_ALERT_*`
配置发送独立邮件告警
- 在响应中写入邮件告警结果,并通过 `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI`、
配置发送独立 Google Voice 告警
- 在响应中写入 Google Voice 告警结果,并通过 `STRATEGY_PLUGIN_ALERT_STATE_GCS_URI`、
`EXECUTION_REPORT_GCS_URI` 或已配置的 Firstrade state bucket 抑制重复插件告警 key
- 在你再次确认后,才允许极小金额实盘验证
- 通用 `us_equity` 策略 profile 的平台层接入
Expand Down
52 changes: 26 additions & 26 deletions application/rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,10 @@
parse_strategy_plugin_mounts,
)
from quant_platform_kit.notifications.events import NotificationPublisher, RenderedNotification
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.strategy_contracts import build_strategy_evaluation_inputs
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
Expand Down Expand Up @@ -212,7 +212,7 @@ def attach_strategy_plugin_result(


def build_strategy_plugin_alert_context_label(settings: PlatformRuntimeSettings) -> str:
return build_email_alert_context_label(
return build_google_voice_alert_context_label(
platform_id="firstrade",
strategy_profile=settings.strategy_profile,
account_scope=settings.account_region or settings.account_prefix,
Expand All @@ -231,7 +231,7 @@ def build_strategy_plugin_alert_store(
state_bucket = env_reader("FIRSTRADE_GCS_STATE_BUCKET", None)
state_prefix = env_reader("FIRSTRADE_STATE_PREFIX", "firstrade-platform") or "firstrade-platform"
state_gcs_uri = f"gs://{state_bucket}/{state_prefix}" if state_bucket else None
return StrategyPluginEmailAlertMarkerStore(
return StrategyPluginGoogleVoiceAlertMarkerStore(
local_dir=env_reader("STRATEGY_PLUGIN_ALERT_STATE_DIR", None) or "/tmp/quant_strategy_plugin_alerts",
gcs_prefix_uri=explicit_gcs_uri or report_gcs_uri or state_gcs_uri,
gcp_project_id=settings.project_id,
Expand All @@ -246,9 +246,9 @@ def publish_strategy_plugin_alerts(
log_message: Callable[..., Any] = print,
env_reader: Callable[[str, str | None], str | None] = os.getenv,
):
return publish_strategy_plugin_email_alerts(
return publish_strategy_plugin_google_voice_alerts(
signals,
email_settings=settings,
google_voice_settings=settings,
translator=translator,
strategy_label=settings.strategy_profile,
context_label=build_strategy_plugin_alert_context_label(settings),
Expand Down Expand Up @@ -380,29 +380,29 @@ def run_strategy_cycle(
}
],
"action_done": False,
"strategy_plugin_alert_email_attempted_count": 0,
"strategy_plugin_alert_email_sent_count": 0,
"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": 0,
"strategy_plugin_alert_google_voice_sent_count": 0,
"strategy_plugin_alert_google_voice_skipped_count": 0,
"strategy_plugin_alert_google_voice_failed_count": 0,
"strategy_plugin_alert_google_voice_deliveries": [],
}
return attach_strategy_plugin_result(
result,
signals=strategy_plugin_signals,
error=strategy_plugin_error,
translator=translator,
)
strategy_plugin_alert_email_result = None
strategy_plugin_alert_email_error = None
strategy_plugin_alert_google_voice_result = None
strategy_plugin_alert_google_voice_error = None
try:
strategy_plugin_alert_email_result = publish_strategy_plugin_alerts(
strategy_plugin_alert_google_voice_result = publish_strategy_plugin_alerts(
strategy_plugin_signals,
settings=settings,
translator=translator,
env_reader=env_reader,
)
except Exception as exc:
strategy_plugin_alert_email_error = f"{type(exc).__name__}: {exc}"
strategy_plugin_alert_google_voice_error = f"{type(exc).__name__}: {exc}"
strategy_run_persisted = False
strategy_run_persistence_error = None
if persist_strategy_runs:
Expand Down Expand Up @@ -479,20 +479,20 @@ def run_strategy_cycle(
result["funding_blocked"] = True
if strategy_run_persistence_error:
result["strategy_run_persistence_error"] = strategy_run_persistence_error
if strategy_plugin_alert_email_result is not None:
result.update(strategy_plugin_alert_email_result.to_report_fields())
if strategy_plugin_alert_google_voice_result is not None:
result.update(strategy_plugin_alert_google_voice_result.to_report_fields())
else:
result.update(
{
"strategy_plugin_alert_email_attempted_count": 0,
"strategy_plugin_alert_email_sent_count": 0,
"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": 0,
"strategy_plugin_alert_google_voice_sent_count": 0,
"strategy_plugin_alert_google_voice_skipped_count": 0,
"strategy_plugin_alert_google_voice_failed_count": 0,
"strategy_plugin_alert_google_voice_deliveries": [],
}
)
if strategy_plugin_alert_email_error:
result["strategy_plugin_alert_email_error"] = strategy_plugin_alert_email_error
if strategy_plugin_alert_google_voice_error:
result["strategy_plugin_alert_google_voice_error"] = strategy_plugin_alert_google_voice_error
attach_strategy_plugin_result(
result,
signals=strategy_plugin_signals,
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ authors = [
]
dependencies = [
"firstrade==0.0.38",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1b6febbba7df81179ad7579f430c26a811c0e1a8",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@1636271a3e0c17fc0c5da363f67eabe114eeff48",
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@8ed13d9122f52c35425b0802d22467bb6664dcd3",
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@e89ea43181f687d3454636b4b2d99ab7771546f4",
"google-cloud-storage",
"requests",
]
Expand Down
4 changes: 2 additions & 2 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
flask
gunicorn
firstrade==0.0.38
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
google-cloud-storage
requests
pytest
16 changes: 13 additions & 3 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ class PlatformRuntimeSettings:
strategy_config_path: str | None = None
strategy_config_source: str | None = None
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 @@ -153,6 +155,11 @@ def load_platform_runtime_settings(
os.getenv("FIRSTRADE_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 Expand Up @@ -219,9 +226,12 @@ 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 _first_non_empty(*raw_values: str | None) -> str | None:
for raw_value in raw_values:
value = str(raw_value or "").strip()
if value:
return value
return None


def _resolve_bool_env(name: str, *, default: bool) -> bool:
Expand Down
18 changes: 9 additions & 9 deletions tests/test_rebalance_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,17 +245,17 @@ def fake_publish(signals, **kwargs):
return 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": [
{"subject": "Crisis plugin alert", "status": "sent"}
],
},
)

monkeypatch.setattr("application.rebalance_service.publish_strategy_plugin_email_alerts", fake_publish)
monkeypatch.setattr("application.rebalance_service.publish_strategy_plugin_google_voice_alerts", fake_publish)

result = run_strategy_cycle(
runtime_settings=settings,
Expand All @@ -266,14 +266,14 @@ def fake_publish(signals, **kwargs):
)

assert result["strategy_plugins"][0]["canonical_route"] == "true_crisis"
assert result["strategy_plugin_alert_email_sent_count"] == 1
assert result["strategy_plugin_alert_google_voice_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][0][0].canonical_route == "true_crisis"
assert "firstrade" in observed_alerts[0][1]["context_label"]
assert result["strategy_plugin_alert_email_deliveries"][0]["status"] == "sent"
assert result["strategy_plugin_alert_google_voice_deliveries"][0]["status"] == "sent"
assert "🧩 Plugin: Crisis Watch Notice | status: true crisis | notice: defend" in messages[0]


Expand All @@ -295,7 +295,7 @@ def test_run_strategy_cycle_strategy_plugin_load_error_is_non_blocking(monkeypat
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
assert result["strategy_plugin_alert_google_voice_sent_count"] == 0


def test_run_strategy_cycle_persists_strategy_run_state(monkeypatch):
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 @@ -53,6 +53,8 @@ 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_google_voice_to == ()
assert settings.crisis_alert_smtp_from is None
assert settings.crisis_alert_email_to == ()
assert settings.crisis_alert_smtp_port == 587
assert settings.crisis_alert_smtp_starttls is True
Expand All @@ -70,9 +72,11 @@ 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):
def test_crisis_alert_google_voice_settings_load_from_env(monkeypatch):
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_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 @@ -83,6 +87,8 @@ def test_crisis_alert_email_settings_load_from_env(monkeypatch):

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
2 changes: 2 additions & 0 deletions tests/test_sync_cloud_run_env_workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ def test_sync_cloud_run_env_workflow_syncs_crisis_alert_settings():
workflow = workflow_path.read_text(encoding="utf-8")

for name in (
"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",
Expand Down