From 2c4f1885f4b16291c274b28240d0966a10810806 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:15:47 +0800 Subject: [PATCH 1/2] Wire IBIT zscore exit runtime settings --- .github/workflows/sync-cloud-run-env.yml | 6 +++ runtime_config_support.py | 49 +++++++++++++++++++++++ scripts/build_cloud_run_env_sync_plan.py | 6 +++ scripts/print_strategy_switch_env_plan.py | 6 +++ strategy_runtime.py | 24 +++++++++++ tests/test_runtime_config_support.py | 21 ++++++++++ tests/test_strategy_runtime.py | 37 +++++++++++++++++ tests/test_sync_cloud_run_env_workflow.sh | 6 +++ 8 files changed, 155 insertions(+) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 2a3f36c..419dd59 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -111,6 +111,12 @@ jobs: INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} DCA_MODE: ${{ vars.DCA_MODE }} DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }} + IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }} + IBIT_ZSCORE_EXIT_MODE: ${{ vars.IBIT_ZSCORE_EXIT_MODE }} + IBIT_ZSCORE_EXIT_PARKING_SYMBOL: ${{ vars.IBIT_ZSCORE_EXIT_PARKING_SYMBOL }} + IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }} + IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE }} + IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }} IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI }} IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }} IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }} diff --git a/runtime_config_support.py b/runtime_config_support.py index d0ab2e3..1706428 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -181,6 +181,12 @@ class PlatformRuntimeSettings: income_layer_max_ratio: float | None = None dca_mode: str | None = None dca_base_investment_usd: float | None = None + ibit_zscore_exit_enabled: bool | None = None + ibit_zscore_exit_mode: str | None = None + ibit_zscore_exit_parking_symbol: str | None = None + ibit_zscore_exit_risk_reduced_exposure: float | None = None + ibit_zscore_exit_risk_off_exposure: float | None = None + ibit_zscore_exit_allow_outside_execution_window: bool | None = None market_signal_handoff_index_uri: str | None = None market_signal_handoff_manifest_uri: str | None = None market_signal_consumption_audit_uri: str | None = None @@ -408,6 +414,18 @@ def load_platform_runtime_settings( income_layer_max_ratio=resolve_optional_ratio_env("INCOME_LAYER_MAX_RATIO"), dca_mode=resolve_optional_dca_mode_env("DCA_MODE"), dca_base_investment_usd=resolve_optional_positive_float_env("DCA_BASE_INVESTMENT_USD"), + ibit_zscore_exit_enabled=resolve_optional_bool_env("IBIT_ZSCORE_EXIT_ENABLED"), + ibit_zscore_exit_mode=resolve_optional_ibit_zscore_exit_mode_env("IBIT_ZSCORE_EXIT_MODE"), + ibit_zscore_exit_parking_symbol=resolve_optional_symbol_env("IBIT_ZSCORE_EXIT_PARKING_SYMBOL"), + ibit_zscore_exit_risk_reduced_exposure=resolve_optional_ratio_env( + "IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE" + ), + ibit_zscore_exit_risk_off_exposure=resolve_optional_ratio_env( + "IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE" + ), + ibit_zscore_exit_allow_outside_execution_window=resolve_optional_bool_env( + "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW" + ), market_signal_handoff_index_uri=first_non_empty( os.getenv("IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI"), os.getenv("MARKET_SIGNAL_HANDOFF_INDEX_URI"), @@ -611,6 +629,37 @@ def resolve_optional_dca_mode_env(name: str) -> str | None: return mode +def resolve_optional_ibit_zscore_exit_mode_env(name: str) -> str | None: + raw_value = os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return None + value = str(raw_value).strip().lower() + aliases = { + "off": "disabled", + "none": "disabled", + "false": "disabled", + "disable": "disabled", + "enabled": "live", + "shadow": "paper", + "dry_run": "paper", + "dry-run": "paper", + } + mode = aliases.get(value, value) + if mode not in {"disabled", "paper", "live"}: + raise ValueError(f"{name} must be disabled, paper, or live, got {raw_value!r}") + return mode + + +def resolve_optional_symbol_env(name: str) -> str | None: + raw_value = os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return None + value = str(raw_value).strip().upper() + if len(value) > 16 or not value.replace(".", "").replace("-", "").isalnum(): + raise ValueError(f"{name} must be a symbol") + return value + + def resolve_optional_bool_env(name: str) -> bool | None: raw_value = os.getenv(name) if raw_value is None or str(raw_value).strip() == "": diff --git a/scripts/build_cloud_run_env_sync_plan.py b/scripts/build_cloud_run_env_sync_plan.py index 287b760..2217025 100644 --- a/scripts/build_cloud_run_env_sync_plan.py +++ b/scripts/build_cloud_run_env_sync_plan.py @@ -106,6 +106,12 @@ def _should_add_local_src(candidate: Path) -> bool: "INCOME_LAYER_MAX_RATIO", "DCA_MODE", "DCA_BASE_INVESTMENT_USD", + "IBIT_ZSCORE_EXIT_ENABLED", + "IBIT_ZSCORE_EXIT_MODE", + "IBIT_ZSCORE_EXIT_PARKING_SYMBOL", + "IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE", + "IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE", + "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW", "IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI", "IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI", "IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI", diff --git a/scripts/print_strategy_switch_env_plan.py b/scripts/print_strategy_switch_env_plan.py index 53df8a6..eeb4dd6 100644 --- a/scripts/print_strategy_switch_env_plan.py +++ b/scripts/print_strategy_switch_env_plan.py @@ -154,6 +154,12 @@ def build_switch_plan( "INCOME_LAYER_MAX_RATIO", "DCA_MODE", "DCA_BASE_INVESTMENT_USD", + "IBIT_ZSCORE_EXIT_ENABLED", + "IBIT_ZSCORE_EXIT_MODE", + "IBIT_ZSCORE_EXIT_PARKING_SYMBOL", + "IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE", + "IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE", + "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW", "IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI", "IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI", "IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI", diff --git a/strategy_runtime.py b/strategy_runtime.py index 8152cb9..0e357bc 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -45,6 +45,7 @@ DEFAULT_REBALANCE_THRESHOLD_RATIO = 0.02 _FEATURE_SNAPSHOT_INPUT = "feature_snapshot" DCA_PROFILES = frozenset({"nasdaq_sp500_smart_dca", "ibit_smart_dca"}) +IBIT_ZSCORE_EXIT_PROFILE = "ibit_smart_dca" _MARKET_HISTORY_INPUT = "market_history" _BENCHMARK_HISTORY_INPUT = "benchmark_history" _DERIVED_INDICATORS_INPUT = "derived_indicators" @@ -954,6 +955,7 @@ def _build_runtime_overrides(runtime_settings: PlatformRuntimeSettings) -> dict[ if income_layer_max_ratio is not None: overrides["income_layer_max_ratio"] = income_layer_max_ratio _apply_dca_runtime_overrides(runtime_settings, overrides) + _apply_ibit_zscore_exit_runtime_overrides(runtime_settings, overrides) return overrides @@ -970,3 +972,25 @@ def _apply_dca_runtime_overrides( overrides["smart_multiplier_enabled"] = dca_mode == "smart" if dca_base_investment_usd is not None: overrides["base_investment_usd"] = dca_base_investment_usd + + +def _apply_ibit_zscore_exit_runtime_overrides( + runtime_settings: PlatformRuntimeSettings, + overrides: dict[str, Any], +) -> None: + if runtime_settings.strategy_profile != IBIT_ZSCORE_EXIT_PROFILE: + return + for setting_name, override_name in ( + ("ibit_zscore_exit_enabled", "ibit_zscore_exit_enabled"), + ("ibit_zscore_exit_mode", "ibit_zscore_exit_mode"), + ("ibit_zscore_exit_parking_symbol", "ibit_zscore_exit_parking_symbol"), + ("ibit_zscore_exit_risk_reduced_exposure", "ibit_zscore_exit_risk_reduced_exposure"), + ("ibit_zscore_exit_risk_off_exposure", "ibit_zscore_exit_risk_off_exposure"), + ( + "ibit_zscore_exit_allow_outside_execution_window", + "ibit_zscore_exit_allow_outside_execution_window", + ), + ): + value = getattr(runtime_settings, setting_name, None) + if value is not None: + overrides[override_name] = value diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index ceaf283..c8028c9 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -543,6 +543,27 @@ def test_load_platform_runtime_settings_reads_income_layer_overrides(monkeypatch assert settings.income_layer_max_ratio == 0.25 +def test_load_platform_runtime_settings_reads_ibit_zscore_exit_overrides(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", runtime_target_json("ibit_smart_dca")) + monkeypatch.setenv("ACCOUNT_GROUP", "paper") + monkeypatch.setenv("IB_ACCOUNT_GROUP_CONFIG_JSON", MINIMAL_GROUP_JSON) + monkeypatch.setenv("IBIT_ZSCORE_EXIT_ENABLED", "true") + monkeypatch.setenv("IBIT_ZSCORE_EXIT_MODE", "enabled") + monkeypatch.setenv("IBIT_ZSCORE_EXIT_PARKING_SYMBOL", "boxx") + monkeypatch.setenv("IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE", "0.5") + monkeypatch.setenv("IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE", "0.25") + monkeypatch.setenv("IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW", "true") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.ibit_zscore_exit_enabled is True + assert settings.ibit_zscore_exit_mode == "live" + assert settings.ibit_zscore_exit_parking_symbol == "BOXX" + assert settings.ibit_zscore_exit_risk_reduced_exposure == 0.5 + assert settings.ibit_zscore_exit_risk_off_exposure == 0.25 + assert settings.ibit_zscore_exit_allow_outside_execution_window is True + + def test_load_platform_runtime_settings_rejects_invalid_income_layer_enabled(monkeypatch): monkeypatch.setenv("INCOME_LAYER_ENABLED", "sometimes") diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 14453c6..16f93ee 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -35,6 +35,12 @@ def _build_runtime_settings( income_layer_max_ratio: float | None = None, dca_mode: str | None = None, dca_base_investment_usd: float | None = None, + ibit_zscore_exit_enabled: bool | None = None, + ibit_zscore_exit_mode: str | None = None, + ibit_zscore_exit_parking_symbol: str | None = None, + ibit_zscore_exit_risk_reduced_exposure: float | None = None, + ibit_zscore_exit_risk_off_exposure: float | None = None, + ibit_zscore_exit_allow_outside_execution_window: bool | None = None, reserved_cash_floor_usd: float = 0.0, reserved_cash_ratio: float | None = None, ) -> PlatformRuntimeSettings: @@ -65,6 +71,14 @@ def _build_runtime_settings( income_layer_max_ratio=income_layer_max_ratio, dca_mode=dca_mode, dca_base_investment_usd=dca_base_investment_usd, + ibit_zscore_exit_enabled=ibit_zscore_exit_enabled, + ibit_zscore_exit_mode=ibit_zscore_exit_mode, + ibit_zscore_exit_parking_symbol=ibit_zscore_exit_parking_symbol, + ibit_zscore_exit_risk_reduced_exposure=ibit_zscore_exit_risk_reduced_exposure, + ibit_zscore_exit_risk_off_exposure=ibit_zscore_exit_risk_off_exposure, + ibit_zscore_exit_allow_outside_execution_window=( + ibit_zscore_exit_allow_outside_execution_window + ), account_group="default", service_name=None, account_ids=(), @@ -323,6 +337,29 @@ def test_dca_overrides_apply_to_runtime_config(): } +def test_ibit_zscore_exit_overrides_apply_to_runtime_config(): + settings = _build_runtime_settings( + profile="ibit_smart_dca", + display_name="IBIT Bitcoin ETF DCA", + target_mode="value", + ibit_zscore_exit_enabled=True, + ibit_zscore_exit_mode="live", + ibit_zscore_exit_parking_symbol="BOXX", + ibit_zscore_exit_risk_reduced_exposure=0.5, + ibit_zscore_exit_risk_off_exposure=0.25, + ibit_zscore_exit_allow_outside_execution_window=True, + ) + + assert strategy_runtime_module._build_runtime_overrides(settings) == { + "ibit_zscore_exit_enabled": True, + "ibit_zscore_exit_mode": "live", + "ibit_zscore_exit_parking_symbol": "BOXX", + "ibit_zscore_exit_risk_reduced_exposure": 0.5, + "ibit_zscore_exit_risk_off_exposure": 0.25, + "ibit_zscore_exit_allow_outside_execution_window": True, + } + + def test_reserved_cash_policy_overrides_apply_to_runtime_config(): settings = _build_runtime_settings( profile="soxl_soxx_trend_income", diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 32087e4..4479426 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -45,6 +45,12 @@ grep -Fq 'INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }}' "$workflow grep -Fq 'INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}' "$workflow_file" grep -Fq 'DCA_MODE: ${{ vars.DCA_MODE }}' "$workflow_file" grep -Fq 'DCA_BASE_INVESTMENT_USD: ${{ vars.DCA_BASE_INVESTMENT_USD }}' "$workflow_file" +grep -Fq 'IBIT_ZSCORE_EXIT_ENABLED: ${{ vars.IBIT_ZSCORE_EXIT_ENABLED }}' "$workflow_file" +grep -Fq 'IBIT_ZSCORE_EXIT_MODE: ${{ vars.IBIT_ZSCORE_EXIT_MODE }}' "$workflow_file" +grep -Fq 'IBIT_ZSCORE_EXIT_PARKING_SYMBOL: ${{ vars.IBIT_ZSCORE_EXIT_PARKING_SYMBOL }}' "$workflow_file" +grep -Fq 'IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE }}' "$workflow_file" +grep -Fq 'IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE: ${{ vars.IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE }}' "$workflow_file" +grep -Fq 'IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW: ${{ vars.IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW }}' "$workflow_file" grep -Fq 'IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_INDEX_URI }}' "$workflow_file" grep -Fq 'IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI: ${{ vars.IBKR_MARKET_SIGNAL_HANDOFF_MANIFEST_URI }}' "$workflow_file" grep -Fq 'IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI: ${{ vars.IBKR_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI }}' "$workflow_file" From e6145c4b7dd6a3c8c497c6c44546bdcf916a114e Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:22:48 +0800 Subject: [PATCH 2/2] Pin strategies for IBIT zscore runtime support --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 81cc5e3..8ef1dcd 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ flask gunicorn quant-platform-kit @ git+https://github.com/QuantStrategyLab/QuantPlatformKit.git@46ca4ea3de8f98a58e2dd86158e7f2070d085cd1 -us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@173b8abbabc6a281b4707e100d248eae99e1f9d5 +us-equity-strategies @ git+https://github.com/QuantStrategyLab/UsEquityStrategies.git@ced1f78827e6112292af24d32dfe0e0f009e2833 hk-equity-strategies @ git+https://github.com/QuantStrategyLab/HkEquityStrategies.git@ec54c685b7dbea931016854db081b8eeaaaef7d2 pandas numpy