diff --git a/.github/workflows/sync-cloud-run-env.yml b/.github/workflows/sync-cloud-run-env.yml index e75d467..eb51220 100644 --- a/.github/workflows/sync-cloud-run-env.yml +++ b/.github/workflows/sync-cloud-run-env.yml @@ -157,6 +157,7 @@ jobs: INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }} QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }} 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 }} RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }} NOTIFY_LANG: ${{ vars.NOTIFY_LANG }} @@ -942,6 +943,12 @@ jobs: remove_env_vars+=("INCOME_LAYER_ENABLED") fi + if [ -n "${INCOME_LAYER_START_USD:-}" ]; then + env_pairs+=("INCOME_LAYER_START_USD=${INCOME_LAYER_START_USD}") + else + remove_env_vars+=("INCOME_LAYER_START_USD") + fi + if [ -n "${INCOME_LAYER_MAX_RATIO:-}" ]; then env_pairs+=("INCOME_LAYER_MAX_RATIO=${INCOME_LAYER_MAX_RATIO}") else diff --git a/runtime_config_support.py b/runtime_config_support.py index c77927b..67e165a 100644 --- a/runtime_config_support.py +++ b/runtime_config_support.py @@ -100,6 +100,7 @@ class PlatformRuntimeSettings: income_threshold_usd: float | None = None qqqi_income_ratio: float | None = None income_layer_enabled: bool | None = None + income_layer_start_usd: float | None = None income_layer_max_ratio: float | None = None runtime_execution_window_trading_days: int | None = None feature_snapshot_path: str | None = None @@ -288,6 +289,7 @@ def load_platform_runtime_settings( 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_start_usd=_optional_non_negative_float_env("INCOME_LAYER_START_USD"), 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 @@ -400,6 +402,17 @@ def _optional_ratio_env(name: str) -> float | None: return value +def _optional_non_negative_float_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 value < 0: + raise ValueError(f"{name} must be non-negative, got {value}") + return float(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 39bb2ab..1065efa 100644 --- a/strategy_runtime.py +++ b/strategy_runtime.py @@ -167,9 +167,12 @@ def _default_runtime_settings(profile: str, display_name: str) -> PlatformRuntim def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSettings) -> dict[str, Any]: overrides: dict[str, Any] = {} income_layer_enabled = getattr(runtime_settings, "income_layer_enabled", None) + income_layer_start_usd = getattr(runtime_settings, "income_layer_start_usd", 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_start_usd is not None: + overrides["income_layer_start_usd"] = income_layer_start_usd if income_layer_max_ratio is not None: overrides["income_layer_max_ratio"] = income_layer_max_ratio if profile == "tqqq_growth_income": diff --git a/tests/test_runtime_config_support.py b/tests/test_runtime_config_support.py index 3874fae..7f61f8b 100644 --- a/tests/test_runtime_config_support.py +++ b/tests/test_runtime_config_support.py @@ -482,6 +482,7 @@ def test_income_layer_overrides_are_loaded_from_env(self): "INCOME_THRESHOLD_USD": "100000", "QQQI_INCOME_RATIO": "0.5", "INCOME_LAYER_ENABLED": "false", + "INCOME_LAYER_START_USD": "250000", "INCOME_LAYER_MAX_RATIO": "0.25", }, clear=True, @@ -492,6 +493,7 @@ def test_income_layer_overrides_are_loaded_from_env(self): self.assertEqual(settings.income_threshold_usd, 100000.0) self.assertEqual(settings.qqqi_income_ratio, 0.5) self.assertFalse(settings.income_layer_enabled) + self.assertEqual(settings.income_layer_start_usd, 250000.0) self.assertEqual(settings.income_layer_max_ratio, 0.25) def test_tech_runtime_execution_window_override_rejects_research_only_profile(self): @@ -532,6 +534,18 @@ def test_rejects_invalid_income_layer_max_ratio(self): with self.assertRaisesRegex(ValueError, "INCOME_LAYER_MAX_RATIO"): load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + def test_rejects_invalid_income_layer_start_usd(self): + with patch.dict( + os.environ, + { + "RUNTIME_TARGET_JSON": runtime_target_json("tqqq_growth_income"), + "INCOME_LAYER_START_USD": "-1", + }, + clear=True, + ): + with self.assertRaisesRegex(ValueError, "INCOME_LAYER_START_USD"): + load_platform_runtime_settings(project_id_resolver=lambda: "project-1") + def test_rejects_human_readable_alias(self): with patch.dict( os.environ, diff --git a/tests/test_strategy_runtime.py b/tests/test_strategy_runtime.py index 77e892f..9c64c44 100644 --- a/tests/test_strategy_runtime.py +++ b/tests/test_strategy_runtime.py @@ -101,6 +101,7 @@ def _build_runtime_settings( income_threshold_usd: float | None = None, qqqi_income_ratio: float | None = None, income_layer_enabled: bool | None = None, + income_layer_start_usd: float | None = None, income_layer_max_ratio: float | None = None, runtime_execution_window_trading_days: int | None = None, ) -> PlatformRuntimeSettings: @@ -121,6 +122,7 @@ def _build_runtime_settings( income_threshold_usd=income_threshold_usd, qqqi_income_ratio=qqqi_income_ratio, income_layer_enabled=income_layer_enabled, + income_layer_start_usd=income_layer_start_usd, income_layer_max_ratio=income_layer_max_ratio, runtime_execution_window_trading_days=runtime_execution_window_trading_days, feature_snapshot_path=feature_snapshot_path, @@ -262,6 +264,7 @@ def test_load_strategy_runtime_applies_tqqq_income_overrides_from_settings(self) income_threshold_usd=100000.0, qqqi_income_ratio=0.5, income_layer_enabled=False, + income_layer_start_usd=250000.0, income_layer_max_ratio=0.25, ), ) @@ -269,10 +272,12 @@ def test_load_strategy_runtime_applies_tqqq_income_overrides_from_settings(self) self.assertEqual(runtime.runtime_overrides["income_threshold_usd"], 100000.0) self.assertEqual(runtime.runtime_overrides["qqqi_income_ratio"], 0.5) self.assertFalse(runtime.runtime_overrides["income_layer_enabled"]) + self.assertEqual(runtime.runtime_overrides["income_layer_start_usd"], 250000.0) self.assertEqual(runtime.runtime_overrides["income_layer_max_ratio"], 0.25) self.assertEqual(runtime.merged_runtime_config["income_threshold_usd"], 100000.0) self.assertEqual(runtime.merged_runtime_config["qqqi_income_ratio"], 0.5) self.assertFalse(runtime.merged_runtime_config["income_layer_enabled"]) + self.assertEqual(runtime.merged_runtime_config["income_layer_start_usd"], 250000.0) self.assertEqual(runtime.merged_runtime_config["income_layer_max_ratio"], 0.25) def test_load_strategy_runtime_applies_tech_execution_window_overrides_from_settings(self): diff --git a/tests/test_sync_cloud_run_env_workflow.sh b/tests/test_sync_cloud_run_env_workflow.sh index 3c79f67..9e050fe 100644 --- a/tests/test_sync_cloud_run_env_workflow.sh +++ b/tests/test_sync_cloud_run_env_workflow.sh @@ -96,6 +96,7 @@ grep -Fq 'STRATEGY_PLUGIN_ALERT_TELEGRAM_CHAT_IDS: ${{ vars.STRATEGY_PLUGIN_ALER grep -Fq 'STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME: ${{ vars.STRATEGY_PLUGIN_ALERT_TELEGRAM_BOT_TOKEN_SECRET_NAME }}' "$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 'INCOME_LAYER_START_USD: ${{ vars.INCOME_LAYER_START_USD }}' "$workflow_file" grep -Fq 'LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}' "$workflow_file" grep -Fq 'RUNTIME_TARGET_JSON: ${{ vars.RUNTIME_TARGET_JSON }}' "$workflow_file" grep -Fq 'ACCOUNT_REGION: ${{ vars.ACCOUNT_REGION || matrix.target.default_account_region }}' "$workflow_file"