Skip to content

Commit 528b530

Browse files
authored
Support income layer runtime controls (#100)
* Support income layer runtime controls * Keep runtime controls compatible with request tests
1 parent 505d296 commit 528b530

6 files changed

Lines changed: 102 additions & 1 deletion

File tree

.github/workflows/sync-cloud-run-env.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ jobs:
102102
FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS: ${{ vars.FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS }}
103103
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
104104
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
105+
INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }}
106+
INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}
107+
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
105108
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
106109
GLOBAL_TELEGRAM_CHAT_ID: ${{ vars.GLOBAL_TELEGRAM_CHAT_ID }}
107110
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
@@ -557,6 +560,9 @@ jobs:
557560
add_optional_env FIRSTRADE_TECH_RUNTIME_EXECUTION_WINDOW_TRADING_DAYS
558561
add_optional_env INCOME_THRESHOLD_USD
559562
add_optional_env QQQI_INCOME_RATIO
563+
add_optional_env INCOME_LAYER_ENABLED
564+
add_optional_env INCOME_LAYER_MAX_RATIO
565+
add_optional_env RUNTIME_TARGET_ENABLED
560566
add_optional_env EXECUTION_REPORT_GCS_URI
561567
add_optional_env GLOBAL_TELEGRAM_CHAT_ID
562568
add_optional_env NOTIFY_LANG

main.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@
2626
finalize_runtime_report,
2727
persist_runtime_report,
2828
)
29-
from runtime_config_support import PlatformRuntimeSettings, load_platform_runtime_settings
29+
from runtime_config_support import (
30+
PlatformRuntimeSettings,
31+
_runtime_target_enabled_env,
32+
load_platform_runtime_settings,
33+
)
3034
from strategy_registry import get_platform_profile_status_matrix
3135

3236
app = Flask(__name__)
@@ -406,6 +410,8 @@ def run_strategy():
406410
),
407411
403,
408412
)
413+
if not _runtime_target_enabled_env():
414+
return jsonify({"ok": True, "status": "skipped", "skip_reason": "runtime_target_disabled"}), 200
409415
try:
410416
return jsonify(_run_strategy_cycle_with_report())
411417
except (FirstradePlatformError, EnvironmentError, ValueError) as exc:

runtime_config_support.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,16 @@ class PlatformRuntimeSettings:
4545
run_strategy_on_http: bool
4646
live_order_ack: bool
4747
max_order_notional_usd: float | None
48+
runtime_target_enabled: bool = True
4849
reserved_cash_floor_usd: float = DEFAULT_RESERVED_CASH_FLOOR_USD
4950
reserved_cash_ratio: float = DEFAULT_RESERVED_CASH_RATIO
5051
persist_strategy_runs: bool = False
5152
safe_haven_cash_substitute_threshold_usd: float = DEFAULT_SAFE_HAVEN_CASH_SUBSTITUTE_THRESHOLD_USD
5253
debug_position_snapshot: bool = False
5354
income_threshold_usd: float | None = None
5455
qqqi_income_ratio: float | None = None
56+
income_layer_enabled: bool | None = None
57+
income_layer_max_ratio: float | None = None
5558
runtime_execution_window_trading_days: int | None = None
5659
feature_snapshot_path: str | None = None
5760
feature_snapshot_manifest_path: str | None = None
@@ -140,6 +143,7 @@ def load_platform_runtime_settings(
140143
tg_token=os.getenv("TELEGRAM_TOKEN"),
141144
tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"),
142145
dry_run_only=dry_run_only,
146+
runtime_target_enabled=_runtime_target_enabled_env(),
143147
live_trading_enabled=resolve_bool_value(os.getenv("FIRSTRADE_ENABLE_LIVE_TRADING")),
144148
run_strategy_on_http=resolve_bool_value(os.getenv("FIRSTRADE_RUN_STRATEGY_ON_HTTP")),
145149
live_order_ack=resolve_bool_value(os.getenv("FIRSTRADE_LIVE_ORDER_ACK")),
@@ -164,6 +168,8 @@ def load_platform_runtime_settings(
164168
debug_position_snapshot=resolve_bool_value(os.getenv("FIRSTRADE_DEBUG_POSITION_SNAPSHOT")),
165169
income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"),
166170
qqqi_income_ratio=_qqqi_income_ratio_env(),
171+
income_layer_enabled=_optional_bool_env("INCOME_LAYER_ENABLED"),
172+
income_layer_max_ratio=_optional_ratio_env("INCOME_LAYER_MAX_RATIO"),
167173
runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env(
168174
strategy_definition.profile
169175
),
@@ -267,6 +273,34 @@ def _qqqi_income_ratio_env() -> float | None:
267273
return value
268274

269275

276+
def _optional_bool_env(name: str) -> bool | None:
277+
raw_value = os.getenv(name)
278+
if raw_value is None or str(raw_value).strip() == "":
279+
return None
280+
value = str(raw_value).strip().lower()
281+
if value in {"1", "true", "yes", "y", "on"}:
282+
return True
283+
if value in {"0", "false", "no", "n", "off"}:
284+
return False
285+
raise ValueError(f"{name} must be boolean, got {raw_value!r}")
286+
287+
288+
def _runtime_target_enabled_env() -> bool:
289+
value = _optional_bool_env("RUNTIME_TARGET_ENABLED")
290+
return True if value is None else value
291+
292+
293+
def _optional_ratio_env(name: str) -> float | None:
294+
value = resolve_optional_float_env(os.environ, name)
295+
if value is None:
296+
return None
297+
if not math.isfinite(value):
298+
raise ValueError(f"{name} must be finite, got {value}")
299+
if not (0.0 <= value <= 1.0):
300+
raise ValueError(f"{name} must be in [0,1], got {value}")
301+
return value
302+
303+
270304
def _resolve_non_negative_float_env(name: str, *, default: float) -> float:
271305
value = resolve_optional_float_env(os.environ, name)
272306
if value is None:

strategy_runtime.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,12 @@ def load_runtime_parameters(self) -> dict[str, Any]:
143143

144144
def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSettings) -> dict[str, Any]:
145145
overrides: dict[str, Any] = {}
146+
income_layer_enabled = getattr(runtime_settings, "income_layer_enabled", None)
147+
income_layer_max_ratio = getattr(runtime_settings, "income_layer_max_ratio", None)
148+
if income_layer_enabled is not None:
149+
overrides["income_layer_enabled"] = income_layer_enabled
150+
if income_layer_max_ratio is not None:
151+
overrides["income_layer_max_ratio"] = income_layer_max_ratio
146152
if profile == "tqqq_growth_income":
147153
if runtime_settings.income_threshold_usd is not None:
148154
overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd

tests/test_runtime_config_support.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ def test_reserved_cash_policy_defaults_to_zero(monkeypatch):
5353

5454
assert settings.reserved_cash_floor_usd == 0.0
5555
assert settings.reserved_cash_ratio == 0.0
56+
assert settings.runtime_target_enabled is True
5657
assert settings.strategy_plugin_alert_channels == ()
5758
assert settings.strategy_plugin_alert_email_recipients == ()
5859
assert settings.strategy_plugin_alert_email_sender_email is None
@@ -96,6 +97,42 @@ def test_reserved_cash_policy_loads_from_env(monkeypatch):
9697
assert settings.reserved_cash_ratio == 0.025
9798

9899

100+
def test_income_layer_overrides_load_from_env(monkeypatch):
101+
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))
102+
monkeypatch.setenv("INCOME_LAYER_ENABLED", "false")
103+
monkeypatch.setenv("INCOME_LAYER_MAX_RATIO", "0.25")
104+
105+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
106+
107+
assert settings.income_layer_enabled is False
108+
assert settings.income_layer_max_ratio == 0.25
109+
110+
111+
def test_invalid_income_layer_max_ratio_is_rejected(monkeypatch):
112+
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json("tqqq_growth_income"))
113+
monkeypatch.setenv("INCOME_LAYER_MAX_RATIO", "1.5")
114+
115+
with pytest.raises(ValueError, match="INCOME_LAYER_MAX_RATIO"):
116+
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
117+
118+
119+
def test_runtime_target_enabled_loads_from_env(monkeypatch):
120+
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
121+
monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "false")
122+
123+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
124+
125+
assert settings.runtime_target_enabled is False
126+
127+
128+
def test_invalid_runtime_target_enabled_is_rejected(monkeypatch):
129+
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
130+
monkeypatch.setenv("RUNTIME_TARGET_ENABLED", "maybe")
131+
132+
with pytest.raises(ValueError, match="RUNTIME_TARGET_ENABLED"):
133+
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
134+
135+
99136
def test_strategy_plugin_alert_email_settings_load_from_env(monkeypatch):
100137
monkeypatch.setenv("RUNTIME_TARGET_JSON", _target_json())
101138
monkeypatch.setenv("STRATEGY_PLUGIN_ALERT_EMAIL_RECIPIENTS", "alerts@example.com; voice@example.com")

tests/test_strategy_runtime.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,3 +47,15 @@ def test_runtime_execution_window_override_ignores_other_profiles():
4747
settings = _runtime_settings(runtime_execution_window_trading_days=7)
4848

4949
assert _build_runtime_overrides("global_etf_rotation", settings) == {}
50+
51+
52+
def test_income_layer_overrides_apply_to_runtime_config():
53+
settings = _runtime_settings(
54+
income_layer_enabled=False,
55+
income_layer_max_ratio=0.25,
56+
)
57+
58+
assert _build_runtime_overrides("global_etf_rotation", settings) == {
59+
"income_layer_enabled": False,
60+
"income_layer_max_ratio": 0.25,
61+
}

0 commit comments

Comments
 (0)