diff --git a/main.py b/main.py index 682d8360..523388e6 100644 --- a/main.py +++ b/main.py @@ -104,7 +104,9 @@ from run_context import ( RunContext, bind_run_context, + get_active_run_context, reset_run_context, + resolve_strategy_runtime, resolve_trend_universe, set_active_trend_universe, ) @@ -263,8 +265,12 @@ def _load_import_safe_strategy_runtime(): BTC_MARKET_SNAPSHOT_RETRY_DELAYS = (5, 15) -def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None) -> None: - """Replace the import-safe evaluator only after RuntimeTarget validation.""" +def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None): + """Replace the import-safe evaluator only after RuntimeTarget validation. + + Writes the activated handle to module STRATEGY_RUNTIME (compat / unbound + fallback) and, when a RunContext is bound, also to ctx.strategy_runtime. + """ global STRATEGY_RUNTIME global TREND_POOL_SIZE @@ -272,12 +278,20 @@ def _activate_execution_strategy_runtime(profile: str, *, runtime_target=None) - global DEFAULT_TREND_POOL_MAX_AGE_DAYS global DEFAULT_TREND_POOL_ACCEPTABLE_MODES - STRATEGY_RUNTIME = (load_strategy_runtime(profile) if runtime_target is None - else load_strategy_runtime(profile, runtime_target=runtime_target)) - TREND_POOL_SIZE = STRATEGY_RUNTIME.trend_pool_size - DEFAULT_LIVE_POOL_LEGACY_PATH = STRATEGY_RUNTIME.default_local_artifact_path - DEFAULT_TREND_POOL_MAX_AGE_DAYS = int(STRATEGY_RUNTIME.artifact_contract["max_age_days"]) - DEFAULT_TREND_POOL_ACCEPTABLE_MODES = tuple(STRATEGY_RUNTIME.artifact_contract["acceptable_modes"]) + activated = ( + load_strategy_runtime(profile) + if runtime_target is None + else load_strategy_runtime(profile, runtime_target=runtime_target) + ) + STRATEGY_RUNTIME = activated + TREND_POOL_SIZE = activated.trend_pool_size + DEFAULT_LIVE_POOL_LEGACY_PATH = activated.default_local_artifact_path + DEFAULT_TREND_POOL_MAX_AGE_DAYS = int(activated.artifact_contract["max_age_days"]) + DEFAULT_TREND_POOL_ACCEPTABLE_MODES = tuple(activated.artifact_contract["acceptable_modes"]) + ctx = get_active_run_context() + if ctx is not None: + ctx.strategy_runtime = activated + return activated class BalanceFetchError(RuntimeError): @@ -362,7 +376,8 @@ def validate_trend_pool_payload( def get_default_live_pool_candidates(): - return list(STRATEGY_RUNTIME.local_artifact_candidates) or tp_get_default_live_pool_candidates( + strategy_runtime = resolve_strategy_runtime(STRATEGY_RUNTIME) + return list(strategy_runtime.local_artifact_candidates) or tp_get_default_live_pool_candidates( DEFAULT_LIVE_POOL_LEGACY_PATH ) @@ -658,9 +673,10 @@ def maybe_send_periodic_btc_status_report( strategy_display_name=None, notifier_fn=None, ): - runtime_manifest = getattr(getattr(STRATEGY_RUNTIME, "entrypoint", None), "manifest", None) + strategy_runtime = resolve_strategy_runtime(STRATEGY_RUNTIME) + runtime_manifest = getattr(getattr(strategy_runtime, "entrypoint", None), "manifest", None) resolved_strategy_display_name = strategy_display_name or build_strategy_display_name(t)( - getattr(STRATEGY_RUNTIME, "profile", "crypto_live_pool_rotation"), + getattr(strategy_runtime, "profile", "crypto_live_pool_rotation"), fallback_name=getattr(runtime_manifest, "display_name", "Crypto Live Pool Rotation"), ) return report_maybe_send_periodic_btc_status_report( @@ -850,7 +866,10 @@ def build_live_runtime(now_utc=None, *, retain_interval_receipts=False): state_writer=set_trade_state, notifier=lambda **kwargs: send_tg_msg(kwargs["token"], kwargs["chat_id"], kwargs["text"]), ) - _activate_execution_strategy_runtime(runtime.strategy_profile, runtime_target=runtime.runtime_target) + runtime.strategy_runtime = _activate_execution_strategy_runtime( + runtime.strategy_profile, + runtime_target=runtime.runtime_target, + ) if not runtime.dry_run and runtime.standard_execution_permitted: runtime.bound_state_access = bind_trade_state_access( normalize_fn=normalize_trade_state, default_state_factory=build_default_state, @@ -963,14 +982,15 @@ def _resolve_strategy_evaluation( for symbol, meta in runtime_trend_universe.items() if not meta.get("valuation_only") ) - account_metrics = STRATEGY_RUNTIME.compute_account_metrics( + strategy_runtime = resolve_strategy_runtime(STRATEGY_RUNTIME) + account_metrics = strategy_runtime.compute_account_metrics( runtime_trend_universe, balances, prices, u_total, fuel_val, ) - return STRATEGY_RUNTIME.evaluate( + return strategy_runtime.evaluate( prices=prices, trend_indicators=trend_indicators, btc_snapshot=btc_snapshot, @@ -1330,9 +1350,12 @@ def _execute_btc_dca_cycle( def execute_cycle(runtime): - # B09: cycle-scoped universe on RunContext — do not mutate module TREND_UNIVERSE. + # B09: cycle-scoped universe + strategy handle on RunContext. + # Do not mutate module TREND_UNIVERSE; STRATEGY_RUNTIME stays as unbound fallback. + mounted_strategy = getattr(runtime, "strategy_runtime", None) or STRATEGY_RUNTIME ctx = RunContext( trend_universe={symbol: meta.copy() for symbol, meta in TREND_UNIVERSE.items()}, + strategy_runtime=mounted_strategy, ) runtime.run_context = ctx token = bind_run_context(ctx) diff --git a/run_context.py b/run_context.py index 7ac5ca48..42a92dbc 100644 --- a/run_context.py +++ b/run_context.py @@ -1,7 +1,8 @@ """Per-cycle RunContext for BinancePlatform (B09). -First slice: trend universe leaves module globals during execute_cycle. -Strategy-runtime activation stays on module globals until a follow-up PR. +Trend universe (PR-1) and strategy-runtime handle (PR-2) bind to a cycle-scoped +context during execute_cycle. Module STRATEGY_RUNTIME / TREND_UNIVERSE remain +import-safe fallbacks and out-of-cycle patch points. Does not change order-submission or state-owner semantics in runtime_support. """ @@ -17,7 +18,7 @@ class RunContext: """Cycle-scoped mutable view. Not a replacement for ExecutionRuntime.""" trend_universe: MutableMapping[str, dict[str, Any]] - strategy_runtime: Any | None = None # reserved for B09 phase 2 + strategy_runtime: Any | None = None _ACTIVE_RUN_CONTEXT: ContextVar[Optional[RunContext]] = ContextVar( @@ -57,3 +58,23 @@ def set_active_trend_universe( ctx.trend_universe = resolved return fallback_setter(resolved) + + +def resolve_strategy_runtime(fallback: Any) -> Any: + """Prefer cycle-bound strategy runtime; else module/import-safe fallback.""" + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None and ctx.strategy_runtime is not None: + return ctx.strategy_runtime + return fallback + + +def set_active_strategy_runtime( + activated: Any, + *, + fallback_setter: Callable[[Any], None], +) -> None: + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None: + ctx.strategy_runtime = activated + return + fallback_setter(activated) diff --git a/runtime_support.py b/runtime_support.py index db59531b..8d17fcf1 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -321,6 +321,7 @@ class ExecutionRuntime: fuel_asset: str = "BNB" side_effect_log: list[dict[str, Any]] = field(default_factory=list) run_context: Any = None + strategy_runtime: Any = None # B09 PR-2: activated handle; cycle mounts onto RunContext def __post_init__(self): if self.now_utc is None: diff --git a/tests/test_run_context.py b/tests/test_run_context.py index f90871a6..78bf6c35 100644 --- a/tests/test_run_context.py +++ b/tests/test_run_context.py @@ -1,8 +1,9 @@ -"""B09: RunContext isolates trend universe from module globals.""" +"""B09: RunContext isolates trend universe and strategy runtime from module globals.""" from __future__ import annotations import unittest +from types import SimpleNamespace from unittest.mock import patch import main @@ -11,6 +12,7 @@ bind_run_context, get_active_run_context, reset_run_context, + resolve_strategy_runtime, resolve_trend_universe, set_active_trend_universe, ) @@ -34,6 +36,7 @@ def tearDown(self): def test_resolve_falls_back_to_module_when_unbound(self): self.assertIs(resolve_trend_universe(main.TREND_UNIVERSE), main.TREND_UNIVERSE) + self.assertIs(resolve_strategy_runtime(main.STRATEGY_RUNTIME), main.STRATEGY_RUNTIME) def test_set_and_resolve_use_active_context(self): custom = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} @@ -78,6 +81,40 @@ def test_sequential_binds_do_not_leak_universe(self): self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + def test_resolve_strategy_runtime_prefers_active_context(self): + module_handle = main.STRATEGY_RUNTIME + bound_handle = SimpleNamespace(profile="bound-profile", marker="ctx") + ctx = RunContext(trend_universe=_baseline_universe(), strategy_runtime=bound_handle) + token = bind_run_context(ctx) + try: + self.assertIs(resolve_strategy_runtime(module_handle), bound_handle) + self.assertIs(main.STRATEGY_RUNTIME, module_handle) + finally: + reset_run_context(token) + self.assertIs(resolve_strategy_runtime(module_handle), module_handle) + + def test_sequential_binds_do_not_leak_strategy_runtime(self): + first = SimpleNamespace(profile="first") + second = SimpleNamespace(profile="second") + module_handle = main.STRATEGY_RUNTIME + + ctx1 = RunContext(trend_universe=_baseline_universe(), strategy_runtime=first) + token1 = bind_run_context(ctx1) + try: + self.assertIs(resolve_strategy_runtime(module_handle), first) + finally: + reset_run_context(token1) + + ctx2 = RunContext(trend_universe=_baseline_universe(), strategy_runtime=second) + token2 = bind_run_context(ctx2) + try: + self.assertIs(resolve_strategy_runtime(module_handle), second) + self.assertIsNot(resolve_strategy_runtime(module_handle), first) + finally: + reset_run_context(token2) + + self.assertIs(resolve_strategy_runtime(module_handle), module_handle) + class ExecuteCycleUniverseIsolationTests(unittest.TestCase): def setUp(self): @@ -128,6 +165,101 @@ def test_unbound_setter_still_updates_module_for_compat(self): finally: main.TREND_UNIVERSE = _baseline_universe() + def test_execute_cycle_mounts_runtime_strategy_handle(self): + mounted = SimpleNamespace(profile="mounted-from-runtime", marker="rt") + + def fake_cycle(runtime, **_kwargs): + self.assertIs(runtime.run_context.strategy_runtime, mounted) + self.assertIs(resolve_strategy_runtime(main.STRATEGY_RUNTIME), mounted) + return {"status": "ok"} + + runtime = ExecutionRuntime( + dry_run=True, + strategy_profile="crypto_live_pool_rotation", + strategy_runtime=mounted, + ) + with patch("main.execute_strategy_cycle", side_effect=fake_cycle): + report = main.execute_cycle(runtime) + self.assertEqual(report["status"], "ok") + self.assertIsNone(get_active_run_context()) + + def test_activate_while_bound_writes_context_and_module(self): + prior = main.STRATEGY_RUNTIME + prior_pool = main.TREND_POOL_SIZE + prior_legacy = main.DEFAULT_LIVE_POOL_LEGACY_PATH + prior_max_age = main.DEFAULT_TREND_POOL_MAX_AGE_DAYS + prior_modes = main.DEFAULT_TREND_POOL_ACCEPTABLE_MODES + fake = SimpleNamespace( + profile="crypto_live_pool_rotation", + trend_pool_size=11, + default_local_artifact_path=prior.default_local_artifact_path, + artifact_contract={ + "max_age_days": int(prior.artifact_contract["max_age_days"]), + "acceptable_modes": tuple(prior.artifact_contract["acceptable_modes"]), + }, + local_artifact_candidates=(), + ) + ctx = RunContext(trend_universe=_baseline_universe()) + token = bind_run_context(ctx) + try: + with patch("main.load_strategy_runtime", return_value=fake) as loader: + activated = main._activate_execution_strategy_runtime("crypto_live_pool_rotation") + loader.assert_called_once_with("crypto_live_pool_rotation") + self.assertIs(activated, fake) + self.assertIs(ctx.strategy_runtime, fake) + self.assertIs(main.STRATEGY_RUNTIME, fake) + self.assertIs(resolve_strategy_runtime(prior), fake) + self.assertEqual(main.TREND_POOL_SIZE, 11) + finally: + reset_run_context(token) + main.STRATEGY_RUNTIME = prior + main.TREND_POOL_SIZE = prior_pool + main.DEFAULT_LIVE_POOL_LEGACY_PATH = prior_legacy + main.DEFAULT_TREND_POOL_MAX_AGE_DAYS = prior_max_age + main.DEFAULT_TREND_POOL_ACCEPTABLE_MODES = prior_modes + + +class StrategyRuntimeEquivalenceTests(unittest.TestCase): + """Bound resolve path must match direct module handle for a frozen profile.""" + + def tearDown(self): + self.assertIsNone(get_active_run_context()) + + def test_bound_resolve_matches_module_metrics_for_same_handle(self): + # Use the import-safe module handle (already loaded) — behavior-equivalent + # mount onto RunContext without requiring full CryptoStrategies catalog. + activated = main.STRATEGY_RUNTIME + universe = { + "ETHUSDT": {"base_asset": "ETH"}, + "SOLUSDT": {"base_asset": "SOL", "valuation_only": True}, + } + prices = {"BTCUSDT": 1.0, "ETHUSDT": 1.0, "SOLUSDT": 1.0, "BNBUSDT": 1.0} + balances = {"BTCUSDT": 0.0, "ETHUSDT": 0.0, "SOLUSDT": 0.0, "BNBUSDT": 0.0} + u_total = 100.0 + fuel_val = 0.0 + + direct_metrics = activated.compute_account_metrics( + universe, balances, prices, u_total, fuel_val + ) + + ctx = RunContext( + trend_universe={symbol: meta.copy() for symbol, meta in universe.items()}, + strategy_runtime=activated, + ) + token = bind_run_context(ctx) + try: + resolved = resolve_strategy_runtime(SimpleNamespace(profile="must-not-use")) + self.assertIs(resolved, activated) + bound_metrics = resolved.compute_account_metrics( + universe, balances, prices, u_total, fuel_val + ) + finally: + reset_run_context(token) + + self.assertEqual(bound_metrics, direct_metrics) + self.assertTrue(callable(activated.evaluate)) + self.assertTrue(callable(resolve_strategy_runtime(activated).evaluate)) + if __name__ == "__main__": unittest.main()