Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 29 additions & 12 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
)

Expand All @@ -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,
)

Expand All @@ -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,
)

Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()),
)


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -1347,7 +1364,7 @@ def execute_cycle(runtime):
traceback_module=traceback,
)
finally:
TREND_UNIVERSE = previous_trend_universe
reset_run_context(token)


def main():
Expand Down
59 changes: 59 additions & 0 deletions run_context.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions runtime_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
133 changes: 133 additions & 0 deletions tests/test_run_context.py
Original file line number Diff line number Diff line change
@@ -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()
Loading