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
53 changes: 38 additions & 15 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
Expand Down Expand Up @@ -263,21 +265,33 @@ 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
global DEFAULT_LIVE_POOL_LEGACY_PATH
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):
Expand Down Expand Up @@ -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
)

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
27 changes: 24 additions & 3 deletions run_context.py
Original file line number Diff line number Diff line change
@@ -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.
"""

Expand All @@ -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(
Expand Down Expand Up @@ -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)
1 change: 1 addition & 0 deletions runtime_support.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
134 changes: 133 additions & 1 deletion tests/test_run_context.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -11,6 +12,7 @@
bind_run_context,
get_active_run_context,
reset_run_context,
resolve_strategy_runtime,
resolve_trend_universe,
set_active_trend_universe,
)
Expand All @@ -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"}}
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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()
Loading