From 39a9ea72acffd554e6c5845c472330d5f7452710 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 24 May 2026 20:37:06 +0800 Subject: [PATCH 1/4] Add platform cash reserve policy --- .github/workflows/sync-cloud-run-env.yml | 14 +++++ README.md | 32 ++++++------ application/runtime_strategy_adapters.py | 8 ++- decision_mapper.py | 53 +++++++++++++++++++ main.py | 4 ++ runtime_config_support.py | 28 ++++++++++ scripts/print_strategy_switch_env_plan.py | 2 + tests/test_decision_mapper.py | 64 +++++++++++++++++++++++ tests/test_runtime_config_support.py | 27 ++++++++++ tests/test_runtime_strategy_adapters.py | 43 ++++++++++++++- tests/test_sync_cloud_run_env_workflow.sh | 6 +++ 11 files changed, 262 insertions(+), 19 deletions(-) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index da471fc..8ae3ef9 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -48,6 +48,8 @@ jobs: LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH }} LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_PATH }} LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON }} + 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 }} # Optional strategy overrides; leave unset to inherit the UsEquityStrategies profile defaults. INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} @@ -344,6 +346,18 @@ jobs: remove_env_vars+=("LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD") fi + if [ -n "${LONGBRIDGE_MIN_RESERVED_CASH_USD:-}" ]; then + env_pairs+=("LONGBRIDGE_MIN_RESERVED_CASH_USD=${LONGBRIDGE_MIN_RESERVED_CASH_USD}") + else + remove_env_vars+=("LONGBRIDGE_MIN_RESERVED_CASH_USD") + fi + + if [ -n "${LONGBRIDGE_RESERVED_CASH_RATIO:-}" ]; then + env_pairs+=("LONGBRIDGE_RESERVED_CASH_RATIO=${LONGBRIDGE_RESERVED_CASH_RATIO}") + else + remove_env_vars+=("LONGBRIDGE_RESERVED_CASH_RATIO") + fi + if [ -n "${LONGBRIDGE_DRY_RUN_ONLY:-}" ]; then env_pairs+=("LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}") else diff --git a/README.md b/README.md index 57c8e79..2ebe52d 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,8 @@ 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`. | +| `LONGBRIDGE_MIN_RESERVED_CASH_USD` | No | Platform-level minimum cash reserve in USD. Defaults to `0`; the effective reserve is the max of this floor, `LONGBRIDGE_RESERVED_CASH_RATIO * total equity`, and any strategy-provided reserve. | +| `LONGBRIDGE_RESERVED_CASH_RATIO` | No | Platform-level minimum cash reserve ratio in `[0,1]`. Defaults to `0`; it can raise but not lower a strategy-provided reserve. | | `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` | No | Safe-haven/cash-sweep target values below this USD amount are kept as cash instead of buying BOXX/BIL. Default `1000`. | | `INCOME_THRESHOLD_USD` | No | Optional strategy override for the `tqqq_growth_income` income-layer threshold. Leave unset to use the strategy package default. | | `QQQI_INCOME_RATIO` | No | Optional strategy override for QQQI's share of the `tqqq_growth_income` income layer, 0–1. | @@ -95,11 +97,11 @@ Recommended runtime secrets in the `longbridgequant` project: ### Multi-deployment isolation (paper/HK/SG) -Deploy the same codebase as multiple Cloud Run services (e.g. `paper` and `SG` today, `HK` later) by setting different values per service: +Deploy the same codebase as multiple Cloud Run services by setting different values per service: - `LONGPORT_SECRET_NAME`: point to different secrets (e.g. `longport_token_paper`, `longport_token_hk`, `longport_token_sg`) - `ACCOUNT_PREFIX`: e.g. `PAPER`, `HK`, `SG` (all Telegram/log alerts will include `[ACCOUNT_PREFIX]`) -- `STRATEGY_PROFILE`: set per service; current live examples are `mega_cap_leader_rotation_top50_balanced` on paper and `soxl_soxx_trend_income` on SG. `HK` will use the same pattern later. The deployment control plane now also carries `RUNTIME_TARGET_JSON`; treat `STRATEGY_PROFILE` as a compatibility input that still selects the strategy implementation, not the only identity key. +- `STRATEGY_PROFILE`: set per service. The deployment control plane now also carries `RUNTIME_TARGET_JSON`; treat `STRATEGY_PROFILE` as a compatibility input that still selects the strategy implementation, not the only identity key. - Current strategy domain is `us_equity`. `STRATEGY_PROFILE` still goes through a platform capability matrix plus a rollout allowlist derived from `runtime_enabled` strategy metadata: `eligible` means the platform can run it in theory, `enabled` means the current rollout really allows it. - `ACCOUNT_REGION`: explicitly mark the deployed account region (`PAPER` / `HK` / `SG`); if unset, the app falls back to `ACCOUNT_PREFIX` or `DEFAULT` - `LONGBRIDGE_DRY_RUN_ONLY`: set per service when that deployment should stay dry-run @@ -120,18 +122,15 @@ Recommended setup: - Optional fallback only: `TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-paper`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `RUNTIME_TARGET_JSON`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (strategy overrides only; leave unset to inherit `UsEquityStrategies`) - - Current live example: `STRATEGY_PROFILE=mega_cap_leader_rotation_top50_balanced` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) - Recommended secret-name values: `longport-app-key-paper`, `longport-app-secret-paper` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `RUNTIME_TARGET_JSON`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (strategy overrides only; leave unset to inherit `UsEquityStrategies`) - - Current live example: `STRATEGY_PROFILE=soxl_soxx_trend_income` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) - Recommended secret-name values: `longport-app-key-sg`, `longport-app-secret-sg` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`, `CLOUD_RUN_SERVICE`, `ACCOUNT_PREFIX`, `ACCOUNT_REGION`, `RUNTIME_TARGET_JSON`, `STRATEGY_PROFILE`, `LONGPORT_SECRET_NAME`, `LONGPORT_APP_KEY_SECRET_NAME`, `LONGPORT_APP_SECRET_SECRET_NAME` - - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (strategy overrides only; leave unset to inherit `UsEquityStrategies`) - - Current live example: `STRATEGY_PROFILE=tech_communication_pullback_enhancement` + - Optional variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`, `LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`, `LONGBRIDGE_STRATEGY_CONFIG_PATH`, `LONGBRIDGE_DRY_RUN_ONLY`, `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`, `LONGBRIDGE_MIN_RESERVED_CASH_USD`, `LONGBRIDGE_RESERVED_CASH_RATIO`, `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (leave unset to inherit platform and strategy defaults) - Recommended secret-name values: `longport-app-key-hk`, `longport-app-secret-hk` On every push to `main`, the workflow updates the configured Cloud Run services with the shared and per-environment values above, and removes `TELEGRAM_CHAT_ID` from each Cloud Run service. @@ -174,7 +173,7 @@ IAM: the Cloud Run service account needs **Secret Manager Admin** (or Secret Acc 基于 LongPort OpenAPI 和 Google Cloud Run 的量化交易系统。 这个仓库通过 `QuantPlatformKit` 复用 LongPort token 处理、上下文初始化、账户快照、行情读取和下单逻辑。Cloud Run 直接部署这个仓库。 -LongBridge 的账户身份按 `paper`、`HK`、`SG` 三个维度建模;当前线上运行的是 `paper` 和 `SG`,`HK` 以后按同样模式补齐。 +LongBridge 的账户身份按 `paper`、`HK`、`SG` 三个维度建模。 `LongBridgePlatform` 现在可直接执行 `UsEquityStrategies` 里的 7 条 `runtime_enabled` `us_equity` 策略:`global_etf_confidence_vol_gate`、`global_etf_rotation`、`mega_cap_leader_rotation_top50_balanced`、`russell_1000_multi_factor_defensive`、`soxl_soxx_trend_income`、`tqqq_growth_income` 和 `tech_communication_pullback_enhancement`。较弱或重复的研究 profile 已从 LongBridge 可配置入口移除。仓库本身继续保留 LongPort 运行时、token 刷新、执行和通知流程。 完整策略说明现在放在 [`UsEquityStrategies`](https://github.com/QuantStrategyLab/UsEquityStrategies)。下面这些章节只保留 LongBridge 运行时、profile 启用状态、部署和凭据说明。 @@ -231,6 +230,8 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 | | `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 LongBridge 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | +| `LONGBRIDGE_MIN_RESERVED_CASH_USD` | 否 | 平台级最低预留现金 USD。默认 `0`;实际预留取该下限、`LONGBRIDGE_RESERVED_CASH_RATIO * 总资产` 和策略预留中的最大值。 | +| `LONGBRIDGE_RESERVED_CASH_RATIO` | 否 | 平台级最低预留现金比例,取值 `[0,1]`。默认 `0`;只会抬高,不会降低策略预留。 | | `LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD` | 否 | `BOXX`/`BIL` 等避险现金替代标的目标金额低于该 USD 门槛时保留现金,不买入。默认 `1000`。 | | `INCOME_THRESHOLD_USD` | 否 | 可选的 `tqqq_growth_income` 收入层启动阈值覆盖(策略 override)。不填时使用策略包默认值。 | | `QQQI_INCOME_RATIO` | 否 | 可选的 QQQI 收入层占比覆盖,0–1(策略 override)。 | @@ -256,11 +257,11 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo ### 多部署隔离(paper/HK/SG) -同一代码库可部署为多个 Cloud Run 服务(如 `paper` 和 `SG`,`HK` 以后按同样模式补齐),通过以下变量区分: +同一代码库可部署为多个 Cloud Run 服务,通过以下变量区分: - `LONGPORT_SECRET_NAME`: 指向不同密钥(如 `longport_token_paper`、`longport_token_hk`、`longport_token_sg`) - `ACCOUNT_PREFIX`: 如 `PAPER`、`HK`、`SG`(所有通知/日志将包含 `[ACCOUNT_PREFIX]`) -- `STRATEGY_PROFILE`: 按服务分别设置;当前线上 paper 用 `mega_cap_leader_rotation_top50_balanced`,SG 用 `soxl_soxx_trend_income`,HK 以后按同样模式补齐。控制面会另外携带 `RUNTIME_TARGET_JSON`,`STRATEGY_PROFILE` 继续只作为兼容选择器。 +- `STRATEGY_PROFILE`: 按服务分别设置。控制面会另外携带 `RUNTIME_TARGET_JSON`,`STRATEGY_PROFILE` 继续只作为兼容选择器。 - 当前策略域是 `us_equity`。`STRATEGY_PROFILE` 现在会先经过平台能力矩阵,再经过从 `runtime_enabled` 策略元数据派生的 rollout allowlist:`eligible` 表示平台理论可跑,`enabled` 表示当前 rollout 真正放开。 - `ACCOUNT_REGION`: 显式标记部署账户区域(`PAPER` / `HK` / `SG`);未设置时会回退到 `ACCOUNT_PREFIX` 或 `DEFAULT` - `LONGBRIDGE_DRY_RUN_ONLY`: 需要保持模拟运行时按服务单独设置 @@ -281,18 +282,15 @@ Secret Manager 中需存在 `LONGPORT_SECRET_NAME` 指定的密钥(默认: `lo - 仅保留为 fallback:`TELEGRAM_TOKEN` - **GitHub Environment: `longbridge-paper`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`RUNTIME_TARGET_JSON`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(仅策略 override;不填则继承 `UsEquityStrategies`) - - 当前线上示例:`STRATEGY_PROFILE=mega_cap_leader_rotation_top50_balanced` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) - 建议的 secret-name 值:`longport-app-key-paper`、`longport-app-secret-paper` - **GitHub Environment: `longbridge-sg`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`RUNTIME_TARGET_JSON`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(仅策略 override;不填则继承 `UsEquityStrategies`) - - 当前线上示例:`STRATEGY_PROFILE=soxl_soxx_trend_income` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) - 建议的 secret-name 值:`longport-app-key-sg`、`longport-app-secret-sg` - **GitHub Environment: `longbridge-hk`** - Variables: `CLOUD_RUN_REGION`、`CLOUD_RUN_SERVICE`、`ACCOUNT_PREFIX`、`ACCOUNT_REGION`、`RUNTIME_TARGET_JSON`、`STRATEGY_PROFILE`、`LONGPORT_SECRET_NAME`、`LONGPORT_APP_KEY_SECRET_NAME`、`LONGPORT_APP_SECRET_SECRET_NAME` - - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(仅策略 override;不填则继承 `UsEquityStrategies`) - - 当前线上示例:`STRATEGY_PROFILE=tech_communication_pullback_enhancement` + - 可选 Variables: `LONGBRIDGE_FEATURE_SNAPSHOT_PATH`、`LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH`、`LONGBRIDGE_STRATEGY_CONFIG_PATH`、`LONGBRIDGE_DRY_RUN_ONLY`、`LONGBRIDGE_DEBUG_POSITION_SNAPSHOT`、`LONGBRIDGE_MIN_RESERVED_CASH_USD`、`LONGBRIDGE_RESERVED_CASH_RATIO`、`LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(不填则继承平台和策略默认值) - 建议的 secret-name 值:`longport-app-key-hk`、`longport-app-secret-hk` 每次 push 到 `main` 时,这个 workflow 会更新配置的 Cloud Run 服务,把共享和各自隔离的变量同步进去,并删除旧的 `TELEGRAM_CHAT_ID`。 diff --git a/application/runtime_strategy_adapters.py b/application/runtime_strategy_adapters.py index 28bf0ed..db1b959 100644 --- a/application/runtime_strategy_adapters.py +++ b/application/runtime_strategy_adapters.py @@ -20,6 +20,7 @@ class LongBridgeRuntimeStrategyAdapters: calculate_rotation_indicators_fn: Callable[..., Any] build_strategy_evaluation_inputs_fn: Callable[..., dict[str, Any]] map_strategy_decision_to_plan_fn: Callable[..., dict[str, Any]] + execution_policy: Mapping[str, Any] | None = None build_strategy_plugin_report_payload_fn: Callable[..., dict[str, Any]] | None = None load_configured_strategy_plugin_signals_fn: Callable[..., Any] | None = None parse_strategy_plugin_mounts_fn: Callable[..., Any] | None = None @@ -125,12 +126,15 @@ def resolve_rebalance_plan(self, *, indicators, snapshot=None, account_state=Non signal_text_fn=self.signal_text_fn, ) evaluation = self.strategy_runtime.evaluate(**evaluation_inputs) + runtime_metadata = dict(getattr(evaluation, "metadata", None) or {}) + if self.execution_policy is not None: + runtime_metadata.setdefault("longbridge_execution_policy", dict(self.execution_policy)) return self.map_strategy_decision_to_plan_fn( evaluation.decision, account_state=resolved_account_state if "account_state" in available_inputs else None, snapshot=resolved_snapshot, strategy_profile=self.strategy_profile, - runtime_metadata=getattr(evaluation, "metadata", None), + runtime_metadata=runtime_metadata, ) @@ -147,6 +151,7 @@ def build_runtime_strategy_adapters( calculate_rotation_indicators_fn: Callable[..., Any], build_strategy_evaluation_inputs_fn: Callable[..., dict[str, Any]], map_strategy_decision_to_plan_fn: Callable[..., dict[str, Any]], + execution_policy: Mapping[str, Any] | None = None, build_strategy_plugin_report_payload_fn: Callable[..., dict[str, Any]] | None = None, load_configured_strategy_plugin_signals_fn: Callable[..., Any] | None = None, parse_strategy_plugin_mounts_fn: Callable[..., Any] | None = None, @@ -163,6 +168,7 @@ def build_runtime_strategy_adapters( calculate_rotation_indicators_fn=calculate_rotation_indicators_fn, build_strategy_evaluation_inputs_fn=build_strategy_evaluation_inputs_fn, map_strategy_decision_to_plan_fn=map_strategy_decision_to_plan_fn, + execution_policy=dict(execution_policy) if execution_policy is not None else None, build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload_fn, load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals_fn, parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts_fn, diff --git a/decision_mapper.py b/decision_mapper.py index 3d52689..39e5979 100644 --- a/decision_mapper.py +++ b/decision_mapper.py @@ -1,6 +1,7 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import replace from typing import Any from us_equity_strategies.catalog import resolve_canonical_profile @@ -85,6 +86,50 @@ def _default_threshold_value(total_equity: float) -> float: return max(_DEFAULT_MIN_TRADE_FLOOR, float(total_equity) * _DEFAULT_REBALANCE_THRESHOLD_RATIO) +def _resolve_platform_reserved_cash( + *, + total_equity: float, + runtime_metadata: Mapping[str, Any] | None, +) -> float: + raw_policy = (runtime_metadata or {}).get("longbridge_execution_policy") + if not isinstance(raw_policy, Mapping): + return 0.0 + reserved_cash_floor_usd = max(0.0, float(raw_policy.get("reserved_cash_floor_usd", 0.0) or 0.0)) + reserved_cash_ratio = float(raw_policy.get("reserved_cash_ratio", 0.0) or 0.0) + reserved_cash_ratio = max(0.0, min(1.0, reserved_cash_ratio)) + return max(reserved_cash_floor_usd, max(0.0, float(total_equity)) * reserved_cash_ratio) + + +def _apply_reserved_cash_policy( + annotations: ValueTargetExecutionAnnotations, + *, + portfolio_inputs, + runtime_metadata: Mapping[str, Any] | None, +) -> ValueTargetExecutionAnnotations: + reserved_cash = max( + float(annotations.reserved_cash or 0.0), + _resolve_platform_reserved_cash( + total_equity=float(portfolio_inputs.total_equity), + runtime_metadata=runtime_metadata, + ), + ) + base_investable_cash = annotations.investable_cash + if base_investable_cash is None: + base_investable_cash = max( + 0.0, + float(portfolio_inputs.liquid_cash) - float(annotations.reserved_cash or 0.0), + ) + investable_cash = min( + max(0.0, float(base_investable_cash)), + max(0.0, float(portfolio_inputs.liquid_cash) - reserved_cash), + ) + return replace( + annotations, + reserved_cash=reserved_cash, + investable_cash=investable_cash, + ) + + def _build_weight_translation_annotations( decision: StrategyDecision, *, @@ -315,6 +360,7 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ ("risk", "income", "safe"), ( "trade_threshold_value", + "reserved_cash", "signal_display", "status_display", "dashboard_text", @@ -335,6 +381,7 @@ def _resolve_layout(strategy_profile: str) -> tuple[str, tuple[str, ...], tuple[ "current_min_trade", ), { + "reserved_cash": 0.0, "signal_display": "", "status_display": "", "dashboard_text": "", @@ -420,6 +467,12 @@ def map_strategy_decision_to_plan( investable_cash=investable_cash, ) + annotations = _apply_reserved_cash_policy( + annotations, + portfolio_inputs=portfolio_inputs, + runtime_metadata=runtime_metadata, + ) + strategy_symbols_order, portfolio_rows_layout, execution_fields, execution_defaults = _resolve_layout( canonical_profile ) diff --git a/main.py b/main.py index 6fb6bc4..93d5779 100644 --- a/main.py +++ b/main.py @@ -144,6 +144,10 @@ def log_runtime_warning(message): calculate_rotation_indicators_fn=calculate_rotation_indicators, build_strategy_evaluation_inputs_fn=build_strategy_evaluation_inputs, map_strategy_decision_to_plan_fn=map_strategy_decision_to_plan, + execution_policy={ + "reserved_cash_floor_usd": RUNTIME_SETTINGS.reserved_cash_floor_usd, + "reserved_cash_ratio": RUNTIME_SETTINGS.reserved_cash_ratio, + }, build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload, load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals, parse_strategy_plugin_mounts_fn=parse_strategy_plugin_mounts, diff --git a/runtime_config_support.py b/runtime_config_support.py index 0bfbd53..07ec298 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -23,6 +23,8 @@ DEFAULT_ACCOUNT_REGION = "DEFAULT" DEFAULT_LONGPORT_SECRET_NAME = "longport_token_hk" +DEFAULT_RESERVED_CASH_FLOOR_USD = 0.0 +DEFAULT_RESERVED_CASH_RATIO = 0.0 DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 @@ -39,6 +41,8 @@ class PlatformRuntimeSettings: tg_token: str | None tg_chat_id: str | None dry_run_only: bool + reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD + reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD debug_position_snapshot: bool = False income_threshold_usd: float | None = None @@ -118,6 +122,14 @@ def load_platform_runtime_settings( tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), dry_run_only=resolve_bool_value(os.getenv("LONGBRIDGE_DRY_RUN_ONLY")), + reserved_cash_floor_usd=_resolve_non_negative_float_env( + "LONGBRIDGE_MIN_RESERVED_CASH_USD", + default=DEFAULT_RESERVED_CASH_FLOOR_USD, + ), + reserved_cash_ratio=_resolve_ratio_env( + "LONGBRIDGE_RESERVED_CASH_RATIO", + default=DEFAULT_RESERVED_CASH_RATIO, + ), safe_haven_cash_substitute_threshold_usd=( max(0.0, safe_haven_cash_substitute_threshold_usd) if safe_haven_cash_substitute_threshold_usd is not None @@ -157,6 +169,22 @@ def _qqqi_income_ratio_env() -> float | None: return value +def _resolve_non_negative_float_env(name: str, *, default: float) -> float: + value = resolve_optional_float_env(os.environ, name) + if value is None: + return float(default) + if value < 0: + raise ValueError(f"{name} must be non-negative, got {value}") + return float(value) + + +def _resolve_ratio_env(name: str, *, default: float) -> float: + value = _resolve_non_negative_float_env(name, default=default) + if value > 1.0: + raise ValueError(f"{name} must be in [0,1], got {value}") + return value + + def _runtime_execution_window_trading_days_env(strategy_profile: str) -> int | None: if strategy_profile != "tech_communication_pullback_enhancement": return None diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index 4f0f520..92751b7 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -72,6 +72,8 @@ def build_switch_plan(profile: str, *, account_region: str | None = None) -> dic ] optional_env = [ "LONGBRIDGE_DRY_RUN_ONLY", + "LONGBRIDGE_MIN_RESERVED_CASH_USD", + "LONGBRIDGE_RESERVED_CASH_RATIO", "LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", ] remove_if_present: list[str] = [] diff --git a/tests/test_decision_mapper.py b/tests/test_decision_mapper.py index fd4d274..2214285 100644 --- a/tests/test_decision_mapper.py +++ b/tests/test_decision_mapper.py @@ -181,6 +181,70 @@ def test_translates_weight_decision_for_tech_strategy(self): self.assertEqual(plan["execution"]["benchmark_symbol"], "QQQ") self.assertEqual(plan["portfolio"]["portfolio_rows"], (("AAPL", "MSFT", "BOXX"),)) + def test_applies_platform_reserved_cash_policy_to_weight_decision(self): + decision = StrategyDecision( + positions=( + PositionTarget(symbol="AAPL", target_weight=0.5), + PositionTarget(symbol="MSFT", target_weight=0.5), + ), + diagnostics={"signal_description": "risk on"}, + ) + snapshot = PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=20000.0, + buying_power=4000.0, + positions=(Position(symbol="AAPL", quantity=10, market_value=1500.0),), + metadata={"account_hash": "longbridge-reserve"}, + ) + + plan = map_strategy_decision_to_plan( + decision, + snapshot=snapshot, + strategy_profile="mega_cap_leader_rotation_top50_balanced", + runtime_metadata={ + "longbridge_execution_policy": { + "reserved_cash_floor_usd": 1500.0, + "reserved_cash_ratio": 0.03, + } + }, + ) + + self.assertEqual(plan["execution"]["reserved_cash"], 1500.0) + self.assertEqual(plan["execution"]["investable_cash"], 2500.0) + + def test_platform_reserved_cash_policy_does_not_lower_strategy_reserve(self): + decision = StrategyDecision( + positions=(PositionTarget(symbol="TQQQ", target_value=5000.0),), + diagnostics={ + "execution_annotations": { + "trade_threshold_value": 100.0, + "reserved_cash": 1200.0, + } + }, + ) + snapshot = PortfolioSnapshot( + as_of=datetime.now(timezone.utc), + total_equity=10000.0, + buying_power=3000.0, + positions=(), + metadata={"account_hash": "longbridge-reserve"}, + ) + + plan = map_strategy_decision_to_plan( + decision, + snapshot=snapshot, + strategy_profile="tqqq_growth_income", + runtime_metadata={ + "longbridge_execution_policy": { + "reserved_cash_floor_usd": 150.0, + "reserved_cash_ratio": 0.03, + } + }, + ) + + self.assertEqual(plan["execution"]["reserved_cash"], 1200.0) + self.assertEqual(plan["execution"]["investable_cash"], 1800.0) + def test_keeps_cash_by_currency_from_snapshot_metadata(self): decision = StrategyDecision( positions=(PositionTarget(symbol="SOXL", target_value=0.0),), diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 9bb8130..c2e68a0 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -20,7 +20,10 @@ from runtime_config_support import ( DEFAULT_ACCOUNT_REGION, DEFAULT_LONGPORT_SECRET_NAME, + DEFAULT_RESERVED_CASH_FLOOR_USD, + DEFAULT_RESERVED_CASH_RATIO, DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, + _resolve_ratio_env, infer_account_region, load_platform_runtime_settings, ) @@ -104,6 +107,8 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro settings.safe_haven_cash_substitute_threshold_usd, DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, ) + self.assertEqual(settings.reserved_cash_floor_usd, DEFAULT_RESERVED_CASH_FLOOR_USD) + self.assertEqual(settings.reserved_cash_ratio, DEFAULT_RESERVED_CASH_RATIO) self.assertFalse(settings.debug_position_snapshot) self.assertIsNotNone(settings.runtime_target) self.assertEqual(settings.runtime_target.platform_id, "longbridge") @@ -194,6 +199,26 @@ def test_safe_haven_cash_substitute_threshold_is_loaded_from_env(self): self.assertEqual(settings.safe_haven_cash_substitute_threshold_usd, 750.0) + def test_reserved_cash_policy_is_loaded_from_env(self): + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), + "LONGBRIDGE_MIN_RESERVED_CASH_USD": "250", + "LONGBRIDGE_RESERVED_CASH_RATIO": "0.025", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.reserved_cash_floor_usd, 250.0) + self.assertEqual(settings.reserved_cash_ratio, 0.025) + + def test_reserved_cash_ratio_rejects_invalid_env(self): + with patch.dict(os.environ, {"LONGBRIDGE_RESERVED_CASH_RATIO": "1.25"}, clear=True): + with self.assertRaisesRegex(ValueError, "LONGBRIDGE_RESERVED_CASH_RATIO"): + _resolve_ratio_env("LONGBRIDGE_RESERVED_CASH_RATIO", default=0.0) + def test_strategy_plugin_mounts_are_loaded_from_env(self): mount_config = '{"strategy_plugins":[{"strategy":"soxl_soxx_trend_income","plugin":"crisis_response_shadow","signal_path":"gs://bucket/latest_signal.json"}]}' with patch.dict( @@ -481,6 +506,8 @@ def test_print_strategy_switch_env_plan_for_global_etf_rotation(self): self.assertEqual(plan["input_mode"], "market_history") self.assertFalse(plan["requires_snapshot_artifacts"]) self.assertFalse(plan["requires_strategy_config_path"]) + self.assertIn("LONGBRIDGE_MIN_RESERVED_CASH_USD", plan["optional_env"]) + self.assertIn("LONGBRIDGE_RESERVED_CASH_RATIO", plan["optional_env"]) self.assertIn("LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", plan["optional_env"]) self.assertIn("LONGBRIDGE_FEATURE_SNAPSHOT_PATH", plan["remove_if_present"]) diff --git a/tests/test_runtime_strategy_adapters.py b/tests/test_runtime_strategy_adapters.py index 7f1343f..e3b4ad4 100644 --- a/tests/test_runtime_strategy_adapters.py +++ b/tests/test_runtime_strategy_adapters.py @@ -134,12 +134,53 @@ def fake_map_plan(decision, **kwargs): "account_state": {"derived": True}, "snapshot": "snapshot-1", "strategy_profile": "soxl_soxx_trend_income", - "runtime_metadata": None, + "runtime_metadata": {}, }, ) assert result == {"plan": True} +def test_runtime_strategy_adapters_add_execution_policy_to_runtime_metadata(): + observed = {} + + class FakeBrokerAdapters: + def build_account_state_from_snapshot(self, _snapshot): + return None + + def fake_evaluate(**_kwargs): + return SimpleNamespace(decision="decision-1", metadata={"signal": "ok"}) + + def fake_map_plan(decision, **kwargs): + observed["map_call"] = (decision, kwargs) + return {"plan": True} + + adapters = build_runtime_strategy_adapters( + strategy_runtime=SimpleNamespace(evaluate=fake_evaluate), + strategy_profile="mega_cap_leader_rotation_top50_balanced", + strategy_runtime_config={}, + available_inputs=("portfolio_snapshot",), + benchmark_symbol="QQQ", + signal_text_fn=lambda icon: f"signal:{icon}", + translator=lambda key, **_kwargs: f"tr:{key}", + broker_adapters=FakeBrokerAdapters(), + calculate_rotation_indicators_fn=lambda *_args, **_kwargs: {}, + build_strategy_evaluation_inputs_fn=lambda **kwargs: kwargs, + map_strategy_decision_to_plan_fn=fake_map_plan, + execution_policy={"reserved_cash_floor_usd": 250.0, "reserved_cash_ratio": 0.03}, + ) + + result = adapters.resolve_rebalance_plan(indicators={}, snapshot="snapshot-1") + + assert result == {"plan": True} + assert observed["map_call"][1]["runtime_metadata"] == { + "signal": "ok", + "longbridge_execution_policy": { + "reserved_cash_floor_usd": 250.0, + "reserved_cash_ratio": 0.03, + }, + } + + def test_runtime_strategy_adapters_loads_and_reports_plugin_signals(): observed = {} signal = SimpleNamespace( diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 4e98d09..aabd400 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -44,6 +44,8 @@ grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH: ${{ vars.LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH }}' "$workflow_file" grep -Fq 'LONGBRIDGE_STRATEGY_CONFIG_PATH: ${{ vars.LONGBRIDGE_STRATEGY_CONFIG_PATH }}' "$workflow_file" grep -Fq 'LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON: ${{ vars.LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON }}' "$workflow_file" +grep -Fq 'LONGBRIDGE_MIN_RESERVED_CASH_USD: ${{ vars.LONGBRIDGE_MIN_RESERVED_CASH_USD }}' "$workflow_file" +grep -Fq 'LONGBRIDGE_RESERVED_CASH_RATIO: ${{ vars.LONGBRIDGE_RESERVED_CASH_RATIO }}' "$workflow_file" grep -Fq 'LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD: ${{ vars.LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD }}' "$workflow_file" grep -Fq 'INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}' "$workflow_file" grep -Fq 'QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}' "$workflow_file" @@ -80,7 +82,11 @@ grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_PATH=${LONGBRIDGE_FEATURE_SNAPSHOT_PATH}' grep -Fq 'LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH=${LONGBRIDGE_FEATURE_SNAPSHOT_MANIFEST_PATH}' "$workflow_file" grep -Fq 'LONGBRIDGE_STRATEGY_CONFIG_PATH=${LONGBRIDGE_STRATEGY_CONFIG_PATH}' "$workflow_file" grep -Fq 'LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON=${LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON}' "$workflow_file" +grep -Fq 'LONGBRIDGE_MIN_RESERVED_CASH_USD=${LONGBRIDGE_MIN_RESERVED_CASH_USD}' "$workflow_file" +grep -Fq 'LONGBRIDGE_RESERVED_CASH_RATIO=${LONGBRIDGE_RESERVED_CASH_RATIO}' "$workflow_file" grep -Fq 'LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=${LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD}' "$workflow_file" +grep -Fq 'remove_env_vars+=("LONGBRIDGE_MIN_RESERVED_CASH_USD")' "$workflow_file" +grep -Fq 'remove_env_vars+=("LONGBRIDGE_RESERVED_CASH_RATIO")' "$workflow_file" grep -Fq 'remove_env_vars+=("LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD")' "$workflow_file" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY=${LONGBRIDGE_DRY_RUN_ONLY}' "$workflow_file" grep -Fq 'INCOME_THRESHOLD_USD=${INCOME_THRESHOLD_USD}' "$workflow_file" From 7199e65411e3bd75ef703d88b55782e308e2d696 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 24 May 2026 20:40:32 +0800 Subject: [PATCH 2/4] Keep cash reserve policy backward compatible --- main.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/main.py b/main.py index 93d5779..4a96c53 100644 --- a/main.py +++ b/main.py @@ -145,8 +145,8 @@ def log_runtime_warning(message): build_strategy_evaluation_inputs_fn=build_strategy_evaluation_inputs, map_strategy_decision_to_plan_fn=map_strategy_decision_to_plan, execution_policy={ - "reserved_cash_floor_usd": RUNTIME_SETTINGS.reserved_cash_floor_usd, - "reserved_cash_ratio": RUNTIME_SETTINGS.reserved_cash_ratio, + "reserved_cash_floor_usd": getattr(RUNTIME_SETTINGS, "reserved_cash_floor_usd", 0.0), + "reserved_cash_ratio": getattr(RUNTIME_SETTINGS, "reserved_cash_ratio", 0.0), }, build_strategy_plugin_report_payload_fn=build_strategy_plugin_report_payload, load_configured_strategy_plugin_signals_fn=load_configured_strategy_plugin_signals, From 9bdbd046363490807fa6fcb4619a68bc8a5a7338 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 24 May 2026 20:44:58 +0800 Subject: [PATCH 3/4] Enforce platform cash reserve policy --- application/runtime_strategy_adapters.py | 2 +- tests/test_runtime_strategy_adapters.py | 11 ++++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/application/runtime_strategy_adapters.py b/application/runtime_strategy_adapters.py index db1b959..30e0b8c 100644 --- a/application/runtime_strategy_adapters.py +++ b/application/runtime_strategy_adapters.py @@ -128,7 +128,7 @@ def resolve_rebalance_plan(self, *, indicators, snapshot=None, account_state=Non evaluation = self.strategy_runtime.evaluate(**evaluation_inputs) runtime_metadata = dict(getattr(evaluation, "metadata", None) or {}) if self.execution_policy is not None: - runtime_metadata.setdefault("longbridge_execution_policy", dict(self.execution_policy)) + runtime_metadata["longbridge_execution_policy"] = dict(self.execution_policy) return self.map_strategy_decision_to_plan_fn( evaluation.decision, account_state=resolved_account_state if "account_state" in available_inputs else None, diff --git a/tests/test_runtime_strategy_adapters.py b/tests/test_runtime_strategy_adapters.py index e3b4ad4..64159f3 100644 --- a/tests/test_runtime_strategy_adapters.py +++ b/tests/test_runtime_strategy_adapters.py @@ -148,7 +148,16 @@ def build_account_state_from_snapshot(self, _snapshot): return None def fake_evaluate(**_kwargs): - return SimpleNamespace(decision="decision-1", metadata={"signal": "ok"}) + return SimpleNamespace( + decision="decision-1", + metadata={ + "signal": "ok", + "longbridge_execution_policy": { + "reserved_cash_floor_usd": 1.0, + "reserved_cash_ratio": 0.0, + }, + }, + ) def fake_map_plan(decision, **kwargs): observed["map_call"] = (decision, kwargs) From 2f92e4b3b624a3f847217b570b5edb7d27e68f81 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sun, 24 May 2026 20:52:49 +0800 Subject: [PATCH 4/4] Reject non-finite cash reserve settings --- runtime_config_support.py | 3 +++ tests/test_runtime_config_support.py | 32 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+) diff --git a/runtime_config_support.py b/runtime_config_support.py index 07ec298..f779eed 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -1,5 +1,6 @@ from __future__ import annotations +import math import os from dataclasses import dataclass from pathlib import Path @@ -173,6 +174,8 @@ def _resolve_non_negative_float_env(name: str, *, default: float) -> float: value = resolve_optional_float_env(os.environ, name) if value is None: return float(default) + if not math.isfinite(value): + raise ValueError(f"{name} must be finite, got {value}") if value < 0: raise ValueError(f"{name} must be non-negative, got {value}") return float(value) diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index c2e68a0..8cb1eb0 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -23,6 +23,7 @@ DEFAULT_RESERVED_CASH_FLOOR_USD, DEFAULT_RESERVED_CASH_RATIO, DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, + _resolve_non_negative_float_env, _resolve_ratio_env, infer_account_region, load_platform_runtime_settings, @@ -219,6 +220,37 @@ def test_reserved_cash_ratio_rejects_invalid_env(self): with self.assertRaisesRegex(ValueError, "LONGBRIDGE_RESERVED_CASH_RATIO"): _resolve_ratio_env("LONGBRIDGE_RESERVED_CASH_RATIO", default=0.0) + def test_reserved_cash_floor_rejects_non_finite_env(self): + for raw_value in ("nan", "inf", "-inf"): + with self.subTest(raw_value=raw_value): + with patch.dict( + os.environ, + {"LONGBRIDGE_MIN_RESERVED_CASH_USD": raw_value}, + clear=True, + ): + with self.assertRaisesRegex( + ValueError, + "LONGBRIDGE_MIN_RESERVED_CASH_USD must be finite", + ): + _resolve_non_negative_float_env( + "LONGBRIDGE_MIN_RESERVED_CASH_USD", + default=0.0, + ) + + def test_reserved_cash_ratio_rejects_non_finite_env(self): + for raw_value in ("nan", "inf", "-inf"): + with self.subTest(raw_value=raw_value): + with patch.dict( + os.environ, + {"LONGBRIDGE_RESERVED_CASH_RATIO": raw_value}, + clear=True, + ): + with self.assertRaisesRegex( + ValueError, + "LONGBRIDGE_RESERVED_CASH_RATIO must be finite", + ): + _resolve_ratio_env("LONGBRIDGE_RESERVED_CASH_RATIO", default=0.0) + def test_strategy_plugin_mounts_are_loaded_from_env(self): mount_config = '{"strategy_plugins":[{"strategy":"soxl_soxx_trend_income","plugin":"crisis_response_shadow","signal_path":"gs://bucket/latest_signal.json"}]}' with patch.dict(