diff --git a/main.py b/main.py index a0be7c06..682d8360 100644 --- a/main.py +++ b/main.py @@ -101,6 +101,13 @@ map_strategy_decision_to_rotation_plan as map_decision_to_rotation_plan, ) from strategy_runtime import load_strategy_runtime +from run_context import ( + RunContext, + bind_run_context, + reset_run_context, + resolve_trend_universe, + set_active_trend_universe, +) from strategy_registry import DEFAULT_STRATEGY_PROFILE from trade_state_support import ( build_default_state as ts_build_default_state, @@ -452,7 +459,7 @@ def update_trend_pool_state(state, resolution): def build_default_state(): return ts_build_default_state( - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), last_good_payload_key=TREND_POOL_LAST_GOOD_PAYLOAD_KEY, action_history_key=TREND_POOL_ACTION_HISTORY_KEY, retired_positions_key=RETIRED_TREND_POSITIONS_KEY, @@ -462,7 +469,7 @@ def build_default_state(): def normalize_trade_state(state): return ts_normalize_trade_state( state, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), last_good_payload_key=TREND_POOL_LAST_GOOD_PAYLOAD_KEY, action_history_key=TREND_POOL_ACTION_HISTORY_KEY, retired_positions_key=RETIRED_TREND_POSITIONS_KEY, @@ -472,7 +479,7 @@ def normalize_trade_state(state): def get_runtime_trend_universe(state): return ts_get_runtime_trend_universe( state, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), retired_positions_key=RETIRED_TREND_POSITIONS_KEY, ) @@ -481,7 +488,7 @@ def get_symbol_trade_state(state, symbol): return ts_get_symbol_trade_state( state, symbol, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), retired_positions_key=RETIRED_TREND_POSITIONS_KEY, ) @@ -491,7 +498,7 @@ def set_symbol_trade_state(state, symbol, symbol_state): state, symbol, symbol_state, - trend_universe=TREND_UNIVERSE, + trend_universe=resolve_trend_universe(TREND_UNIVERSE), retired_positions_key=RETIRED_TREND_POSITIONS_KEY, ) @@ -786,7 +793,7 @@ def enrich_btc_snapshot_with_cycle_indicators(btc_snapshot: dict, log_buffer: li def resolve_runtime_trend_indicators(runtime): return infra_resolve_runtime_trend_indicators( runtime, - TREND_UNIVERSE, + resolve_trend_universe(TREND_UNIVERSE), fetch_daily_indicators_fn=fetch_daily_indicators, ) @@ -855,11 +862,18 @@ def build_live_runtime(now_utc=None, *, retain_interval_receipts=False): return runtime -def _set_runtime_trend_universe(resolved_trend_universe): +def _module_set_trend_universe(resolved_trend_universe): global TREND_UNIVERSE TREND_UNIVERSE = resolved_trend_universe +def _set_runtime_trend_universe(resolved_trend_universe): + set_active_trend_universe( + resolved_trend_universe, + fallback_setter=_module_set_trend_universe, + ) + + def _ensure_runtime_client(runtime, report): return infra_ensure_runtime_client( runtime, @@ -1269,7 +1283,7 @@ def _execute_trend_rotation( execute_trend_sells=_execute_trend_sells, execute_trend_buys=_execute_trend_buys, append_trend_symbol_status=_append_trend_symbol_status, - official_trend_pool_symbols=list(TREND_UNIVERSE.keys()), + official_trend_pool_symbols=list(resolve_trend_universe(TREND_UNIVERSE).keys()), ) @@ -1316,9 +1330,12 @@ def _execute_btc_dca_cycle( def execute_cycle(runtime): - global TREND_UNIVERSE - previous_trend_universe = {symbol: meta.copy() for symbol, meta in TREND_UNIVERSE.items()} - + # B09: cycle-scoped universe on RunContext — do not mutate module TREND_UNIVERSE. + ctx = RunContext( + trend_universe={symbol: meta.copy() for symbol, meta in TREND_UNIVERSE.items()}, + ) + runtime.run_context = ctx + token = bind_run_context(ctx) try: return execute_strategy_cycle( runtime, @@ -1347,7 +1364,7 @@ def execute_cycle(runtime): traceback_module=traceback, ) finally: - TREND_UNIVERSE = previous_trend_universe + reset_run_context(token) def main(): diff --git a/run_context.py b/run_context.py new file mode 100644 index 00000000..7ac5ca48 --- /dev/null +++ b/run_context.py @@ -0,0 +1,59 @@ +"""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. +Does not change order-submission or state-owner semantics in runtime_support. +""" + +from __future__ import annotations + +from contextvars import ContextVar, Token +from dataclasses import dataclass +from typing import Any, Callable, Mapping, MutableMapping, Optional + + +@dataclass +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 + + +_ACTIVE_RUN_CONTEXT: ContextVar[Optional[RunContext]] = ContextVar( + "binance_active_run_context", + default=None, +) + + +def bind_run_context(ctx: RunContext) -> Token: + return _ACTIVE_RUN_CONTEXT.set(ctx) + + +def reset_run_context(token: Token) -> None: + _ACTIVE_RUN_CONTEXT.reset(token) + + +def get_active_run_context() -> Optional[RunContext]: + return _ACTIVE_RUN_CONTEXT.get() + + +def resolve_trend_universe( + fallback: Mapping[str, dict[str, Any]], +) -> Mapping[str, dict[str, Any]]: + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None: + return ctx.trend_universe + return fallback + + +def set_active_trend_universe( + resolved: MutableMapping[str, dict[str, Any]], + *, + fallback_setter: Callable[[MutableMapping[str, dict[str, Any]]], None], +) -> None: + ctx = _ACTIVE_RUN_CONTEXT.get() + if ctx is not None: + ctx.trend_universe = resolved + return + fallback_setter(resolved) diff --git a/runtime_support.py b/runtime_support.py index 636c9156..db59531b 100644 --- a/runtime_support.py +++ b/runtime_support.py @@ -320,6 +320,7 @@ class ExecutionRuntime: fuel_symbol: str = "BNBUSDT" fuel_asset: str = "BNB" side_effect_log: list[dict[str, Any]] = field(default_factory=list) + run_context: Any = None def __post_init__(self): if self.now_utc is None: diff --git a/tests/test_run_context.py b/tests/test_run_context.py new file mode 100644 index 00000000..f90871a6 --- /dev/null +++ b/tests/test_run_context.py @@ -0,0 +1,133 @@ +"""B09: RunContext isolates trend universe from module globals.""" + +from __future__ import annotations + +import unittest +from unittest.mock import patch + +import main +from run_context import ( + RunContext, + bind_run_context, + get_active_run_context, + reset_run_context, + resolve_trend_universe, + set_active_trend_universe, +) +from runtime_support import ExecutionRuntime + + +def _baseline_universe(): + return {symbol: meta.copy() for symbol, meta in main.STATIC_TREND_UNIVERSE.items()} + + +class RunContextUnitTests(unittest.TestCase): + def setUp(self): + main.TREND_UNIVERSE = _baseline_universe() + + def tearDown(self): + main.TREND_UNIVERSE = _baseline_universe() + self.assertIsNone( + get_active_run_context(), + "active RunContext leaked after test", + ) + + def test_resolve_falls_back_to_module_when_unbound(self): + self.assertIs(resolve_trend_universe(main.TREND_UNIVERSE), main.TREND_UNIVERSE) + + def test_set_and_resolve_use_active_context(self): + custom = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + ctx = RunContext(trend_universe=_baseline_universe()) + token = bind_run_context(ctx) + try: + set_active_trend_universe( + custom, + fallback_setter=lambda _value: (_ for _ in ()).throw( + AssertionError("fallback must not run while bound") + ), + ) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), custom) + self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + finally: + reset_run_context(token) + self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + self.assertIs(resolve_trend_universe(main.TREND_UNIVERSE), main.TREND_UNIVERSE) + + def test_sequential_binds_do_not_leak_universe(self): + first = {"AAAISOLATEUSDT": {"base_asset": "AAA"}} + second = {"BBBISOLATEUSDT": {"base_asset": "BBB"}} + + ctx1 = RunContext(trend_universe=_baseline_universe()) + token1 = bind_run_context(ctx1) + try: + set_active_trend_universe(first, fallback_setter=main._module_set_trend_universe) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), first) + finally: + reset_run_context(token1) + + ctx2 = RunContext(trend_universe=_baseline_universe()) + token2 = bind_run_context(ctx2) + try: + resolved = resolve_trend_universe(main.TREND_UNIVERSE) + self.assertNotIn("AAAISOLATEUSDT", resolved) + set_active_trend_universe(second, fallback_setter=main._module_set_trend_universe) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), second) + self.assertNotIn("AAAISOLATEUSDT", resolve_trend_universe(main.TREND_UNIVERSE)) + finally: + reset_run_context(token2) + + self.assertEqual(dict(main.TREND_UNIVERSE), _baseline_universe()) + + +class ExecuteCycleUniverseIsolationTests(unittest.TestCase): + def setUp(self): + self._baseline = _baseline_universe() + main.TREND_UNIVERSE = _baseline_universe() + + def tearDown(self): + main.TREND_UNIVERSE = _baseline_universe() + self.assertIsNone(get_active_run_context()) + + def test_execute_cycle_does_not_mutate_module_universe_on_success(self): + injected = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + + def fake_cycle(runtime, **_kwargs): + self.assertIsNotNone(runtime.run_context) + main._set_runtime_trend_universe(injected) + self.assertEqual(dict(resolve_trend_universe(main.TREND_UNIVERSE)), injected) + self.assertEqual(dict(main.TREND_UNIVERSE), self._baseline) + return {"status": "ok"} + + runtime = ExecutionRuntime(dry_run=True, strategy_profile="crypto_live_pool_rotation") + with patch("main.execute_strategy_cycle", side_effect=fake_cycle): + report = main.execute_cycle(runtime) + self.assertEqual(report["status"], "ok") + self.assertEqual(dict(main.TREND_UNIVERSE), self._baseline) + self.assertIsNone(get_active_run_context()) + + def test_execute_cycle_does_not_mutate_module_universe_on_error(self): + injected = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + + def fake_cycle(runtime, **_kwargs): + main._set_runtime_trend_universe(injected) + raise RuntimeError("boom") + + runtime = ExecutionRuntime(dry_run=True, strategy_profile="crypto_live_pool_rotation") + with patch("main.execute_strategy_cycle", side_effect=fake_cycle): + with self.assertRaises(RuntimeError): + main.execute_cycle(runtime) + self.assertEqual(dict(main.TREND_UNIVERSE), self._baseline) + self.assertIsNone(get_active_run_context()) + + def test_unbound_setter_still_updates_module_for_compat(self): + custom = {"ZZZTESTUSDT": {"base_asset": "ZZZ"}} + self.assertIsNone(get_active_run_context()) + main._set_runtime_trend_universe(custom) + try: + self.assertEqual(dict(main.TREND_UNIVERSE), custom) + finally: + main.TREND_UNIVERSE = _baseline_universe() + + +if __name__ == "__main__": + unittest.main()