diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index b33bacf..da471fc 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -48,6 +48,7 @@ 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_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 }} QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} @@ -337,6 +338,12 @@ jobs: remove_env_vars+=("LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON") fi + if [ -n "${LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD:-}" ]; then + env_pairs+=("LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=${LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD}") + else + remove_env_vars+=("LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD") + 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 a514ba2..57c8e79 100644 --- a/README.md +++ b/README.md @@ -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`. | +| `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. | | `NOTIFY_LANG` | No | Notification language: `en` (English, default) or `zh` (Chinese) | @@ -119,17 +120,17 @@ 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`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (strategy overrides only; leave unset to inherit `UsEquityStrategies`) + - 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` - 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`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (strategy overrides only; leave unset to inherit `UsEquityStrategies`) + - 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` - 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`, `INCOME_THRESHOLD_USD`, `QQQI_INCOME_RATIO` (strategy overrides only; leave unset to inherit `UsEquityStrategies`) + - 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` - Recommended secret-name values: `longport-app-key-hk`, `longport-app-secret-hk` @@ -230,6 +231,7 @@ Telegram 通知包含结构化的调仓和心跳消息,支持中英文切换 | `LONGBRIDGE_DRY_RUN_ONLY` | 否 | 设为 `true` 时,该部署保持 dry-run。 | | `LONGBRIDGE_DEBUG_POSITION_SNAPSHOT` | 否 | 设为 `true` 时输出 LongBridge 原始持仓数量和可卖数量,便于排查。 | | `LONGBRIDGE_STRATEGY_PLUGIN_MOUNTS_JSON` | 否 | 可选的 LongBridge 侧策略插件挂载 JSON。插件 artifact 自带模式;平台配置不要设置 `mode`。 | +| `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)。 | | `NOTIFY_LANG` | 否 | 通知语言: `en`(英文,默认)或 `zh`(中文) | @@ -279,17 +281,17 @@ 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`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(仅策略 override;不填则继承 `UsEquityStrategies`) + - 可选 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` - 建议的 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`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(仅策略 override;不填则继承 `UsEquityStrategies`) + - 可选 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` - 建议的 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`、`INCOME_THRESHOLD_USD`、`QQQI_INCOME_RATIO`(仅策略 override;不填则继承 `UsEquityStrategies`) + - 可选 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` - 建议的 secret-name 值:`longport-app-key-hk`、`longport-app-secret-hk` diff --git a/application/execution_service.py b/application/execution_service.py index febfe85..0c089da 100644 --- a/application/execution_service.py +++ b/application/execution_service.py @@ -88,10 +88,53 @@ class ExecutionCycleResult: action_done: bool +DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 + + def _noop_sleep(_seconds): return None +def _safe_haven_cash_symbols(*, portfolio: dict, allocation: dict) -> tuple[str, ...]: + symbols: list[str] = [] + for symbol in allocation.get("safe_haven_symbols", ()): + normalized = str(symbol or "").strip().upper() + if normalized: + symbols.append(normalized) + cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper() + if cash_sweep_symbol: + symbols.append(cash_sweep_symbol) + return tuple(dict.fromkeys(symbols)) + + +def _apply_safe_haven_cash_substitution( + *, + plan, + portfolio, + allocation, + threshold_usd, +) -> tuple[dict, dict]: + threshold = max(0.0, float(threshold_usd or 0.0)) + target_values = { + str(symbol).strip().upper(): float(value or 0.0) + for symbol, value in dict(allocation.get("targets") or {}).items() + } + if threshold <= 0.0: + return dict(plan or {}), {**dict(allocation or {}), "targets": target_values} + + changed = False + for symbol in _safe_haven_cash_symbols(portfolio=portfolio, allocation=allocation): + target_value = float(target_values.get(symbol, 0.0) or 0.0) + if 0.0 < target_value < threshold: + target_values[symbol] = 0.0 + changed = True + adjusted_allocation = {**dict(allocation or {}), "targets": target_values} + adjusted_plan = dict(plan or {}) + if changed: + adjusted_plan["allocation"] = adjusted_allocation + return adjusted_plan, adjusted_allocation + + def _normalize_cash_by_currency(raw_cash) -> dict[str, float]: if not isinstance(raw_cash, Mapping): return {} @@ -249,6 +292,7 @@ def execute_rebalance_cycle( post_sell_refresh_attempts=1, post_sell_refresh_interval_sec=0.0, sleeper=_noop_sleep, + safe_haven_cash_substitute_threshold_usd=DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, ) -> ExecutionCycleResult: logs: list[str] = [] skip_logs: list[str] = [] @@ -264,6 +308,12 @@ def execute_rebalance_cycle( market_values = dict(portfolio["market_values"]) quantities = dict(portfolio["quantities"]) sellable_quantities = dict(portfolio["sellable_quantities"]) + plan, allocation = _apply_safe_haven_cash_substitution( + plan=plan, + portfolio=portfolio, + allocation=allocation, + threshold_usd=safe_haven_cash_substitute_threshold_usd, + ) target_values = dict(allocation["targets"]) cash_sweep_symbol = str(portfolio.get("cash_sweep_symbol") or "").strip().upper() available_cash = float(portfolio["liquid_cash"]) @@ -533,6 +583,12 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): best_refreshed_state = refreshed_state break plan, portfolio, execution, allocation = best_refreshed_state + plan, allocation = _apply_safe_haven_cash_substitution( + plan=plan, + portfolio=portfolio, + allocation=allocation, + threshold_usd=safe_haven_cash_substitute_threshold_usd, + ) threshold_value = float(execution["trade_threshold_value"]) limit_order_symbols = set( allocation.get("risk_symbols", ()) + allocation.get("income_symbols", ()) @@ -692,7 +748,14 @@ def record_dry_run(symbol, side, quantity, price, *, order_type): notify_issue=notify_issue, ) if cash_sweep_price is not None and cash_sweep_price > 0.0 and investable_cash > cash_sweep_price * 2: - quantity = int(investable_cash // cash_sweep_price) + substitution_threshold = max( + 0.0, + float(safe_haven_cash_substitute_threshold_usd or 0.0), + ) + if substitution_threshold <= 0.0 or investable_cash >= substitution_threshold: + quantity = int(investable_cash // cash_sweep_price) + else: + quantity = 0 if quantity > 0: quantity_text = format_quantity(quantity) if dry_run_only: diff --git a/application/rebalance_service.py b/application/rebalance_service.py index 754dc54..7e6b54e 100644 --- a/application/rebalance_service.py +++ b/application/rebalance_service.py @@ -214,6 +214,7 @@ def fetch_replanned_state(): post_sell_refresh_attempts=config.post_sell_refresh_attempts, post_sell_refresh_interval_sec=config.post_sell_refresh_interval_sec, sleeper=config.sleeper or _noop_sleep, + safe_haven_cash_substitute_threshold_usd=config.safe_haven_cash_substitute_threshold_usd, ) execution = execution_result.execution logs = list(execution_result.logs) diff --git a/application/runtime_composer.py b/application/runtime_composer.py index 384ea68..05b1c63 100644 --- a/application/runtime_composer.py +++ b/application/runtime_composer.py @@ -39,6 +39,7 @@ class LongBridgeRuntimeComposer: limit_buy_premium: float order_poll_interval_sec: int order_poll_max_attempts: int + safe_haven_cash_substitute_threshold_usd: float dry_run_only: bool = False broker_adapters: Any = None strategy_adapters: Any = None @@ -184,6 +185,7 @@ def build_rebalance_config(self, *, strategy_plugin_signals=()) -> LongBridgeReb dry_run_only=self.dry_run_only, post_sell_refresh_attempts=self.order_poll_max_attempts, post_sell_refresh_interval_sec=self.order_poll_interval_sec, + safe_haven_cash_substitute_threshold_usd=self.safe_haven_cash_substitute_threshold_usd, sleeper=self.sleeper, extra_notification_lines=getattr( self.strategy_adapters, @@ -228,6 +230,7 @@ def build_runtime_composer( limit_buy_premium: float, order_poll_interval_sec: int, order_poll_max_attempts: int, + safe_haven_cash_substitute_threshold_usd: float, dry_run_only: bool, dry_run_only_override: bool | None = None, broker_adapters: Any, @@ -269,6 +272,7 @@ def build_runtime_composer( limit_buy_premium=float(limit_buy_premium), order_poll_interval_sec=int(order_poll_interval_sec), order_poll_max_attempts=int(order_poll_max_attempts), + safe_haven_cash_substitute_threshold_usd=float(safe_haven_cash_substitute_threshold_usd), dry_run_only=bool(dry_run_only if dry_run_only_override is None else dry_run_only_override), broker_adapters=broker_adapters, strategy_adapters=strategy_adapters, diff --git a/application/runtime_dependencies.py b/application/runtime_dependencies.py index 00faf7f..d3bad10 100644 --- a/application/runtime_dependencies.py +++ b/application/runtime_dependencies.py @@ -20,6 +20,7 @@ class LongBridgeRebalanceConfig: dry_run_only: bool = False post_sell_refresh_attempts: int = 1 post_sell_refresh_interval_sec: float = 0.0 + safe_haven_cash_substitute_threshold_usd: float = 1000.0 sleeper: Callable[[float], None] | None = None extra_notification_lines: tuple[str, ...] = () diff --git a/main.py b/main.py index d75fa0d..6fb6bc4 100644 --- a/main.py +++ b/main.py @@ -92,6 +92,7 @@ def get_project_id(): # Token refresh: days before expiry to trigger refresh TOKEN_REFRESH_THRESHOLD_DAYS = 30 +DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 SEPARATOR = "━━━━━━━━━━━━━━━━━━" @@ -149,6 +150,16 @@ def log_runtime_warning(message): ) +def _safe_haven_cash_substitute_threshold_usd() -> float: + return float( + getattr( + RUNTIME_SETTINGS, + "safe_haven_cash_substitute_threshold_usd", + DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, + ) + ) + + def build_composer(*, dry_run_only_override: bool | None = None): return build_runtime_composer( project_id=PROJECT_ID, @@ -171,6 +182,7 @@ def build_composer(*, dry_run_only_override: bool | None = None): limit_buy_premium=LIMIT_BUY_PREMIUM, order_poll_interval_sec=ORDER_POLL_INTERVAL_SEC, order_poll_max_attempts=ORDER_POLL_MAX_ATTEMPTS, + safe_haven_cash_substitute_threshold_usd=_safe_haven_cash_substitute_threshold_usd(), dry_run_only=RUNTIME_SETTINGS.dry_run_only, dry_run_only_override=dry_run_only_override, broker_adapters=BROKER_ADAPTERS, diff --git a/runtime_config_support.py b/runtime_config_support.py index 7cae507..0bfbd53 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -23,6 +23,7 @@ DEFAULT_ACCOUNT_REGION = "DEFAULT" DEFAULT_LONGPORT_SECRET_NAME = "longport_token_hk" +DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD = 1000.0 @dataclass(frozen=True) @@ -38,6 +39,7 @@ class PlatformRuntimeSettings: tg_token: str | None tg_chat_id: str | None dry_run_only: bool + 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 qqqi_income_ratio: float | None = None @@ -77,6 +79,10 @@ def load_platform_runtime_settings( project_id_resolver: Callable[[], str | None], ) -> PlatformRuntimeSettings: account_prefix = os.getenv("ACCOUNT_PREFIX", "DEFAULT") + safe_haven_cash_substitute_threshold_usd = resolve_optional_float_env( + os.environ, + "LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", + ) runtime_target = resolve_runtime_target_from_env( env=os.environ, expected_platform_id=LONGBRIDGE_PLATFORM, @@ -112,6 +118,11 @@ 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")), + 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 + else DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD + ), debug_position_snapshot=resolve_bool_value(os.getenv("LONGBRIDGE_DEBUG_POSITION_SNAPSHOT")), income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"), qqqi_income_ratio=_qqqi_income_ratio_env(), diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index 61fd952..4f0f520 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -70,7 +70,10 @@ def build_switch_plan(profile: str, *, account_region: str | None = None) -> dic "LONGPORT_APP_KEY_SECRET_NAME", "LONGPORT_APP_SECRET_SECRET_NAME", ] - optional_env = ["LONGBRIDGE_DRY_RUN_ONLY"] + optional_env = [ + "LONGBRIDGE_DRY_RUN_ONLY", + "LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", + ] remove_if_present: list[str] = [] notes = [ "Keep ACCOUNT_PREFIX and ACCOUNT_REGION aligned to the current paper or SG service identity.", diff --git a/tests/test_rebalance_service.py b/tests/test_rebalance_service.py index 1a69f6e..5c6b46f 100644 --- a/tests/test_rebalance_service.py +++ b/tests/test_rebalance_service.py @@ -23,6 +23,7 @@ sys.modules["requests"] = requests_stub try: from application import rebalance_service + from application.execution_service import execute_rebalance_cycle from application.runtime_dependencies import LongBridgeRebalanceConfig, LongBridgeRebalanceRuntime from notifications.telegram import build_translator from quant_platform_kit.common.models import ExecutionReport, PortfolioSnapshot, Position, QuoteSnapshot @@ -155,6 +156,61 @@ def _build_snapshot(plan, *, phase=""): class RebalanceServiceNotificationTests(unittest.TestCase): + def test_safe_haven_target_below_cash_substitute_threshold_stays_cash(self): + submitted_orders = [] + plan = _build_plan( + strategy_symbols=("BOXX",), + safe_haven_symbols=("BOXX",), + targets={"BOXX": 750.0}, + market_values={"BOXX": 0.0}, + sellable_quantities={"BOXX": 0}, + quantities={"BOXX": 0}, + current_min_trade=10.0, + trade_threshold_value=10.0, + investable_cash=750.0, + market_status="Cash substitute", + deploy_ratio_text="0.0%", + income_ratio_text="0.0%", + income_locked_ratio_text="0.0%", + signal_message="Small safe-haven sleeve", + available_cash=750.0, + total_strategy_equity=750.0, + portfolio_rows=(("BOXX",),), + ) + + result = execute_rebalance_cycle( + trade_context=object(), + plan=plan, + portfolio=plan["portfolio"], + execution=plan["execution"], + allocation=plan["allocation"], + fetch_replanned_state=lambda: ( + plan, + plan["portfolio"], + plan["execution"], + plan["allocation"], + ), + market_data_port=CallableMarketDataPort( + quote_loader=lambda symbol: QuoteSnapshot( + symbol=symbol, + as_of="2026-04-21", + last_price=100.0, + ) + ), + estimate_max_purchase_quantity=lambda *_args, **_kwargs: 10, + execution_port=CallableExecutionPort(lambda order_intent: submitted_orders.append(order_intent)), + notify_issue=lambda _title, _detail: None, + translator=build_translator("zh"), + with_prefix=lambda message: message, + limit_sell_discount=0.995, + limit_buy_premium=1.0, + safe_haven_cash_substitute_threshold_usd=1000.0, + ) + + self.assertFalse(result.action_done) + self.assertEqual(submitted_orders, []) + self.assertEqual(result.allocation["targets"]["BOXX"], 0.0) + def test_run_strategy_prefers_portfolio_port_runtime_path(self): sent_messages = [] observed = {} @@ -549,20 +605,20 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self): strategy_symbols=("SOXL", "SOXX", "BOXX"), risk_symbols=("SOXL", "SOXX"), safe_haven_symbols=("BOXX",), - targets={"SOXL": 0.0, "SOXX": 163.14, "BOXX": 924.46}, + targets={"SOXL": 0.0, "SOXX": 163.14, "BOXX": 1224.46}, market_values={"SOXL": 0.0, "SOXX": 0.0, "BOXX": 699.54}, sellable_quantities={"SOXL": 0, "SOXX": 0, "BOXX": 6}, quantities={"SOXL": 0, "SOXX": 0, "BOXX": 6}, current_min_trade=100.0, trade_threshold_value=100.0, - investable_cash=891.03, + investable_cash=1191.03, market_status="🧯 过热降档(SOXX)", deploy_ratio_text="15.0%", income_ratio_text="0.0%", income_locked_ratio_text="0.0%", signal_message="SOXX 目标仓位 15.0%", - available_cash=923.66, - total_strategy_equity=1087.60, + available_cash=1223.66, + total_strategy_equity=1387.60, portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), ) @@ -575,10 +631,10 @@ def test_strategy_target_rebuys_cash_sweep_symbol_after_buy_skip(self): self.assertIn("🔔 【调仓指令】", sent_messages[0]) self.assertIn("SOXX.US 目标差额 $163.14", sent_messages[0]) self.assertIn("SOXX.US 目标差额 $163.14 未超过 1 股价格 $504.60", sent_messages[0]) - self.assertNotIn("可投资现金 $891.03 不足买入 1 股", sent_messages[0]) + self.assertNotIn("可投资现金 $1191.03 不足买入 1 股", sent_messages[0]) self.assertNotIn("市价卖出] BOXX", sent_messages[0]) self.assertNotIn("市价买入] SOXX", sent_messages[0]) - self.assertIn("市价买入] BOXX: 7股", sent_messages[0]) + self.assertIn("市价买入] BOXX: 10股", sent_messages[0]) self.assertIn("买入说明", sent_messages[0]) self.assertNotIn("限价买入] SOXX", sent_messages[0]) @@ -588,32 +644,32 @@ def test_target_gap_below_one_share_does_not_report_cash_shortage(self): risk_symbols=("SOXL", "SOXX"), income_symbols=("QQQI", "SPYI"), safe_haven_symbols=("BOXX",), - targets={"SOXL": 636.28, "SOXX": 218.01, "BOXX": 105.08, "QQQI": 0.0, "SPYI": 0.0}, + targets={"SOXL": 636.28, "SOXX": 218.01, "BOXX": 1005.08, "QQQI": 0.0, "SPYI": 0.0}, market_values={"SOXL": 636.28, "SOXX": 218.01, "BOXX": 0.0, "QQQI": 0.0, "SPYI": 0.0}, sellable_quantities={"SOXL": 4, "SOXX": 0.4326, "BOXX": 0, "QQQI": 0, "SPYI": 0}, quantities={"SOXL": 4, "SOXX": 0.4326, "BOXX": 0, "QQQI": 0, "SPYI": 0}, current_min_trade=100.0, trade_threshold_value=100.0, - investable_cash=164.98, + investable_cash=1064.98, market_status="🚀 风险开启(SOXX+SOXL)", deploy_ratio_text="90.0%", income_ratio_text="0.0%", income_locked_ratio_text="0.0%", signal_message="SOXX 站上 140 日门槛线,持有 SOXL 70.0% + SOXX 20.0%", - available_cash=196.50, - total_strategy_equity=1050.79, + available_cash=1096.50, + total_strategy_equity=1950.79, portfolio_rows=(("SOXL", "SOXX"), ("BOXX", "QQQI", "SPYI")), ) sent_messages, _, _ = self._run_strategy( plan, - prices={"BOXX.US": 116.74}, + prices={"BOXX.US": 1167.40}, dry_run_only=True, ) self.assertEqual(len(sent_messages), 1) self.assertIn("💓 【心跳检测】", sent_messages[0]) - self.assertIn("BOXX.US 目标差额 $105.08 未超过 1 股价格 $116.74", sent_messages[0]) + self.assertIn("BOXX.US 目标差额 $1005.08 未超过 1 股价格 $1167.40", sent_messages[0]) self.assertNotIn("可投资现金 $164.98 不足买入 1 股", sent_messages[0]) def test_strategy_target_buy_floors_to_cash_backed_whole_shares(self): @@ -769,20 +825,20 @@ def test_market_buy_floors_to_whole_shares(self): plan = _build_plan( strategy_symbols=("BOXX",), safe_haven_symbols=("BOXX",), - targets={"BOXX": 150.0}, + targets={"BOXX": 1150.0}, market_values={"BOXX": 0.0}, sellable_quantities={"BOXX": 0}, quantities={"BOXX": 0}, current_min_trade=100.0, trade_threshold_value=100.0, - investable_cash=200.0, + investable_cash=1200.0, market_status="🚀 风险开启(BOXX)", deploy_ratio_text="100.0%", income_ratio_text="0.0%", income_locked_ratio_text="0.0%", signal_message="BOXX 目标仓位 100.0%", - available_cash=200.0, - total_strategy_equity=150.0, + available_cash=1200.0, + total_strategy_equity=1150.0, portfolio_rows=(("BOXX",),), ) @@ -793,8 +849,8 @@ def test_market_buy_floors_to_whole_shares(self): ) self.assertEqual(len(sent_messages), 1) - self.assertIn("市价买入] BOXX: 1股", sent_messages[0]) - self.assertNotIn("市价买入] BOXX: 1.5股", sent_messages[0]) + self.assertIn("市价买入] BOXX: 10股", sent_messages[0]) + self.assertNotIn("市价买入] BOXX: 10.5股", sent_messages[0]) def test_zero_target_sell_uses_sellable_quantity_not_price_derived_floor(self): plan = _build_plan( @@ -1015,7 +1071,7 @@ def test_cash_sweep_symbol_can_fund_buy_when_investable_cash_is_zero(self): quantities={"SOXL": 0, "SOXX": 0, "BOXX": 5}, current_min_trade=100.0, trade_threshold_value=100.0, - investable_cash=500.0, + investable_cash=1200.0, market_status="🧯 过热降档(SOXX)", deploy_ratio_text="15.0%", income_ratio_text="0.0%", @@ -1302,14 +1358,14 @@ def test_dry_run_rebuys_cash_sweep_symbol_with_remaining_investable_cash(self): quantities={"SOXL": 0, "SOXX": 0, "BOXX": 0}, current_min_trade=100.0, trade_threshold_value=100.0, - investable_cash=500.0, + investable_cash=1200.0, market_status="🧯 过热降档(SOXX)", deploy_ratio_text="0.0%", income_ratio_text="0.0%", income_locked_ratio_text="0.0%", signal_message="无其他买单,仅保留现金回补", - available_cash=500.0, - total_strategy_equity=500.0, + available_cash=1200.0, + total_strategy_equity=1200.0, portfolio_rows=(("SOXL", "SOXX"), ("BOXX",)), ) diff --git a/tests/test_runtime_composer.py b/tests/test_runtime_composer.py index f3552eb..4e279d6 100644 --- a/tests/test_runtime_composer.py +++ b/tests/test_runtime_composer.py @@ -51,6 +51,7 @@ def fake_bootstrap_builder(**kwargs): limit_buy_premium=1.005, order_poll_interval_sec=1, order_poll_max_attempts=8, + safe_haven_cash_substitute_threshold_usd=1000.0, dry_run_only=True, runtime_target=build_runtime_target( platform_id="longbridge", @@ -129,3 +130,4 @@ def fake_bootstrap_builder(**kwargs): assert config.limit_buy_premium == 1.005 assert config.strategy_display_name == "SOXL/SOXX 半导体趋势收益" assert config.dry_run_only is True + assert config.safe_haven_cash_substitute_threshold_usd == 1000.0 diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 85645ad..9bb8130 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -20,6 +20,7 @@ from runtime_config_support import ( DEFAULT_ACCOUNT_REGION, DEFAULT_LONGPORT_SECRET_NAME, + DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, infer_account_region, load_platform_runtime_settings, ) @@ -99,6 +100,10 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro self.assertIsNone(settings.tg_token) self.assertIsNone(settings.tg_chat_id) self.assertFalse(settings.dry_run_only) + self.assertEqual( + settings.safe_haven_cash_substitute_threshold_usd, + DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD, + ) self.assertFalse(settings.debug_position_snapshot) self.assertIsNotNone(settings.runtime_target) self.assertEqual(settings.runtime_target.platform_id, "longbridge") @@ -176,6 +181,19 @@ def test_debug_position_snapshot_is_loaded_from_env(self): self.assertTrue(settings.debug_position_snapshot) + def test_safe_haven_cash_substitute_threshold_is_loaded_from_env(self): + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE), + "LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD": "750", + }, + clear=True, + ): + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + self.assertEqual(settings.safe_haven_cash_substitute_threshold_usd, 750.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( @@ -463,6 +481,7 @@ 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_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD", plan["optional_env"]) self.assertIn("LONGBRIDGE_FEATURE_SNAPSHOT_PATH", plan["remove_if_present"]) def test_print_strategy_switch_env_plan_for_russell(self): diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 0d2182b..4e98d09 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -44,6 +44,7 @@ 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_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" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}' "$workflow_file" @@ -79,6 +80,8 @@ 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_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD=${LONGBRIDGE_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD}' "$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" grep -Fq 'QQQI_INCOME_RATIO=${QQQI_INCOME_RATIO}' "$workflow_file"