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 @@ -51,7 +51,9 @@ jobs:
LONGBRIDGE_MIN_RESERVED_CASH_USD: ${{ vars.LONGBRIDGE_MIN_RESERVED_CASH_USD }}
LONGBRIDGE_RESERVED_CASH_RATIO: ${{ vars.LONGBRIDGE_RESERVED_CASH_RATIO }}
LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.LONGBRIDGE_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 @@ -377,12 +379,24 @@ jobs:
remove_env_vars+=("LONGBRIDGE_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 @@ -70,9 +70,11 @@ Telegram notifications include structured execution and heartbeat messages, with
| `LONGBRIDGE_DRY_RUN_ONLY` | No | Set to `true` to keep the selected deployment in dry-run mode. |
| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | No | Set to `true` to log raw LongBridge position quantity and available quantity for troubleshooting. |
| `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | No | Optional LongBridge-side strategy plugin mount JSON. The plugin artifact controls mode; platform config must not set `mode`. |
| `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 All @@ -88,8 +90,8 @@ Telegram notifications include structured execution and heartbeat messages, with

Strategy allocation can still target fractional dollar values and fractional position weights. The LongBridge execution layer now keeps a whole-share-only rule for every broker order: sell sizing floors to whole shares, buy sizing floors to whole shares, and fractional orders are skipped rather than downgraded. When a target value is zero, sell sizing uses the sellable position quantity instead of re-deriving shares from current price, so liquidation targets do not leave a residual share because of quote drift.

When `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` includes the `crisis_response_shadow` plugin, the normal Telegram cycle 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 `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` includes the `crisis_response_shadow` plugin, the normal Telegram cycle 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.

Secret Manager must contain the secret named by `LONGPORT_SECRET_NAME` (default: `longport_token_paper`), where the **latest version = active access token**. The app refreshes it when expiry is within 30 days.

Expand Down Expand Up @@ -129,7 +131,7 @@ Recommended setup:
- `TELEGRAM_TOKEN_SECRET_NAME` (recommended: `longbridge-telegram-token`)
- `NOTIFY_LANG`
- `GLOBAL_TELEGRAM_CHAT_ID`
- 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`
- **Repository Secrets (shared):**
- Optional fallback only: `TELEGRAM_TOKEN`
- Optional fallback only: `CRISIS_ALERT_SMTP_PASSWORD`
Expand Down Expand Up @@ -243,9 +245,11 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换
| `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 |
| `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 |
| `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 LongBridge 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 |
| `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 All @@ -261,8 +265,8 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换

策略分配层仍然可以按目标金额和目标比例计算出小数仓位;LongBridge 执行层只提交整数股订单,因为实测账户的 OpenAPI `submit_order` 会拒绝碎股委托数量。目标市值为 0 时,卖出数量直接按可卖整数股持仓计算,不再用当前报价反推股数,避免因报价漂移留下 1 股残仓。

如果 `LONGBRIDGE_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。
如果 `LONGBRIDGE_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。

Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `longport_token_paper`),**最新版本 = 当前有效的 access token**。Token 到期前 30 天会自动刷新。

Expand Down Expand Up @@ -302,7 +306,7 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo
- `TELEGRAM_TOKEN_SECRET_NAME`(建议:`longbridge-telegram-token`)
- `NOTIFY_LANG`
- `GLOBAL_TELEGRAM_CHAT_ID`
- 可选危机插件邮件告警:`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`
- **仓库级 Secrets(共享):**
- 仅保留为 fallback:`TELEGRAM_TOKEN`
- 仅保留为 fallback:`CRISIS_ALERT_SMTP_PASSWORD`
Expand Down
22 changes: 11 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
load_configured_strategy_plugin_signals,
parse_strategy_plugin_mounts,
)
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_logging import build_run_id, emit_runtime_log
Expand Down Expand Up @@ -218,15 +218,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="longbridge",
strategy_profile=STRATEGY_PROFILE,
account_scope=ACCOUNT_REGION,
Expand All @@ -235,23 +235,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
requests
pytz
Expand Down
16 changes: 13 additions & 3 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,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 @@ -158,6 +160,11 @@ def load_platform_runtime_settings(
os.getenv("LONGBRIDGE_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 @@ -204,9 +211,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
12 changes: 6 additions & 6 deletions tests/test_request_handling.py
Original file line number Diff line number Diff line change
Expand Up @@ -430,15 +430,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": [],
},
)

module.publish_strategy_plugin_email_alerts = fake_publish
module.publish_strategy_plugin_google_voice_alerts = fake_publish

module.run_strategy()

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 @@ -119,6 +119,8 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro
self.assertIsNone(settings.feature_snapshot_path)
self.assertIsNone(settings.strategy_config_path)
self.assertIsNone(settings.strategy_plugin_mounts_json)
self.assertEqual(settings.crisis_alert_google_voice_to, ())
self.assertIsNone(settings.crisis_alert_smtp_from)
self.assertEqual(settings.crisis_alert_email_to, ())
self.assertIsNone(settings.crisis_alert_email_from)
self.assertIsNone(settings.crisis_alert_smtp_host)
Expand Down Expand Up @@ -274,12 +276,14 @@ def test_strategy_plugin_mounts_are_loaded_from_env(self):

self.assertEqual(settings.strategy_plugin_mounts_json, mount_config)

def test_crisis_alert_email_config_is_loaded_from_env(self):
def test_crisis_alert_google_voice_config_is_loaded_from_env(self):
with patch.dict(
os.environ,
{
"RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE),
"CRISIS_ALERT_GOOGLE_VOICE_TO": "gateway@txt.voice.google.com",
"CRISIS_ALERT_EMAIL_TO": "risk@example.com;ops@example.com,risk@example.com",
"CRISIS_ALERT_SMTP_FROM": "smtp-from@example.com",
"CRISIS_ALERT_EMAIL_FROM": "bot@example.com",
"CRISIS_ALERT_SMTP_HOST": "smtp.example.com",
"CRISIS_ALERT_SMTP_PORT": "465",
Expand All @@ -292,6 +296,8 @@ def test_crisis_alert_email_config_is_loaded_from_env(self):
):
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")

self.assertEqual(settings.crisis_alert_google_voice_to, ("gateway@txt.voice.google.com",))
self.assertEqual(settings.crisis_alert_smtp_from, "smtp-from@example.com")
self.assertEqual(settings.crisis_alert_email_to, ("risk@example.com", "ops@example.com"))
self.assertEqual(settings.crisis_alert_email_from, "bot@example.com")
self.assertEqual(settings.crisis_alert_smtp_host, "smtp.example.com")
Expand Down
Loading