Files
sentiment-engine/MALKHUT/malkhut/tests/test_training_exhaustive.py

501 lines
21 KiB
Python
Raw Normal View History

"""
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