Skip to content

Commit 57a66c4

Browse files
authored
Support income layer runtime controls (#176)
* Support income layer runtime controls * Keep runtime controls compatible with test stubs
1 parent 7710c30 commit 57a66c4

6 files changed

Lines changed: 116 additions & 0 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,9 @@ jobs:
156156
# Optional strategy overrides; leave unset to inherit the UsEquityStrategies profile defaults.
157157
INCOME_THRESHOLD_USD: ${{ vars.INCOME_THRESHOLD_USD }}
158158
QQQI_INCOME_RATIO: ${{ vars.QQQI_INCOME_RATIO }}
159+
INCOME_LAYER_ENABLED: ${{ vars.INCOME_LAYER_ENABLED }}
160+
INCOME_LAYER_MAX_RATIO: ${{ vars.INCOME_LAYER_MAX_RATIO }}
161+
RUNTIME_TARGET_ENABLED: ${{ vars.RUNTIME_TARGET_ENABLED }}
159162
NOTIFY_LANG: ${{ vars.NOTIFY_LANG }}
160163
EXECUTION_REPORT_GCS_URI: ${{ vars.EXECUTION_REPORT_GCS_URI }}
161164
LONGBRIDGE_DRY_RUN_ONLY: ${{ vars.LONGBRIDGE_DRY_RUN_ONLY }}
@@ -933,6 +936,24 @@ jobs:
933936
remove_env_vars+=("QQQI_INCOME_RATIO")
934937
fi
935938
939+
if [ -n "${INCOME_LAYER_ENABLED:-}" ]; then
940+
env_pairs+=("INCOME_LAYER_ENABLED=${INCOME_LAYER_ENABLED}")
941+
else
942+
remove_env_vars+=("INCOME_LAYER_ENABLED")
943+
fi
944+
945+
if [ -n "${INCOME_LAYER_MAX_RATIO:-}" ]; then
946+
env_pairs+=("INCOME_LAYER_MAX_RATIO=${INCOME_LAYER_MAX_RATIO}")
947+
else
948+
remove_env_vars+=("INCOME_LAYER_MAX_RATIO")
949+
fi
950+
951+
if [ -n "${RUNTIME_TARGET_ENABLED:-}" ]; then
952+
env_pairs+=("RUNTIME_TARGET_ENABLED=${RUNTIME_TARGET_ENABLED}")
953+
else
954+
remove_env_vars+=("RUNTIME_TARGET_ENABLED")
955+
fi
956+
936957
gcloud_args=(
937958
run services update "${CLOUD_RUN_SERVICE}"
938959
--region "${CLOUD_RUN_REGION}"

main.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -428,6 +428,9 @@ def publish_strategy_plugin_alerts(signals, *, report=None):
428428

429429

430430
def run_strategy(*, force_run: bool = False, validation_only: bool = False, validation_label: str = "backfill"):
431+
if not validation_only and not force_run and not getattr(RUNTIME_SETTINGS, "runtime_target_enabled", True):
432+
print(f"[{datetime.now()}] Runtime target disabled; skip strategy execution.", flush=True)
433+
return True
431434
composer = build_composer(dry_run_only_override=True if validation_only else None)
432435
reporting_adapters = composer.build_reporting_adapters()
433436
log_context, report = reporting_adapters.start_run()

runtime_config_support.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ class PlatformRuntimeSettings:
8686
tg_token: str | None
8787
tg_chat_id: str | None
8888
dry_run_only: bool
89+
runtime_target_enabled: bool = True
8990
market: str = DEFAULT_MARKET
9091
market_calendar: str = DEFAULT_MARKET_CALENDAR
9192
market_timezone: str = DEFAULT_MARKET_TIMEZONE
@@ -98,6 +99,8 @@ class PlatformRuntimeSettings:
9899
debug_position_snapshot: bool = False
99100
income_threshold_usd: float | None = None
100101
qqqi_income_ratio: float | None = None
102+
income_layer_enabled: bool | None = None
103+
income_layer_max_ratio: float | None = None
101104
runtime_execution_window_trading_days: int | None = None
102105
feature_snapshot_path: str | None = None
103106
feature_snapshot_manifest_path: str | None = None
@@ -263,6 +266,7 @@ def load_platform_runtime_settings(
263266
tg_token=os.getenv("TELEGRAM_TOKEN"),
264267
tg_chat_id=os.getenv("GLOBAL_TELEGRAM_CHAT_ID"),
265268
dry_run_only=resolve_bool_value(os.getenv("LONGBRIDGE_DRY_RUN_ONLY")),
269+
runtime_target_enabled=_runtime_target_enabled_env(),
266270
reserved_cash_floor_usd=_resolve_non_negative_float_env(
267271
"LONGBRIDGE_MIN_RESERVED_CASH_USD",
268272
default=DEFAULT_RESERVED_CASH_FLOOR_USD,
@@ -283,6 +287,8 @@ def load_platform_runtime_settings(
283287
debug_position_snapshot=resolve_bool_value(os.getenv("LONGBRIDGE_DEBUG_POSITION_SNAPSHOT")),
284288
income_threshold_usd=resolve_optional_float_env(os.environ, "INCOME_THRESHOLD_USD"),
285289
qqqi_income_ratio=_qqqi_income_ratio_env(),
290+
income_layer_enabled=_optional_bool_env("INCOME_LAYER_ENABLED"),
291+
income_layer_max_ratio=_optional_ratio_env("INCOME_LAYER_MAX_RATIO"),
286292
runtime_execution_window_trading_days=_runtime_execution_window_trading_days_env(
287293
strategy_definition.profile
288294
),
@@ -366,6 +372,34 @@ def _qqqi_income_ratio_env() -> float | None:
366372
return value
367373

368374

375+
def _optional_bool_env(name: str) -> bool | None:
376+
raw_value = os.getenv(name)
377+
if raw_value is None or str(raw_value).strip() == "":
378+
return None
379+
value = str(raw_value).strip().lower()
380+
if value in {"1", "true", "yes", "y", "on"}:
381+
return True
382+
if value in {"0", "false", "no", "n", "off"}:
383+
return False
384+
raise ValueError(f"{name} must be boolean, got {raw_value!r}")
385+
386+
387+
def _runtime_target_enabled_env() -> bool:
388+
value = _optional_bool_env("RUNTIME_TARGET_ENABLED")
389+
return True if value is None else value
390+
391+
392+
def _optional_ratio_env(name: str) -> float | None:
393+
value = resolve_optional_float_env(os.environ, name)
394+
if value is None:
395+
return None
396+
if not math.isfinite(value):
397+
raise ValueError(f"{name} must be finite, got {value}")
398+
if not (0.0 <= value <= 1.0):
399+
raise ValueError(f"{name} must be in [0,1], got {value}")
400+
return value
401+
402+
369403
def _resolve_non_negative_float_env(name: str, *, default: float) -> float:
370404
value = resolve_optional_float_env(os.environ, name)
371405
if value is None:

strategy_runtime.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ def _default_runtime_settings(profile: str, display_name: str) -> PlatformRuntim
166166

167167
def _build_runtime_overrides(profile: str, runtime_settings: PlatformRuntimeSettings) -> dict[str, Any]:
168168
overrides: dict[str, Any] = {}
169+
income_layer_enabled = getattr(runtime_settings, "income_layer_enabled", None)
170+
income_layer_max_ratio = getattr(runtime_settings, "income_layer_max_ratio", None)
171+
if income_layer_enabled is not None:
172+
overrides["income_layer_enabled"] = income_layer_enabled
173+
if income_layer_max_ratio is not None:
174+
overrides["income_layer_max_ratio"] = income_layer_max_ratio
169175
if profile == "tqqq_growth_income":
170176
if runtime_settings.income_threshold_usd is not None:
171177
overrides["income_threshold_usd"] = runtime_settings.income_threshold_usd

tests/test_runtime_config_support.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ def test_load_platform_runtime_settings_uses_defaults_with_explicit_strategy_pro
149149
self.assertIsNotNone(settings.runtime_target)
150150
self.assertEqual(settings.runtime_target.platform_id, "longbridge")
151151
self.assertEqual(settings.runtime_target.execution_mode, "live")
152+
self.assertTrue(settings.runtime_target_enabled)
152153
self.assertIsNone(settings.income_threshold_usd)
153154
self.assertIsNone(settings.qqqi_income_ratio)
154155
self.assertIsNone(settings.feature_snapshot_path)
@@ -247,6 +248,31 @@ def test_dry_run_only_is_loaded_from_env(self):
247248

248249
self.assertTrue(settings.dry_run_only)
249250

251+
def test_runtime_target_enabled_is_loaded_from_env(self):
252+
with patch.dict(
253+
os.environ,
254+
{
255+
"RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE),
256+
"RUNTIME_TARGET_ENABLED": "false",
257+
},
258+
clear=True,
259+
):
260+
settings = load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
261+
262+
self.assertFalse(settings.runtime_target_enabled)
263+
264+
def test_invalid_runtime_target_enabled_is_rejected(self):
265+
with patch.dict(
266+
os.environ,
267+
{
268+
"RUNTIME_TARGET_JSON": runtime_target_json(SAMPLE_STRATEGY_PROFILE),
269+
"RUNTIME_TARGET_ENABLED": "maybe",
270+
},
271+
clear=True,
272+
):
273+
with self.assertRaisesRegex(ValueError, "RUNTIME_TARGET_ENABLED"):
274+
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
275+
250276
def test_debug_position_snapshot_is_loaded_from_env(self):
251277
with patch.dict(
252278
os.environ,
@@ -455,6 +481,8 @@ def test_income_layer_overrides_are_loaded_from_env(self):
455481
"RUNTIME_TARGET_JSON": runtime_target_json("tqqq_growth_income"),
456482
"INCOME_THRESHOLD_USD": "100000",
457483
"QQQI_INCOME_RATIO": "0.5",
484+
"INCOME_LAYER_ENABLED": "false",
485+
"INCOME_LAYER_MAX_RATIO": "0.25",
458486
},
459487
clear=True,
460488
):
@@ -463,6 +491,8 @@ def test_income_layer_overrides_are_loaded_from_env(self):
463491
self.assertEqual(settings.strategy_profile, "tqqq_growth_income")
464492
self.assertEqual(settings.income_threshold_usd, 100000.0)
465493
self.assertEqual(settings.qqqi_income_ratio, 0.5)
494+
self.assertFalse(settings.income_layer_enabled)
495+
self.assertEqual(settings.income_layer_max_ratio, 0.25)
466496

467497
def test_tech_runtime_execution_window_override_rejects_research_only_profile(self):
468498
with patch.dict(
@@ -490,6 +520,18 @@ def test_rejects_invalid_qqqi_income_ratio(self):
490520
with self.assertRaisesRegex(ValueError, "QQQI_INCOME_RATIO"):
491521
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
492522

523+
def test_rejects_invalid_income_layer_max_ratio(self):
524+
with patch.dict(
525+
os.environ,
526+
{
527+
"RUNTIME_TARGET_JSON": runtime_target_json("tqqq_growth_income"),
528+
"INCOME_LAYER_MAX_RATIO": "1.5",
529+
},
530+
clear=True,
531+
):
532+
with self.assertRaisesRegex(ValueError, "INCOME_LAYER_MAX_RATIO"):
533+
load_platform_runtime_settings(project_id_resolver=lambda: "project-1")
534+
493535
def test_rejects_human_readable_alias(self):
494536
with patch.dict(
495537
os.environ,

tests/test_strategy_runtime.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@ def _build_runtime_settings(
100100
feature_snapshot_path: str | None = None,
101101
income_threshold_usd: float | None = None,
102102
qqqi_income_ratio: float | None = None,
103+
income_layer_enabled: bool | None = None,
104+
income_layer_max_ratio: float | None = None,
103105
runtime_execution_window_trading_days: int | None = None,
104106
) -> PlatformRuntimeSettings:
105107
return PlatformRuntimeSettings(
@@ -118,6 +120,8 @@ def _build_runtime_settings(
118120
dry_run_only=False,
119121
income_threshold_usd=income_threshold_usd,
120122
qqqi_income_ratio=qqqi_income_ratio,
123+
income_layer_enabled=income_layer_enabled,
124+
income_layer_max_ratio=income_layer_max_ratio,
121125
runtime_execution_window_trading_days=runtime_execution_window_trading_days,
122126
feature_snapshot_path=feature_snapshot_path,
123127
feature_snapshot_manifest_path=None,
@@ -257,13 +261,19 @@ def test_load_strategy_runtime_applies_tqqq_income_overrides_from_settings(self)
257261
"tqqq_growth_income",
258262
income_threshold_usd=100000.0,
259263
qqqi_income_ratio=0.5,
264+
income_layer_enabled=False,
265+
income_layer_max_ratio=0.25,
260266
),
261267
)
262268

263269
self.assertEqual(runtime.runtime_overrides["income_threshold_usd"], 100000.0)
264270
self.assertEqual(runtime.runtime_overrides["qqqi_income_ratio"], 0.5)
271+
self.assertFalse(runtime.runtime_overrides["income_layer_enabled"])
272+
self.assertEqual(runtime.runtime_overrides["income_layer_max_ratio"], 0.25)
265273
self.assertEqual(runtime.merged_runtime_config["income_threshold_usd"], 100000.0)
266274
self.assertEqual(runtime.merged_runtime_config["qqqi_income_ratio"], 0.5)
275+
self.assertFalse(runtime.merged_runtime_config["income_layer_enabled"])
276+
self.assertEqual(runtime.merged_runtime_config["income_layer_max_ratio"], 0.25)
267277

268278
def test_load_strategy_runtime_applies_tech_execution_window_overrides_from_settings(self):
269279
entrypoint = _TechEntrypoint()

0 commit comments

Comments
 (0)