Skip to content

Commit c25d20c

Browse files
committed
Send crisis plugin alerts by email
1 parent 30af50a commit c25d20c

11 files changed

Lines changed: 426 additions & 6 deletions

.env.example

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,17 @@ ACCOUNT_REGION=US
2222
NOTIFY_LANG=en
2323
TELEGRAM_TOKEN=
2424
GLOBAL_TELEGRAM_CHAT_ID=
25+
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON=
26+
27+
# Optional independent email channel for escalated strategy plugin alerts.
28+
CRISIS_ALERT_EMAIL_TO=
29+
CRISIS_ALERT_EMAIL_FROM=
30+
CRISIS_ALERT_SMTP_HOST=
31+
CRISIS_ALERT_SMTP_PORT=587
32+
CRISIS_ALERT_SMTP_USERNAME=
33+
CRISIS_ALERT_SMTP_PASSWORD=
34+
CRISIS_ALERT_SMTP_STARTTLS=true
35+
CRISIS_ALERT_SMTP_SSL=false
2536

2637
# Runtime safety controls.
2738
FIRSTRADE_COOKIE_DIR=.runtime/firstrade-cookies

.github/workflows/sync-cloud-run-env.yml

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,14 @@ jobs:
6161
FIRSTRADE_STATE_PREFIX: ${{ vars.FIRSTRADE_STATE_PREFIX }}
6262
FIRSTRADE_STRATEGY_CONFIG_PATH: ${{ vars.FIRSTRADE_STRATEGY_CONFIG_PATH }}
6363
FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON }}
64+
CRISIS_ALERT_EMAIL_TO: ${{ vars.CRISIS_ALERT_EMAIL_TO }}
65+
CRISIS_ALERT_EMAIL_FROM: ${{ vars.CRISIS_ALERT_EMAIL_FROM }}
66+
CRISIS_ALERT_SMTP_HOST: ${{ vars.CRISIS_ALERT_SMTP_HOST }}
67+
CRISIS_ALERT_SMTP_PORT: ${{ vars.CRISIS_ALERT_SMTP_PORT }}
68+
CRISIS_ALERT_SMTP_USERNAME: ${{ vars.CRISIS_ALERT_SMTP_USERNAME }}
69+
CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME: ${{ vars.CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME }}
70+
CRISIS_ALERT_SMTP_STARTTLS: ${{ vars.CRISIS_ALERT_SMTP_STARTTLS }}
71+
CRISIS_ALERT_SMTP_SSL: ${{ vars.CRISIS_ALERT_SMTP_SSL }}
6472
FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
6573
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
6674
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
@@ -69,6 +77,7 @@ jobs:
6977
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
7078
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
7179
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
80+
CRISIS_ALERT_SMTP_PASSWORD: ${{ secrets.CRISIS_ALERT_SMTP_PASSWORD }}
7281
FIRSTRADE_USERNAME: ${{ secrets.FIRSTRADE_USERNAME }}
7382
FIRSTRADE_PASSWORD: ${{ secrets.FIRSTRADE_PASSWORD }}
7483
FIRSTRADE_MFA_SECRET: ${{ secrets.FIRSTRADE_MFA_SECRET }}
@@ -424,6 +433,13 @@ jobs:
424433
add_optional_env FIRSTRADE_FEATURE_SNAPSHOT_MANIFEST_PATH
425434
add_optional_env FIRSTRADE_STRATEGY_CONFIG_PATH
426435
add_optional_env FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON
436+
add_optional_env CRISIS_ALERT_EMAIL_TO
437+
add_optional_env CRISIS_ALERT_EMAIL_FROM
438+
add_optional_env CRISIS_ALERT_SMTP_HOST
439+
add_optional_env CRISIS_ALERT_SMTP_PORT
440+
add_optional_env CRISIS_ALERT_SMTP_USERNAME
441+
add_optional_env CRISIS_ALERT_SMTP_STARTTLS
442+
add_optional_env CRISIS_ALERT_SMTP_SSL
427443
add_optional_env FIRSTRADE_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
428444
add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
429445
add_optional_env INCOME_THRESHOLD_USD
@@ -433,6 +449,7 @@ jobs:
433449
add_optional_env NOTIFY_LANG
434450
435451
add_optional_secret TELEGRAM_TOKEN TELEGRAM_TOKEN_SECRET_NAME TELEGRAM_TOKEN
452+
add_optional_secret CRISIS_ALERT_SMTP_PASSWORD CRISIS_ALERT_SMTP_PASSWORD_SECRET_NAME CRISIS_ALERT_SMTP_PASSWORD
436453
add_optional_secret FIRSTRADE_USERNAME FIRSTRADE_USERNAME_SECRET_NAME FIRSTRADE_USERNAME
437454
add_optional_secret FIRSTRADE_PASSWORD FIRSTRADE_PASSWORD_SECRET_NAME FIRSTRADE_PASSWORD
438455
add_optional_secret FIRSTRADE_MFA_SECRET FIRSTRADE_MFA_SECRET_SECRET_NAME FIRSTRADE_MFA_SECRET

README.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,15 @@ commit credentials.
8484
| `NOTIFY_LANG` | Optional | Notification language, `en` or `zh` |
8585
| `TELEGRAM_TOKEN` | Optional | Telegram bot token for strategy-cycle summaries |
8686
| `GLOBAL_TELEGRAM_CHAT_ID` | Optional | Telegram chat ID for strategy-cycle summaries |
87+
| `FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON` | Optional | JSON sidecar plugin mount config. Overrides global `STRATEGY_PLUGIN_MOUNTS_JSON` for this platform |
88+
| `CRISIS_ALERT_EMAIL_TO` | Optional | Comma, semicolon, or newline separated recipients for escalated strategy plugin email alerts |
89+
| `CRISIS_ALERT_EMAIL_FROM` | Optional | SMTP sender address for escalated strategy plugin email alerts |
90+
| `CRISIS_ALERT_SMTP_HOST` | Optional | SMTP host for escalated strategy plugin email alerts |
91+
| `CRISIS_ALERT_SMTP_PORT` | Optional | SMTP port. Defaults to `587` |
92+
| `CRISIS_ALERT_SMTP_USERNAME` | Optional | SMTP username when authentication is required |
93+
| `CRISIS_ALERT_SMTP_PASSWORD` | Optional | SMTP password, preferably supplied from Secret Manager in Cloud Run |
94+
| `CRISIS_ALERT_SMTP_STARTTLS` | Optional | Enable STARTTLS for SMTP. Defaults to `true` |
95+
| `CRISIS_ALERT_SMTP_SSL` | Optional | Use SMTP over SSL. Defaults to `false` |
8796
| `FIRSTRADE_COOKIE_DIR` | Optional | Cookie cache directory, default `.runtime/firstrade-cookies` |
8897
| `FIRSTRADE_ENABLE_LIVE_TRADING` | Optional | Must be `true` before any live order can be submitted |
8998
| `FIRSTRADE_RUN_SMOKE_ON_HTTP` | Optional | Must be `true` before `/smoke` performs a real login/quote |
@@ -165,10 +174,13 @@ full guarded strategy cycle:
165174
- connect to Firstrade with the unofficial client
166175
- read the selected account, balances, positions, quotes, and OHLC history
167176
- load the selected shared `UsEquityStrategies` runtime
177+
- load configured shared strategy plugin signal artifacts without changing core strategy logic
168178
- map the strategy decision into a value-target Firstrade plan
169179
- route generated orders through the local safety layer
170180
- publish a compact Telegram summary when `TELEGRAM_TOKEN` and
171181
`GLOBAL_TELEGRAM_CHAT_ID` are configured
182+
- send independent SMTP email alerts for escalated strategy plugin signals when
183+
`CRISIS_ALERT_*` is configured
172184

173185
The default mode remains dry-run. A live HTTP-triggered strategy order requires
174186
all of these gates:
@@ -299,6 +311,8 @@ Firstrade 登录、账户/行情读取、下单转换、安全闸和部署 wirin
299311
- dry-run / preview 下单验证
300312
- `/run` 执行通用美股策略的 dry-run 调仓闭环
301313
- 配置 `TELEGRAM_TOKEN``GLOBAL_TELEGRAM_CHAT_ID` 后发送运行摘要
314+
- 读取通用策略插件信号,并在危机类插件触发时通过 `CRISIS_ALERT_*`
315+
配置发送独立邮件告警
302316
- 在你再次确认后,才允许极小金额实盘验证
303317
- 通用 `us_equity` 策略 profile 的平台层接入
304318

application/rebalance_service.py

Lines changed: 136 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,14 @@
4444
build_semiconductor_rotation_indicators_from_history,
4545
required_semiconductor_rotation_history_lookback,
4646
)
47+
from quant_platform_kit.common.strategy_plugins import (
48+
build_strategy_plugin_alert_messages,
49+
build_strategy_plugin_notification_lines,
50+
build_strategy_plugin_report_payload,
51+
load_configured_strategy_plugin_signals,
52+
parse_strategy_plugin_mounts,
53+
)
54+
from quant_platform_kit.notifications.email import send_smtp_email
4755
from quant_platform_kit.notifications.events import NotificationPublisher, RenderedNotification
4856
from quant_platform_kit.strategy_contracts import build_strategy_evaluation_inputs
4957
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
@@ -162,6 +170,101 @@ def publish_log(text: str) -> None:
162170
return True
163171

164172

173+
def load_strategy_plugin_signals(
174+
raw_mounts,
175+
*,
176+
strategy_profile: str,
177+
parse_mounts_fn=parse_strategy_plugin_mounts,
178+
load_signals_fn=load_configured_strategy_plugin_signals,
179+
):
180+
if not raw_mounts:
181+
return (), None
182+
try:
183+
mounts = parse_mounts_fn(raw_mounts)
184+
if not mounts:
185+
return (), None
186+
return load_signals_fn(mounts, strategy_profile=strategy_profile), None
187+
except Exception as exc:
188+
return (), f"{type(exc).__name__}: {exc}"
189+
190+
191+
def attach_strategy_plugin_result(
192+
result: dict[str, Any],
193+
*,
194+
signals,
195+
error: str | None,
196+
translator: Callable[..., str],
197+
) -> dict[str, Any]:
198+
if signals:
199+
result.update(build_strategy_plugin_report_payload(signals))
200+
notification_lines = build_strategy_plugin_notification_lines(
201+
signals,
202+
translator=translator,
203+
)
204+
if notification_lines:
205+
result["strategy_plugin_lines"] = notification_lines
206+
if error:
207+
result["strategy_plugin_error"] = error
208+
return result
209+
210+
211+
def _call_log_message(log_message: Callable[..., Any], text: str) -> None:
212+
try:
213+
log_message(text, flush=True)
214+
except TypeError:
215+
log_message(text)
216+
217+
218+
def send_crisis_alert_email(
219+
alert_message,
220+
*,
221+
settings: PlatformRuntimeSettings,
222+
smtp_module=None,
223+
log_message: Callable[..., Any] = print,
224+
) -> bool:
225+
send_kwargs: dict[str, Any] = {}
226+
if smtp_module is not None:
227+
send_kwargs["smtp_module"] = smtp_module
228+
return send_smtp_email(
229+
subject=alert_message.subject,
230+
body=alert_message.body,
231+
smtp_host=getattr(settings, "crisis_alert_smtp_host", None),
232+
smtp_port=getattr(settings, "crisis_alert_smtp_port", 587),
233+
sender=getattr(settings, "crisis_alert_email_from", None),
234+
recipients=getattr(settings, "crisis_alert_email_to", ()),
235+
username=getattr(settings, "crisis_alert_smtp_username", None),
236+
password=getattr(settings, "crisis_alert_smtp_password", None),
237+
use_starttls=getattr(settings, "crisis_alert_smtp_starttls", True),
238+
use_ssl=getattr(settings, "crisis_alert_smtp_ssl", False),
239+
printer=lambda text, **_kwargs: _call_log_message(log_message, text),
240+
**send_kwargs,
241+
)
242+
243+
244+
def publish_strategy_plugin_alerts(
245+
signals,
246+
*,
247+
settings: PlatformRuntimeSettings,
248+
translator: Callable[..., str],
249+
log_message: Callable[..., Any] = print,
250+
) -> int:
251+
sent_count = 0
252+
for alert_message in build_strategy_plugin_alert_messages(
253+
signals,
254+
translator=translator,
255+
strategy_label=settings.strategy_profile,
256+
):
257+
if send_crisis_alert_email(
258+
alert_message,
259+
settings=settings,
260+
log_message=log_message,
261+
):
262+
sent_count += 1
263+
if sent_count:
264+
_call_log_message(log_message, f"strategy_plugin_alert_email_sent count={sent_count}")
265+
return sent_count
266+
267+
165268
def _runtime_metadata_with_execution_policy(
166269
metadata: Mapping[str, Any] | None,
167270
*,
@@ -186,6 +289,11 @@ def run_strategy_cycle(
186289
) -> dict[str, Any]:
187290
now = _utcnow()
188291
settings = runtime_settings or load_platform_runtime_settings(project_id_resolver=get_project_id)
292+
translator = build_translator(settings.notify_lang)
293+
strategy_plugin_signals, strategy_plugin_error = load_strategy_plugin_signals(
294+
settings.strategy_plugin_mounts_json,
295+
strategy_profile=settings.strategy_profile,
296+
)
189297
resolved_credentials = credentials or FirstradeCredentials.from_env(env_reader)
190298
store = state_store or build_gcs_state_store_from_env(env_reader)
191299
persist_strategy_runs = bool(settings.persist_strategy_runs and store is not None)
@@ -227,7 +335,7 @@ def run_strategy_cycle(
227335
available_inputs=available_inputs,
228336
market_inputs=market_inputs,
229337
portfolio_snapshot=snapshot,
230-
translator=build_translator(settings.notify_lang),
338+
translator=translator,
231339
)
232340
evaluation = strategy_runtime.evaluate(**evaluation_inputs)
233341
plan = map_strategy_decision_to_plan(
@@ -258,7 +366,7 @@ def run_strategy_cycle(
258366
run_period=run_period,
259367
)
260368
if is_duplicate_live_run(existing_run):
261-
return {
369+
result = {
262370
"ok": True,
263371
"api_kind": "unofficial-reverse-engineered",
264372
"account": masked_account,
@@ -280,7 +388,24 @@ def run_strategy_cycle(
280388
}
281389
],
282390
"action_done": False,
391+
"strategy_plugin_alert_email_sent_count": 0,
283392
}
393+
return attach_strategy_plugin_result(
394+
result,
395+
signals=strategy_plugin_signals,
396+
error=strategy_plugin_error,
397+
translator=translator,
398+
)
399+
strategy_plugin_alert_email_sent_count = 0
400+
strategy_plugin_alert_email_error = None
401+
try:
402+
strategy_plugin_alert_email_sent_count = publish_strategy_plugin_alerts(
403+
strategy_plugin_signals,
404+
settings=settings,
405+
translator=translator,
406+
)
407+
except Exception as exc:
408+
strategy_plugin_alert_email_error = f"{type(exc).__name__}: {exc}"
284409
strategy_run_persisted = False
285410
strategy_run_persistence_error = None
286411
if persist_strategy_runs:
@@ -357,6 +482,15 @@ def run_strategy_cycle(
357482
result["funding_blocked"] = True
358483
if strategy_run_persistence_error:
359484
result["strategy_run_persistence_error"] = strategy_run_persistence_error
485+
result["strategy_plugin_alert_email_sent_count"] = strategy_plugin_alert_email_sent_count
486+
if strategy_plugin_alert_email_error:
487+
result["strategy_plugin_alert_email_error"] = strategy_plugin_alert_email_error
488+
attach_strategy_plugin_result(
489+
result,
490+
signals=strategy_plugin_signals,
491+
error=strategy_plugin_error,
492+
translator=translator,
493+
)
360494
if persist_strategy_runs:
361495
completed_state = build_strategy_run_state(
362496
stage=strategy_run_stage,

notifications/telegram.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@
3636
"quantity_share": "{quantity}股",
3737
"quantity_shares": "{quantity}股",
3838
"signal_label": "信号",
39+
"strategy_plugin_line": "🧩 插件:{plugin} | 状态:{route} | 提醒:{action}",
40+
"strategy_plugin_alert_subject": "🚨 危机插件告警:{plugin} | {route}",
41+
"strategy_plugin_alert_title": "🚨 【危机插件告警】",
42+
"strategy_plugin_alert_strategy": "策略:{strategy}",
43+
"strategy_plugin_alert_as_of": "信号时间:{as_of}",
44+
"strategy_plugin_alert_would_trade": "若启用交易会操作:{value}",
45+
"strategy_plugin_alert_source": "来源:{source}",
46+
"strategy_plugin_name_crisis_response_shadow": "危机观察通知",
47+
"strategy_plugin_mode_shadow": "影子观察",
48+
"strategy_plugin_route_no_action": "未触发危机",
49+
"strategy_plugin_route_true_crisis": "真危机",
50+
"strategy_plugin_route_unknown_route": "未知状态",
51+
"strategy_plugin_action_no_action": "不操作",
52+
"strategy_plugin_action_watch_only": "仅通知",
53+
"strategy_plugin_action_defend": "防守",
54+
"strategy_plugin_action_blocked": "已阻断",
55+
"strategy_plugin_action_monitor": "持续观察",
56+
"strategy_plugin_action_unknown_action": "未知提醒",
3957
"separator": SEPARATOR,
4058
"same_trading_day": "当日执行",
4159
"next_trading_day": "次一交易日执行",
@@ -118,6 +136,24 @@
118136
"quantity_share": "{quantity} share",
119137
"quantity_shares": "{quantity} shares",
120138
"signal_label": "Signal",
139+
"strategy_plugin_line": "🧩 Plugin: {plugin} | status: {route} | notice: {action}",
140+
"strategy_plugin_alert_subject": "🚨 Crisis plugin alert: {plugin} | {route}",
141+
"strategy_plugin_alert_title": "🚨 【Crisis Plugin Alert】",
142+
"strategy_plugin_alert_strategy": "Strategy: {strategy}",
143+
"strategy_plugin_alert_as_of": "Signal as-of: {as_of}",
144+
"strategy_plugin_alert_would_trade": "Would trade if enabled: {value}",
145+
"strategy_plugin_alert_source": "Source: {source}",
146+
"strategy_plugin_name_crisis_response_shadow": "Crisis Watch Notice",
147+
"strategy_plugin_mode_shadow": "shadow",
148+
"strategy_plugin_route_no_action": "no crisis detected",
149+
"strategy_plugin_route_true_crisis": "true crisis",
150+
"strategy_plugin_route_unknown_route": "unknown status",
151+
"strategy_plugin_action_no_action": "no action",
152+
"strategy_plugin_action_watch_only": "notify only",
153+
"strategy_plugin_action_defend": "defend",
154+
"strategy_plugin_action_blocked": "blocked",
155+
"strategy_plugin_action_monitor": "watch",
156+
"strategy_plugin_action_unknown_action": "unknown notice",
121157
"separator": SEPARATOR,
122158
"same_trading_day": "same trading day",
123159
"next_trading_day": "next trading day",
@@ -575,6 +611,7 @@ def render_cycle_summary(result: Mapping[str, Any], *, lang: str = "en") -> str:
575611
lines.extend(dashboard_lines)
576612
lines.extend(_format_timing_lines(execution, translator=translator))
577613
lines.extend(_format_signal_lines(execution, translator=translator))
614+
lines.extend(str(line).strip() for line in result.get("strategy_plugin_lines") or ())
578615
lines.append(SEPARATOR)
579616
lines.extend(target_diff_lines)
580617
if submitted:

pyproject.toml

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ authors = [
1414
]
1515
dependencies = [
1616
"firstrade==0.0.38",
17-
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9c4ea7878a08fb2f518c74c99bda68d8ef8fd0bb",
18-
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ce7887482eeab7f519484610ee8b20cb7bc886a0",
17+
"quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1b6febbba7df81179ad7579f430c26a811c0e1a8",
18+
"us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@1636271a3e0c17fc0c5da363f67eabe114eeff48",
19+
"google-cloud-storage",
1920
"requests",
2021
]
2122

requirements.txt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
flask
22
gunicorn
33
firstrade==0.0.38
4-
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@9c4ea7878a08fb2f518c74c99bda68d8ef8fd0bb
5-
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ce7887482eeab7f519484610ee8b20cb7bc886a0
4+
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1b6febbba7df81179ad7579f430c26a811c0e1a8
5+
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@1636271a3e0c17fc0c5da363f67eabe114eeff48
6+
google-cloud-storage
67
requests
78
pytest

0 commit comments

Comments
 (0)