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
82 changes: 82 additions & 0 deletions .github/workflows/sync-cloud-run-env.yml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ 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_CHANNELS: ${{ vars.CRISIS_ALERT_CHANNELS }}
CRISIS_ALERT_EMAIL_RECIPIENTS: ${{ vars.CRISIS_ALERT_EMAIL_RECIPIENTS }}
CRISIS_ALERT_EMAIL_SENDER_EMAIL: ${{ vars.CRISIS_ALERT_EMAIL_SENDER_EMAIL }}
CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME: ${{ vars.CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME }}
Expand All @@ -65,6 +66,15 @@ jobs:
CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID: ${{ vars.CRISIS_ALERT_SMS_MESSAGING_SERVICE_ID }}
CRISIS_ALERT_SMS_API_BASE_URL: ${{ vars.CRISIS_ALERT_SMS_API_BASE_URL }}
CRISIS_ALERT_SMS_BODY_MAX_CHARS: ${{ vars.CRISIS_ALERT_SMS_BODY_MAX_CHARS }}
CRISIS_ALERT_PUSH_RECIPIENTS: ${{ vars.CRISIS_ALERT_PUSH_RECIPIENTS }}
CRISIS_ALERT_PUSH_PROVIDER: ${{ vars.CRISIS_ALERT_PUSH_PROVIDER }}
CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME: ${{ vars.CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME }}
CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME: ${{ vars.CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME }}
CRISIS_ALERT_PUSH_API_BASE_URL: ${{ vars.CRISIS_ALERT_PUSH_API_BASE_URL }}
CRISIS_ALERT_PUSH_DEVICE: ${{ vars.CRISIS_ALERT_PUSH_DEVICE }}
CRISIS_ALERT_PUSH_PRIORITY: ${{ vars.CRISIS_ALERT_PUSH_PRIORITY }}
CRISIS_ALERT_PUSH_TAGS: ${{ vars.CRISIS_ALERT_PUSH_TAGS }}
CRISIS_ALERT_PUSH_BODY_MAX_CHARS: ${{ vars.CRISIS_ALERT_PUSH_BODY_MAX_CHARS }}
# Optional strategy overrides; leave unset to inherit the UsEquityStrategies profile defaults.
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
Expand All @@ -75,6 +85,8 @@ jobs:
TELEGRAM_TOKEN: ${{ secrets.TELEGRAM_TOKEN }}
CRISIS_ALERT_EMAIL_SENDER_PASSWORD: ${{ secrets.CRISIS_ALERT_EMAIL_SENDER_PASSWORD }}
CRISIS_ALERT_SMS_AUTH_TOKEN: ${{ secrets.CRISIS_ALERT_SMS_AUTH_TOKEN }}
CRISIS_ALERT_PUSH_APP_TOKEN: ${{ secrets.CRISIS_ALERT_PUSH_APP_TOKEN }}
CRISIS_ALERT_PUSH_ACCESS_TOKEN: ${{ secrets.CRISIS_ALERT_PUSH_ACCESS_TOKEN }}
steps:
- name: Check whether env sync is enabled
id: config
Expand Down Expand Up @@ -363,6 +375,28 @@ jobs:
remove_secret_vars+=("CRISIS_ALERT_SMS_AUTH_TOKEN")
fi

if [ -n "${CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME:-}" ]; then
secret_pairs+=("CRISIS_ALERT_PUSH_APP_TOKEN=${CRISIS_ALERT_PUSH_APP_TOKEN_SECRET_NAME}:latest")
remove_env_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN")
elif [ -n "${CRISIS_ALERT_PUSH_APP_TOKEN:-}" ]; then
env_pairs+=("CRISIS_ALERT_PUSH_APP_TOKEN=${CRISIS_ALERT_PUSH_APP_TOKEN}")
remove_secret_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN")
else
remove_env_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN")
remove_secret_vars+=("CRISIS_ALERT_PUSH_APP_TOKEN")
fi

if [ -n "${CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME:-}" ]; then
secret_pairs+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN=${CRISIS_ALERT_PUSH_ACCESS_TOKEN_SECRET_NAME}:latest")
remove_env_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN")
elif [ -n "${CRISIS_ALERT_PUSH_ACCESS_TOKEN:-}" ]; then
env_pairs+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN=${CRISIS_ALERT_PUSH_ACCESS_TOKEN}")
remove_secret_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN")
else
remove_env_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN")
remove_secret_vars+=("CRISIS_ALERT_PUSH_ACCESS_TOKEN")
fi

secret_pairs+=("LONGPORT_APP_KEY=${LONGPORT_APP_KEY_SECRET_NAME}:latest")
remove_env_vars+=("LONGPORT_APP_KEY")

Expand Down Expand Up @@ -417,6 +451,12 @@ jobs:
remove_env_vars+=("LONGBRIDGE_RESERVED_CASH_RATIO")
fi

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

if [ -n "${CRISIS_ALERT_EMAIL_RECIPIENTS:-}" ]; then
env_pairs+=("CRISIS_ALERT_EMAIL_RECIPIENTS=${CRISIS_ALERT_EMAIL_RECIPIENTS}")
else
Expand Down Expand Up @@ -489,6 +529,48 @@ jobs:
remove_env_vars+=("CRISIS_ALERT_SMS_BODY_MAX_CHARS")
fi

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

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

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

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

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

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

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

if [ -n "${LONGBRIDGE_DRY_RUN_ONLY:-}" ]; then
env_pairs+=("LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}")
else
Expand Down
14 changes: 8 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ 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_CHANNELS` | No | Optional crisis alert channel list: `email`, `sms`, and/or `push`. |
| `CRISIS_ALERT_EMAIL_RECIPIENTS` | No | Comma/semicolon/newline-separated email-form recipients. Use a normal mailbox for email-only delivery, or a Google Voice-associated mailbox/address to also trigger Google Voice prompts. |
| `CRISIS_ALERT_EMAIL_SENDER_EMAIL` | No | Sender email address used for crisis alert email. Gmail is the default transport, but the sender naming is provider-neutral. |
| `CRISIS_ALERT_EMAIL_SENDER_PASSWORD` | No | Sender SMTP password or app password. For Cloud Run, prefer `CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME` in env sync. |
Expand All @@ -86,8 +87,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 notification when the `CRISIS_ALERT_EMAIL_*` 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 independent crisis alerts through configured `CRISIS_ALERT_CHANNELS` channels.
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 @@ -154,7 +155,7 @@ Important:
- GitHub now authenticates to Google Cloud with OIDC + Workload Identity Federation, so `GCP_SA_KEY` is no longer required for this workflow.
- If you deploy with `gcloud run deploy --source` or a Cloud Run source trigger, also grant `roles/storage.objectViewer` on `gs://run-sources-<project>-<region>` to the build service account, the deploy service account, and the default compute service account. Otherwise source deploy can fail before Cloud Build starts with `storage.objects.get` denied.
- Here "shared" only means **shared inside this repository** between the `paper`, `HK`, and `SG` Cloud Run services. The Telegram token can still be shared, but LongPort app credentials should live in Secret Manager and be referenced by per-environment secret-name variables; they are not meant to be a global secret set reused by unrelated quant repos.
- If you want one cross-project shared layer across multiple quant repos, keep it small: `GLOBAL_TELEGRAM_CHAT_ID`, `NOTIFY_LANG`, and shared crisis email settings under `CRISIS_ALERT_EMAIL_*` are reasonable when the same alert policy applies; account credentials and deployment keys are not.
- If you want one cross-project shared layer across multiple quant repos, keep it small: `GLOBAL_TELEGRAM_CHAT_ID`, `NOTIFY_LANG`, `CRISIS_ALERT_CHANNELS`, and shared crisis alert settings under `CRISIS_ALERT_EMAIL_*`/`CRISIS_ALERT_PUSH_*` are reasonable when the same alert policy applies; account credentials, deployment keys, and alert secrets are not.

### Deployment unit and naming

Expand Down Expand Up @@ -240,6 +241,7 @@ 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_CHANNELS` | 否 | 可选危机告警通道列表:`email`、`sms` 和/或 `push`。 |
| `CRISIS_ALERT_EMAIL_RECIPIENTS` | 否 | 通知收件邮箱。普通邮箱只收邮件;关联 Google Voice 的邮箱/地址会额外触发 Google Voice 提醒。支持逗号、分号或换行分隔。 |
| `CRISIS_ALERT_EMAIL_SENDER_EMAIL` | 否 | 通知发送方邮箱。默认传输走 Gmail SMTP,但命名不绑定 Gmail。 |
| `CRISIS_ALERT_EMAIL_SENDER_PASSWORD` | 否 | 发送方 SMTP 密码或 app password。Cloud Run env sync 建议配置 `CRISIS_ALERT_EMAIL_SENDER_PASSWORD_SECRET_NAME`。 |
Expand All @@ -256,8 +258,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_EMAIL_*` 配置完整,服务还会额外发送一条独立邮件危机通知
邮件告警结果会写入 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_CHANNELS` 配置额外发送独立危机通知
告警结果会写入 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 @@ -325,7 +327,7 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo
- GitHub 现在通过 OIDC + Workload Identity Federation 登录 Google Cloud,这个 workflow 不再需要 `GCP_SA_KEY`。
- 如果你用 `gcloud run deploy --source` 或 Cloud Run source trigger 部署,还要给 `gs://run-sources-<project>-<region>` 这个 staging bucket 补 `roles/storage.objectViewer`,对象是 build service account、deploy service account、默认 compute service account。少了这层权限,部署会在 Cloud Build 启动前直接报 `storage.objects.get denied`。
- 这里的“共享”只是指 **同一个仓库里的 paper / HK / SG 服务共享**。Telegram token 可以继续共用,但 LongPort app 凭据建议放到 Secret Manager,并通过各自 Environment 里的 secret-name 变量引用,不建议把它们当成所有 quant 共用的全局 secrets。
- 如果你真的要在多个 quant 仓库之间保留一层全局共享,建议只保留 `GLOBAL_TELEGRAM_CHAT_ID`、`NOTIFY_LANG`,以及同一套危机邮件策略下的 `CRISIS_ALERT_EMAIL_*` 这种低耦合配置。账户凭据和部署 key 不要做成全局共享。
- 如果你真的要在多个 quant 仓库之间保留一层全局共享,建议只保留 `GLOBAL_TELEGRAM_CHAT_ID`、`NOTIFY_LANG`、`CRISIS_ALERT_CHANNELS`,以及同一套危机告警策略下的 `CRISIS_ALERT_EMAIL_*`/`CRISIS_ALERT_PUSH_*` 这种低耦合配置。账户凭据、部署 key 和告警 secret 不要做成全局共享。

### 部署单元和命名建议

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@7183145b06f8b9292e1c8f3adba0a4f5dc0a5834
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@d13b248d279887a57eef5b7692c2c7bd33906348
quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@1050a678a17a997bf4776c2a4c4bef59de29e1c1
us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@48f0267e3128eb7c6a8344ddefa08e0e80dc9d27
pandas
requests
pytz
Expand Down
22 changes: 22 additions & 0 deletions runtime_config_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ class PlatformRuntimeSettings:
strategy_config_path: str | None = None
strategy_config_source: str | None = None
strategy_plugin_mounts_json: str | None = None
crisis_alert_channels: tuple[str, ...] = ()
crisis_alert_email_recipients: tuple[str, ...] = ()
crisis_alert_email_sender_email: str | None = None
crisis_alert_email_sender_password: str | None = None
Expand All @@ -68,6 +69,15 @@ class PlatformRuntimeSettings:
crisis_alert_sms_messaging_service_id: str | None = None
crisis_alert_sms_api_base_url: str | None = None
crisis_alert_sms_body_max_chars: str | None = None
crisis_alert_push_recipients: tuple[str, ...] = ()
crisis_alert_push_provider: str | None = None
crisis_alert_push_app_token: str | None = None
crisis_alert_push_access_token: str | None = None
crisis_alert_push_api_base_url: str | None = None
crisis_alert_push_device: str | None = None
crisis_alert_push_priority: str | None = None
crisis_alert_push_tags: str | None = None
crisis_alert_push_body_max_chars: str | None = None
runtime_target: RuntimeTarget | None = None


Expand Down Expand Up @@ -164,6 +174,7 @@ def load_platform_runtime_settings(
os.getenv("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON")
or os.getenv("STRATEGY_PLUGIN_MOUNTS_JSON")
),
crisis_alert_channels=_split_env_list(os.getenv("CRISIS_ALERT_CHANNELS")),
crisis_alert_email_recipients=_split_env_list(os.getenv("CRISIS_ALERT_EMAIL_RECIPIENTS")),
crisis_alert_email_sender_email=_first_non_empty(os.getenv("CRISIS_ALERT_EMAIL_SENDER_EMAIL")),
crisis_alert_email_sender_password=_first_non_empty(
Expand All @@ -186,6 +197,17 @@ def load_platform_runtime_settings(
crisis_alert_sms_body_max_chars=_first_non_empty(
os.getenv("CRISIS_ALERT_SMS_BODY_MAX_CHARS")
),
crisis_alert_push_recipients=_split_env_list(os.getenv("CRISIS_ALERT_PUSH_RECIPIENTS")),
crisis_alert_push_provider=_first_non_empty(os.getenv("CRISIS_ALERT_PUSH_PROVIDER")),
crisis_alert_push_app_token=_first_non_empty(os.getenv("CRISIS_ALERT_PUSH_APP_TOKEN")),
crisis_alert_push_access_token=_first_non_empty(os.getenv("CRISIS_ALERT_PUSH_ACCESS_TOKEN")),
crisis_alert_push_api_base_url=_first_non_empty(os.getenv("CRISIS_ALERT_PUSH_API_BASE_URL")),
crisis_alert_push_device=_first_non_empty(os.getenv("CRISIS_ALERT_PUSH_DEVICE")),
crisis_alert_push_priority=_first_non_empty(os.getenv("CRISIS_ALERT_PUSH_PRIORITY")),
crisis_alert_push_tags=_first_non_empty(os.getenv("CRISIS_ALERT_PUSH_TAGS")),
crisis_alert_push_body_max_chars=_first_non_empty(
os.getenv("CRISIS_ALERT_PUSH_BODY_MAX_CHARS")
),
runtime_target=runtime_target,
)

Expand Down
Loading