CWM (103): core mechanics, exhaustive edge cases, numba, exchange mechanics Replay (118): exhaustive verification, microstructure, trajectory Training (190): asset classification, phase0 extensive, pipeline, exhaustive DSL (102): v2 syntax, expanded, new features ASEx (33): validate-before-mutate, single-writer Planner (48): MCTS, alternatives, hooks Counterparties (19): 9 adversarial agent policies Clock (30): event-driven reactor BingX (28): venue adapter IPC (8): Zinc SHM Storage (9): ClickHouse Risk (4): hard invariants State (17): frozen dataclass invariants Integration: E2E, concurrency, sync/async seams, hypothesis, fuzz, adversarial
501 lines
21 KiB
Python
501 lines
21 KiB
Python
"""
|
||
Exhaustive training subsystem tests.
|
||
|
||
Categories:
|
||
1. CMA Parameter Codec (encode/decode/bounds)
|
||
2. SelfPlayPool (add/evict/diversity)
|
||
3. Scenario Factory (suite generation)
|
||
4. Policy Evaluator (real episodes, metrics)
|
||
5. EpisodeResult (fields, computation)
|
||
6. Bootstrap CI
|
||
7. CMAESTrainer (end-to-end)
|
||
8. Promotion logic
|
||
"""
|
||
import dataclasses
|
||
import math
|
||
import pytest
|
||
from malkhut.state import (
|
||
AccountState, ExecutionIntent, FulfilmentPolicyParams, IntentKind,
|
||
MarketWorldState, Mode, OrderBookState, PriceLevel, Side, VenueRules,
|
||
)
|
||
from malkhut.actions import ActionKind, FulfilmentAction, OrderType
|
||
from malkhut.cwm.core import MinimalCryptoLOBCWM
|
||
from malkhut.counterparties import default_counterparty_ecology
|
||
from malkhut.training.cma_trainer import (
|
||
CMAParameterCodec, SelfPlayPool, PolicySnapshot,
|
||
ScenarioFactory, PolicyEvaluator, EpisodeResult,
|
||
CMAESTrainer, bootstrap_ci,
|
||
)
|
||
|
||
|
||
def _baseline(**kw):
|
||
d = dict(
|
||
version="baseline", ucb_c=1.414, max_sims=256, max_depth=3,
|
||
rollout_depth=3, root_temperature=0.5, min_root_entropy=0.25,
|
||
quote_offsets_ticks=(0, 1, 2), quote_size_fractions=(0.1, 0.25, 0.5),
|
||
passive_ttl_ms=200, aggressive_ttl_ms=50,
|
||
maker_edge_min_bps=0.5, cross_spread_edge_min_bps=5.0,
|
||
adverse_toxicity_cancel_threshold=0.5, queue_churn_cancel_threshold=0.5,
|
||
mae_tail_cut_bps=50.0, mfe_giveback_cut_fraction=0.5,
|
||
max_time_in_loss_s=300.0, failed_recovery_cut_count=3,
|
||
recovery_velocity_min_bps_per_s=0.0,
|
||
max_symbol_notional_fraction=0.20, max_single_order_notional_fraction=0.05,
|
||
reduce_when_global_up_fraction=0.30, session_profit_lock_fraction=0.02,
|
||
w_expected_pnl=1.0, w_fill_probability=0.5, w_adverse_selection=2.0,
|
||
w_queue_priority=0.5, w_inventory_risk=1.5, w_tail_loss=5.0,
|
||
w_fee_quality=0.5, w_time_decay=0.3, w_policy_entropy=0.5,
|
||
robust_tail_weight=2.0, toxic_counterparty_weight=3.0,
|
||
low_liquidity_weight=2.0, latency_stress_weight=1.0,
|
||
)
|
||
d.update(kw)
|
||
return FulfilmentPolicyParams(**d)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 1. CMA PARAMETER CODEC
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestCMAParameterCodec:
|
||
def test_initial_vector_length(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
assert len(x0) == len(codec.SPECS)
|
||
|
||
def test_bounds_length(self):
|
||
codec = CMAParameterCodec()
|
||
lows, highs = codec.bounds()
|
||
assert len(lows) == len(codec.SPECS)
|
||
assert len(highs) == len(codec.SPECS)
|
||
|
||
def test_bounds_ordered(self):
|
||
codec = CMAParameterCodec()
|
||
lows, highs = codec.bounds()
|
||
assert all(l <= h for l, h in zip(lows, highs))
|
||
|
||
def test_initial_vector_midpoint(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
lows, highs = codec.bounds()
|
||
for v, lo, hi in zip(x0, lows, highs):
|
||
assert lo <= v <= hi
|
||
|
||
def test_decode_returns_params(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
p = codec.decode(x0, "v_test")
|
||
assert isinstance(p, FulfilmentPolicyParams)
|
||
|
||
def test_decode_version_preserved(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
p = codec.decode(x0, "my_version")
|
||
assert p.version == "my_version"
|
||
|
||
def test_decode_int_fields_are_integers(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
p = codec.decode(x0, "int_test")
|
||
assert isinstance(p.max_depth, int)
|
||
assert isinstance(p.passive_ttl_ms, int)
|
||
assert isinstance(p.failed_recovery_cut_count, int)
|
||
|
||
def test_decode_clips_above(self):
|
||
codec = CMAParameterCodec()
|
||
highs = [s.high for s in codec.SPECS]
|
||
x_over = [h + 10.0 for h in highs]
|
||
p = codec.decode(x_over, "over")
|
||
for spec in codec.SPECS:
|
||
val = getattr(p, spec.name)
|
||
if spec.kind == "float":
|
||
assert val <= spec.high + 1e-9
|
||
|
||
def test_decode_clips_below(self):
|
||
codec = CMAParameterCodec()
|
||
lows = [s.low for s in codec.SPECS]
|
||
x_under = [l - 10.0 for l in lows]
|
||
p = codec.decode(x_under, "under")
|
||
for spec in codec.SPECS:
|
||
val = getattr(p, spec.name)
|
||
if spec.kind == "float":
|
||
assert val >= spec.low - 1e-9
|
||
|
||
def test_decode_idempotent(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
p1 = codec.decode(x0, "a")
|
||
p2 = codec.decode(x0, "b")
|
||
assert p1.ucb_c == p2.ucb_c
|
||
assert p1.max_depth == p2.max_depth
|
||
|
||
def test_different_vectors_different_params(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
x1 = list(x0)
|
||
x1[0] += 0.5
|
||
p0 = codec.decode(x0, "a")
|
||
p1 = codec.decode(x1, "b")
|
||
assert p0.ucb_c != p1.ucb_c
|
||
|
||
def test_decode_midpoint_values(self):
|
||
codec = CMAParameterCodec()
|
||
x0 = codec.initial_vector(_baseline())
|
||
p = codec.decode(x0, "mid")
|
||
# ucb_c midpoint = (0.2 + 3.0) / 2 = 1.6
|
||
assert p.ucb_c == pytest.approx(1.6, abs=0.01)
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 2. SELF-PLAY POOL
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestSelfPlayPool:
|
||
def test_add_and_retrieve(self):
|
||
pool = SelfPlayPool(max_size=5)
|
||
snap = PolicySnapshot(
|
||
params=_baseline(version="v1"), score=10.0,
|
||
created_ts_ns=1000, performance_vector=(1.0, 0.5, 0.8, 0.3, 0.1),
|
||
)
|
||
pool.maybe_add(snap)
|
||
assert len(pool.policies()) == 1
|
||
assert pool.policies()[0].version == "v1"
|
||
|
||
def test_evict_lowest_when_full(self):
|
||
pool = SelfPlayPool(max_size=3)
|
||
for i in range(5):
|
||
pool.maybe_add(PolicySnapshot(
|
||
params=_baseline(version=f"v{i}"), score=float(i),
|
||
created_ts_ns=1000 + i,
|
||
performance_vector=(float(i), float(i) * 0.1, 0.5, 0.3, 0.1),
|
||
))
|
||
assert len(pool.policies()) == 3
|
||
# Should keep top 3 by score (diversity eviction keeps diverse ones)
|
||
versions = [s.params.version for s in pool.snapshots]
|
||
assert "v4" in versions # highest score always kept
|
||
|
||
def test_diversity_preservation(self):
|
||
pool = SelfPlayPool(max_size=3)
|
||
# Add 3 diverse policies
|
||
pool.maybe_add(PolicySnapshot(
|
||
params=_baseline(version="a"), score=10.0,
|
||
created_ts_ns=1000, performance_vector=(1.0, 0.0, 0.5, 0.3, 0.1),
|
||
))
|
||
pool.maybe_add(PolicySnapshot(
|
||
params=_baseline(version="b"), score=9.0,
|
||
created_ts_ns=1001, performance_vector=(0.0, 1.0, 0.5, 0.3, 0.1),
|
||
))
|
||
pool.maybe_add(PolicySnapshot(
|
||
params=_baseline(version="c"), score=8.0,
|
||
created_ts_ns=1002, performance_vector=(0.5, 0.5, 0.5, 0.3, 0.1),
|
||
))
|
||
# Add a similar one — should not evict diverse ones
|
||
pool.maybe_add(PolicySnapshot(
|
||
params=_baseline(version="d"), score=7.0,
|
||
created_ts_ns=1003, performance_vector=(1.0, 0.0, 0.5, 0.3, 0.1),
|
||
))
|
||
assert len(pool.policies()) == 3
|
||
|
||
def test_policies_returns_tuple(self):
|
||
pool = SelfPlayPool(max_size=5)
|
||
pool.maybe_add(PolicySnapshot(
|
||
params=_baseline(version="v1"), score=10.0,
|
||
created_ts_ns=1000,
|
||
))
|
||
assert isinstance(pool.policies(), tuple)
|
||
|
||
def test_empty_pool(self):
|
||
pool = SelfPlayPool(max_size=5)
|
||
assert len(pool.policies()) == 0
|
||
|
||
def test_cosine_similarity(self):
|
||
a = (1.0, 0.0, 0.0)
|
||
b = (1.0, 0.0, 0.0)
|
||
assert SelfPlayPool._cosine_similarity(a, b) == pytest.approx(1.0)
|
||
|
||
def test_cosine_orthogonal(self):
|
||
a = (1.0, 0.0, 0.0)
|
||
b = (0.0, 1.0, 0.0)
|
||
assert SelfPlayPool._cosine_similarity(a, b) == pytest.approx(0.0)
|
||
|
||
def test_cosine_empty(self):
|
||
assert SelfPlayPool._cosine_similarity((), ()) == 0.0
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 3. SCENARIO FACTORY
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestScenarioFactory:
|
||
def test_build_suite_returns_scenarios(self):
|
||
factory = ScenarioFactory()
|
||
suite = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=10)
|
||
assert len(suite) >= 30 # 30 real market scenarios per symbol
|
||
|
||
def test_each_scenario_has_unique_id(self):
|
||
factory = ScenarioFactory()
|
||
suite = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=10)
|
||
ids = [s.scenario_id for s in suite]
|
||
assert len(set(ids)) == len(ids)
|
||
|
||
def test_scenarios_have_different_tags(self):
|
||
factory = ScenarioFactory()
|
||
suite = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=10)
|
||
all_tags = set()
|
||
for s in suite:
|
||
all_tags.update(s.tags)
|
||
assert "normal" in all_tags
|
||
assert "thin" in all_tags
|
||
assert "toxic" in all_tags
|
||
|
||
def test_scenario_has_valid_state(self):
|
||
factory = ScenarioFactory()
|
||
suite = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=10)
|
||
for s in suite:
|
||
assert s.initial_state.book.best_bid > 0
|
||
assert s.initial_state.book.best_ask > 0
|
||
assert s.initial_state.account.equity > 0
|
||
|
||
def test_multi_symbol(self):
|
||
factory = ScenarioFactory()
|
||
suite = factory.build_suite(symbols=("BTCUSDT", "ETHUSDT"), steps_per_scenario=5)
|
||
assert len(suite) >= 60 # 30 per symbol × 2 symbols
|
||
|
||
def test_scenario_counterparties(self):
|
||
factory = ScenarioFactory()
|
||
suite = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=10)
|
||
for s in suite:
|
||
assert len(s.counterparties) > 0
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 4. POLICY EVALUATOR
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestPolicyEvaluator:
|
||
def _evaluator(self):
|
||
return PolicyEvaluator(
|
||
cwm_factory=lambda: MinimalCryptoLOBCWM(),
|
||
counterparties=default_counterparty_ecology(),
|
||
)
|
||
|
||
def _scenario(self):
|
||
factory = ScenarioFactory()
|
||
return factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=5)[0]
|
||
|
||
def test_evaluate_returns_score_and_results(self):
|
||
eval_ = self._evaluator()
|
||
scenario = self._scenario()
|
||
score, results = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
assert isinstance(score, float)
|
||
assert len(results) == 1
|
||
assert isinstance(results[0], EpisodeResult)
|
||
|
||
def test_episode_has_steps(self):
|
||
eval_ = self._evaluator()
|
||
scenario = self._scenario()
|
||
_, results = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
assert results[0].steps > 0
|
||
|
||
def test_episode_has_pnl(self):
|
||
eval_ = self._evaluator()
|
||
scenario = self._scenario()
|
||
_, results = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
assert isinstance(results[0].pnl_bps, float)
|
||
|
||
def test_episode_has_drawdown(self):
|
||
eval_ = self._evaluator()
|
||
scenario = self._scenario()
|
||
_, results = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
assert results[0].max_drawdown_bps >= 0
|
||
|
||
def test_episode_has_final_equity(self):
|
||
eval_ = self._evaluator()
|
||
scenario = self._scenario()
|
||
_, results = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
assert results[0].final_equity > 0
|
||
|
||
def test_deterministic_with_same_seed(self):
|
||
eval_ = self._evaluator()
|
||
scenario = self._scenario()
|
||
_, r1 = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
_, r2 = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
assert r1[0].pnl_bps == r2[0].pnl_bps
|
||
|
||
def test_different_seeds_different_results(self):
|
||
eval_ = self._evaluator()
|
||
scenario = self._scenario()
|
||
_, r1 = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=42,
|
||
)
|
||
_, r2 = eval_.evaluate_candidate(
|
||
params=_baseline(version="test"), scenarios=[scenario], rng_seed=99,
|
||
)
|
||
# Different seeds produce different planner distributions (entropy differs)
|
||
assert r1[0].policy_entropy_avg != r2[0].policy_entropy_avg
|
||
|
||
def test_performance_vector(self):
|
||
results = [
|
||
EpisodeResult(scenario_id="s1", policy_version="v1", seed=1, pnl_bps=10.0, max_drawdown_bps=5.0, fill_ratio=0.8, policy_entropy_avg=0.5, order_count=10, cancel_count=2),
|
||
EpisodeResult(scenario_id="s2", policy_version="v1", seed=2, pnl_bps=-5.0, max_drawdown_bps=10.0, fill_ratio=0.6, policy_entropy_avg=0.3, order_count=8, cancel_count=1),
|
||
]
|
||
vec = PolicyEvaluator.performance_vector(results)
|
||
assert len(vec) == 5
|
||
assert vec[0] == pytest.approx(2.5, abs=0.01) # mean PnL
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 5. BOOTSTRAP CI
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestBootstrapCI:
|
||
def test_ci_returns_tuple(self):
|
||
result = bootstrap_ci([1.0, 2.0, 3.0, 4.0, 5.0])
|
||
assert len(result) == 3
|
||
|
||
def test_ci_mean_correct(self):
|
||
scores = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||
mean, lo, hi = bootstrap_ci(scores, n_bootstrap=100)
|
||
assert mean == pytest.approx(3.0, abs=0.01)
|
||
|
||
def test_ci_contains_mean(self):
|
||
scores = [1.0, 2.0, 3.0, 4.0, 5.0]
|
||
mean, lo, hi = bootstrap_ci(scores, n_bootstrap=100)
|
||
assert lo <= mean <= hi
|
||
|
||
def test_ci_wider_with_fewer_samples(self):
|
||
scores = [1.0, 2.0, 3.0]
|
||
_, lo1, hi1 = bootstrap_ci(scores, n_bootstrap=100)
|
||
scores2 = [1.0] * 100
|
||
_, lo2, hi2 = bootstrap_ci(scores2, n_bootstrap=100)
|
||
# More uniform data → tighter CI
|
||
assert (hi1 - lo1) > (hi2 - lo2)
|
||
|
||
def test_ci_empty(self):
|
||
mean, lo, hi = bootstrap_ci([])
|
||
assert mean == 0.0
|
||
|
||
def test_ci_single_value(self):
|
||
mean, lo, hi = bootstrap_ci([5.0])
|
||
assert mean == 5.0
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 6. PROMOTION LOGIC
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestPromotion:
|
||
def _trainer(self):
|
||
codec = CMAParameterCodec()
|
||
evaluator = PolicyEvaluator(
|
||
cwm_factory=lambda: MinimalCryptoLOBCWM(),
|
||
counterparties=default_counterparty_ecology(),
|
||
)
|
||
pool = SelfPlayPool(max_size=5)
|
||
return CMAESTrainer(codec=codec, evaluator=evaluator, pool=pool)
|
||
|
||
def test_promote_better_candidate(self):
|
||
trainer = self._trainer()
|
||
incumbent = PolicySnapshot(
|
||
params=_baseline(version="inc"), score=10.0,
|
||
created_ts_ns=1000, evaluation_summary={"mean_pnl": 5.0, "max_dd": 2.0},
|
||
performance_vector=(5.0, 2.0, 0.8, 0.3, 0.1),
|
||
)
|
||
candidate = PolicySnapshot(
|
||
params=_baseline(version="cand"), score=12.0,
|
||
created_ts_ns=1001, evaluation_summary={"mean_pnl": 7.0, "max_dd": 1.5},
|
||
performance_vector=(7.0, 1.5, 0.9, 0.4, 0.05),
|
||
)
|
||
ok, reason = trainer.promote(candidate, incumbent)
|
||
assert ok
|
||
assert reason == "promoted"
|
||
|
||
def test_reject_worse_candidate(self):
|
||
trainer = self._trainer()
|
||
incumbent = PolicySnapshot(
|
||
params=_baseline(version="inc"), score=10.0,
|
||
created_ts_ns=1000, evaluation_summary={},
|
||
performance_vector=(1.0,),
|
||
)
|
||
candidate = PolicySnapshot(
|
||
params=_baseline(version="cand"), score=9.0,
|
||
created_ts_ns=1001, evaluation_summary={},
|
||
performance_vector=(0.5,),
|
||
)
|
||
ok, reason = trainer.promote(candidate, incumbent)
|
||
assert not ok
|
||
assert reason == "insufficient_edge"
|
||
|
||
def test_reject_no_performance_vector(self):
|
||
trainer = self._trainer()
|
||
incumbent = PolicySnapshot(
|
||
params=_baseline(version="inc"), score=10.0,
|
||
created_ts_ns=1000, evaluation_summary={},
|
||
)
|
||
candidate = PolicySnapshot(
|
||
params=_baseline(version="cand"), score=15.0,
|
||
created_ts_ns=1001, evaluation_summary={},
|
||
)
|
||
ok, reason = trainer.promote(candidate, incumbent)
|
||
assert not ok
|
||
assert reason == "no_performance_vector"
|
||
|
||
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
# 7. CMA-ES TRAINER (end-to-end)
|
||
# ══════════════════════════════════════════════════════════════════════════════
|
||
|
||
class TestCMAESTrainer:
|
||
def test_train_returns_snapshot(self):
|
||
codec = CMAParameterCodec()
|
||
evaluator = PolicyEvaluator(
|
||
cwm_factory=lambda: MinimalCryptoLOBCWM(),
|
||
counterparties=default_counterparty_ecology(),
|
||
)
|
||
pool = SelfPlayPool(max_size=5)
|
||
trainer = CMAESTrainer(codec=codec, evaluator=evaluator, pool=pool)
|
||
|
||
factory = ScenarioFactory()
|
||
scenarios = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=5)
|
||
|
||
result = trainer.train(
|
||
incumbent=_baseline(version="init"),
|
||
scenarios=scenarios,
|
||
budget_evals=14,
|
||
seed=42,
|
||
)
|
||
assert isinstance(result, PolicySnapshot)
|
||
assert result.params is not None
|
||
|
||
def test_pool_grows_during_training(self):
|
||
codec = CMAParameterCodec()
|
||
evaluator = PolicyEvaluator(
|
||
cwm_factory=lambda: MinimalCryptoLOBCWM(),
|
||
counterparties=default_counterparty_ecology(),
|
||
)
|
||
pool = SelfPlayPool(max_size=5)
|
||
trainer = CMAESTrainer(codec=codec, evaluator=evaluator, pool=pool)
|
||
|
||
factory = ScenarioFactory()
|
||
scenarios = factory.build_suite(symbols=("BTCUSDT",), steps_per_scenario=3)
|
||
|
||
trainer.train(
|
||
incumbent=_baseline(version="init"),
|
||
scenarios=scenarios,
|
||
budget_evals=14,
|
||
seed=42,
|
||
)
|
||
assert len(pool.policies()) > 0
|