From 38ecc9f1f740b13f129743988132c668b9f0fa94 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 --- .env.example | 8 ++++ .github/workflows/sync-cloud-run-env.yml | 16 ++++++++ runtime_config_support.py | 47 +++++++++++++++++++++++ strategy_runtime.py | 25 ++++++++++++ tests/test_runtime_config_support.py | 19 +++++++++ tests/test_strategy_runtime.py | 21 ++++++++++ tests/test_sync_cloud_run_env_workflow.py | 8 ++++ 7 files changed, 144 insertions(+) diff --git a/.env.example b/.env.example index d3b0706..8906540 100644 --- a/.env.example +++ b/.env.example @@ -23,6 +23,14 @@ NOTIFY_LANG=en TELEGRAM_TOKEN= GLOBAL_TELEGRAM_CHAT_ID= FIRSTRADE_STRATEGY_PLUGIN_MOUNTS_JSON= +DCA_MODE= +DCA_BASE_INVESTMENT_USD= +IBIT_ZSCORE_EXIT_ENABLED= +IBIT_ZSCORE_EXIT_MODE= +IBIT_ZSCORE_EXIT_PARKING_SYMBOL=BOXX +IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE= +IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE= +IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW= # Optional email channel for escalated strategy plugin alerts. STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS= diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index 18fc8af..b22a13b 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -120,6 +120,14 @@ jobs: INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }} 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 }} RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }} GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }} @@ -589,6 +597,14 @@ jobs: add_optional_env INCOME_LAYER_ENABLED add_optional_env INCOME_LAYER_START_USD add_optional_env INCOME_LAYER_MAX_RATIO + add_optional_env DCA_MODE + add_optional_env DCA_BASE_INVESTMENT_USD + add_optional_env IBIT_ZSCORE_EXIT_ENABLED + add_optional_env IBIT_ZSCORE_EXIT_MODE + add_optional_env IBIT_ZSCORE_EXIT_PARKING_SYMBOL + add_optional_env IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE + add_optional_env IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE + add_optional_env IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW add_optional_env RUNTIME_TARGET_ENABLED add_optional_env EXECUTION_REPORT_GCS_URI add_optional_env GLOBAL_TELEGRAM_CHAT_ID diff --git a/runtime_config_support.py b/runtime_config_support.py index 1e81cfd..b34e7f2 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -58,6 +58,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 runtime_execution_window_trading_days: int | None = None market_signal_handoff_index_uri: str | None = None market_signal_handoff_manifest_uri: str | None = None @@ -186,6 +192,16 @@ def load_platform_runtime_settings( income_layer_max_ratio=_optional_ratio_env("INCOME_LAYER_MAX_RATIO"), dca_mode=_optional_dca_mode_env("DCA_MODE"), dca_base_investment_usd=_optional_positive_float_env("DCA_BASE_INVESTMENT_USD"), + ibit_zscore_exit_enabled=_optional_bool_env("IBIT_ZSCORE_EXIT_ENABLED"), + ibit_zscore_exit_mode=_optional_ibit_zscore_exit_mode_env("IBIT_ZSCORE_EXIT_MODE"), + ibit_zscore_exit_parking_symbol=_optional_symbol_env("IBIT_ZSCORE_EXIT_PARKING_SYMBOL"), + ibit_zscore_exit_risk_reduced_exposure=_optional_ratio_env( + "IBIT_ZSCORE_EXIT_RISK_REDUCED_EXPOSURE" + ), + ibit_zscore_exit_risk_off_exposure=_optional_ratio_env("IBIT_ZSCORE_EXIT_RISK_OFF_EXPOSURE"), + ibit_zscore_exit_allow_outside_execution_window=_optional_bool_env( + "IBIT_ZSCORE_EXIT_ALLOW_OUTSIDE_EXECUTION_WINDOW" + ), runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env( strategy_definition.profile ), @@ -407,6 +423,37 @@ def _optional_dca_mode_env(name: str) -> str | None: return mode +def _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 _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_non_negative_float_env(name: str, *, default: float) -> float: value = resolve_optional_float_env(os.environ, name) if value is None: diff --git a/strategy_runtime.py b/strategy_runtime.py index 6d45396..fdf4532 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -26,6 +26,7 @@ _FEATURE_SNAPSHOT_INPUT = "feature_snapshot" DCA_PROFILES = frozenset({"nasdaq_sp500_smart_dca", "ibit_smart_dca"}) +IBIT_ZSCORE_EXIT_PROFILE = "ibit_smart_dca" @dataclass(frozen=True) @@ -183,6 +184,7 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett overrides["smart_multiplier_enabled"] = dca_mode == "smart" if dca_base_investment_usd is not None: overrides["base_investment_usd"] = dca_base_investment_usd + _apply_ibit_zscore_exit_runtime_overrides(profile, runtime_settings, overrides) if profile == "tqqq_growth_income": if runtime_settings.income_threshold_usd is not None: overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd @@ -199,6 +201,29 @@ def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSett return overrides +def _apply_ibit_zscore_exit_runtime_overrides( + profile: str, + runtime_settings: PlatformRuntimeSettings, + overrides: dict[str, Any], +) -> None: + if 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 + + def load_strategy_runtime( raw_profile: str | None, *, diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index db582ea..bbaf1ec 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -110,6 +110,25 @@ def test_income_layer_overrides_load_from_env(monkeypatch): assert settings.income_layer_max_ratio == 0.25 +def test_ibit_zscore_exit_overrides_load_from_env(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("ibit_smart_dca")) + 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_invalid_income_layer_max_ratio_is_rejected(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income")) monkeypatch.setenv("INCOME_LAYER_MAX_RATIO", "1.5") diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index ccff68a..e165085 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -77,6 +77,27 @@ def test_dca_overrides_apply_to_runtime_config(): } +def test_ibit_zscore_exit_overrides_apply_to_runtime_config(): + settings = _runtime_settings( + strategy_profile="ibit_smart_dca", + 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 _build_runtime_overrides("ibit_smart_dca", 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 = _runtime_settings( strategy_profile="soxl_soxx_trend_income", diff --git a/tests/test_sync_cloud_run_env_workflow.py b/tests/test_sync_cloud_run_env_workflow.py index d73e834..973ee64 100644 --- a/tests/test_sync_cloud_run_env_workflow.py +++ b/tests/test_sync_cloud_run_env_workflow.py @@ -49,6 +49,14 @@ def test_sync_cloud_run_env_workflow_syncs_strategy_plugin_alert_settings(): "INCOME_LAYER_ENABLED", "INCOME_LAYER_START_USD", "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", "FIRSTRADE_MARKET_SIGNAL_HANDOFF_INDEX_URI", "FIRSTRADE_MARKET_SIGNAL_HANDOFF_MANIFEST_URI", "FIRSTRADE_MARKET_SIGNAL_CONSUMPTION_AUDIT_URI", From ed08611443f21839c4f2d4c2bc0dac16b6ffacc9 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Sat, 20 Jun 2026 05:22:55 +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 68fc5d2..40c1dd9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,7 +2,7 @@ flask gunicorn firstrade==0.0.39 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 google-cloud-storage google-auth requests