From 82a2fd9afcc81c39b70b76f4d47022c3956812ce Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:53:03 +0800 Subject: [PATCH 1/2] Support income layer runtime controls --- .github/workflows/sync-cloud-run-env.yml | 6 ++++ main.py | 2 ++ runtime_config_support.py | 34 ++++++++++++++++++++++ strategy_runtime.py | 4 +++ tests/test_runtime_config_support.py | 37 ++++++++++++++++++++++++ tests/test_strategy_runtime.py | 12 ++++++++ 6 files changed, 95 insertions(+) diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index b5cf1e2..fd5f8b4 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -102,6 +102,9 @@ jobs: FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }} INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} + INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }} + INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }} + 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 }} NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} @@ -557,6 +560,9 @@ jobs: add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS add_optional_env INCOME_THRESHOLD_USD add_optional_env QQQI_INCOME_RATIO + add_optional_env INCOME_LAYER_ENABLED + add_optional_env INCOME_LAYER_MAX_RATIO + add_optional_env RUNTIME_TARGET_ENABLED add_optional_env EXECUTION_REPORT_GCS_URI add_optional_env GLOBAL_TELEGRAM_CHAT_ID add_optional_env NOTIFY_LANG diff --git a/main.py b/main.py index 6cf3a5c..3118031 100644 --- a/main.py +++ b/main.py @@ -406,6 +406,8 @@ def run_strategy(): ), 403, ) + if not _runtime_settings().runtime_target_enabled: + return jsonify({"ok": True, "status": "skipped", "skip_reason": "runtime_target_disabled"}), 200 try: return jsonify(_run_strategy_cycle_with_report()) except (FirstradePlatformError, EnvironmentError, ValueError) as exc: diff --git a/runtime_config_support.py b/runtime_config_support.py index 0dfe5f8..08e852f 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -45,6 +45,7 @@ class PlatformRuntimeSettings: run_strategy_on_http: bool live_order_ack: bool max_order_notional_usd: float | None + runtime_target_enabled: bool = True reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO persist_strategy_runs: bool = False @@ -52,6 +53,8 @@ class PlatformRuntimeSettings: debug_position_snapshot: bool = False income_threshold_usd: float | None = None qqqi_income_ratio: float | None = None + income_layer_enabled: bool | None = None + income_layer_max_ratio: float | None = None runtime_execution_window_trading_days: int | None = None feature_snapshot_path: str | None = None feature_snapshot_manifest_path: str | None = None @@ -140,6 +143,7 @@ def load_platform_runtime_settings( tg_token=os.getenv("TELEGRAM_TOKEN"), tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"), dry_run_only=dry_run_only, + runtime_target_enabled=_runtime_target_enabled_env(), live_trading_enabled=resolve_bool_value(os.getenv("FIRSTRADE_ENABLE_LIVE_TRADING")), run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")), live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")), @@ -164,6 +168,8 @@ def load_platform_runtime_settings( debug_position_snapshot=resolve_bool_value(os.getenv("FIRSTRADE_DEBUG_POSITION_SNAPSHOT")), income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"), qqqi_income_ratio=_qqqi_income_ratio_env(), + income_layer_enabled=_optional_bool_env("INCOME_LAYER_ENABLED"), + income_layer_max_ratio=_optional_ratio_env("INCOME_LAYER_MAX_RATIO"), runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env( strategy_definition.profile ), @@ -267,6 +273,34 @@ def _qqqi_income_ratio_env() -> float | None: return value +def _optional_bool_env(name: str) -> bool | None: + raw_value = os.getenv(name) + if raw_value is None or str(raw_value).strip() == "": + return None + value = str(raw_value).strip().lower() + if value in {"1", "true", "yes", "y", "on"}: + return True + if value in {"0", "false", "no", "n", "off"}: + return False + raise ValueError(f"{name} must be boolean, got {raw_value!r}") + + +def _runtime_target_enabled_env() -> bool: + value = _optional_bool_env("RUNTIME_TARGET_ENABLED") + return True if value is None else value + + +def _optional_ratio_env(name: str) -> float | None: + value = resolve_optional_float_env(os.environ, name) + if value is None: + return None + if not math.isfinite(value): + raise ValueError(f"{name} must be finite, got {value}") + if not (0.0 <= value <= 1.0): + raise ValueError(f"{name} must be in [0,1], got {value}") + 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 ddad952..4937477 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -143,6 +143,10 @@ def load_runtime_parameters(self) -> dict[str, Any]: def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSettings) -> dict[str, Any]: overrides: dict[str, Any] = {} + if runtime_settings.income_layer_enabled is not None: + overrides["income_layer_enabled"] = runtime_settings.income_layer_enabled + if runtime_settings.income_layer_max_ratio is not None: + overrides["income_layer_max_ratio"] = runtime_settings.income_layer_max_ratio if profile == "tqqq_growth_income": if runtime_settings.income_threshold_usd is not None: overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index dc8508b..c8ad8a7 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -53,6 +53,7 @@ def test_reserved_cash_policy_defaults_to_zero(monkeypatch): assert settings.reserved_cash_floor_usd == 0.0 assert settings.reserved_cash_ratio == 0.0 + assert settings.runtime_target_enabled is True assert settings.strategy_plugin_alert_channels == () assert settings.strategy_plugin_alert_email_recipients == () assert settings.strategy_plugin_alert_email_sender_email is None @@ -96,6 +97,42 @@ def test_reserved_cash_policy_loads_from_env(monkeypatch): assert settings.reserved_cash_ratio == 0.025 +def test_income_layer_overrides_load_from_env(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income")) + monkeypatch.setenv("INCOME_LAYER_ENABLED", "false") + monkeypatch.setenv("INCOME_LAYER_MAX_RATIO", "0.25") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.income_layer_enabled is False + assert settings.income_layer_max_ratio == 0.25 + + +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") + + with pytest.raises(ValueError, match="INCOME_LAYER_MAX_RATIO"): + load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + +def test_runtime_target_enabled_loads_from_env(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json()) + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false") + + settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + assert settings.runtime_target_enabled is False + + +def test_invalid_runtime_target_enabled_is_rejected(monkeypatch): + monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json()) + monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "maybe") + + with pytest.raises(ValueError, match="RUNTIME_TARGET_ENABLED"): + load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + + def test_strategy_plugin_alert_email_settings_load_from_env(monkeypatch): monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json()) monkeypatch.setenv("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS", "alerts@example.com; voice@example.com") diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index ad8d946..bdf05bd 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -47,3 +47,15 @@ def test_runtime_execution_window_override_ignores_other_profiles(): settings = _runtime_settings(runtime_execution_window_trading_days=7) assert _build_runtime_overrides("global_etf_rotation", settings) == {} + + +def test_income_layer_overrides_apply_to_runtime_config(): + settings = _runtime_settings( + income_layer_enabled=False, + income_layer_max_ratio=0.25, + ) + + assert _build_runtime_overrides("global_etf_rotation", settings) == { + "income_layer_enabled": False, + "income_layer_max_ratio": 0.25, + } From 14cf309db213a5844fe87f59d00067b8eabb70f6 Mon Sep 17 00:00:00 2001 From: Pigbibi <20649888+Pigbibi@users.noreply.github.com> Date: Thu, 18 Jun 2026 22:58:24 +0800 Subject: [PATCH 2/2] Keep runtime controls compatible with request tests --- main.py | 8 ++++++-- strategy_runtime.py | 10 ++++++---- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/main.py b/main.py index 3118031..bd6a053 100644 --- a/main.py +++ b/main.py @@ -26,7 +26,11 @@ finalize_runtime_report, persist_runtime_report, ) -from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings +from runtime_config_support import ( + PlatformRuntimeSettings, + _runtime_target_enabled_env, + load_platform_runtime_settings, +) from strategy_registry import get_platform_profile_status_matrix app = Flask(__name__) @@ -406,7 +410,7 @@ def run_strategy(): ), 403, ) - if not _runtime_settings().runtime_target_enabled: + if not _runtime_target_enabled_env(): return jsonify({"ok": True, "status": "skipped", "skip_reason": "runtime_target_disabled"}), 200 try: return jsonify(_run_strategy_cycle_with_report()) diff --git a/strategy_runtime.py b/strategy_runtime.py index 4937477..69749a2 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -143,10 +143,12 @@ def load_runtime_parameters(self) -> dict[str, Any]: def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSettings) -> dict[str, Any]: overrides: dict[str, Any] = {} - if runtime_settings.income_layer_enabled is not None: - overrides["income_layer_enabled"] = runtime_settings.income_layer_enabled - if runtime_settings.income_layer_max_ratio is not None: - overrides["income_layer_max_ratio"] = runtime_settings.income_layer_max_ratio + income_layer_enabled = getattr(runtime_settings, "income_layer_enabled", None) + income_layer_max_ratio = getattr(runtime_settings, "income_layer_max_ratio", None) + if income_layer_enabled is not None: + overrides["income_layer_enabled"] = income_layer_enabled + if income_layer_max_ratio is not None: + overrides["income_layer_max_ratio"] = income_layer_max_ratio if profile == "tqqq_growth_income": if runtime_settings.income_threshold_usd is not None: overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd