import numpy as np import pytest from trademind.config import LearnerConfig from trademind.learner import ( AdaptiveLearner, NullLearner, OnlineLogisticRegression, ReplayBuffer, RunningScaler, sigmoid, ) def make_learner(tmp_path, **overrides) -> AdaptiveLearner: config = LearnerConfig( model_path=str(tmp_path / "model.npz"), warmup_samples=overrides.pop("warmup_samples", 20), batch_size=overrides.pop("batch_size", 32), train_every_n_samples=overrides.pop("train_every_n_samples", 1), learning_rate=overrides.pop("learning_rate", 0.05), **overrides, ) return AdaptiveLearner(config, n_features=4, seed=1) # ---------------------------------------------------------------- Bausteine def test_sigmoid_is_bounded_and_stable(): assert sigmoid(0.0) == pytest.approx(0.5) assert 0.0 < float(sigmoid(-1000.0)) < 1e-10 assert float(sigmoid(1000.0)) > 1 - 1e-10 def test_running_scaler_matches_numpy(): rng = np.random.default_rng(0) data = rng.normal(5.0, 3.0, size=(500, 4)) scaler = RunningScaler(4) for row in data: scaler.update(row) assert np.allclose(scaler.mean, data.mean(axis=0), atol=1e-9) assert np.allclose(scaler.std, data.std(axis=0, ddof=1), atol=1e-9) def test_scaler_clips_outliers(): scaler = RunningScaler(2) for value in np.random.default_rng(1).normal(0, 1, size=(200, 2)): scaler.update(value) scaled = scaler.transform(np.array([[1e6, -1e6]])) assert np.abs(scaled).max() <= 6.0 def test_replay_buffer_is_a_ring(): buffer = ReplayBuffer(3, 2, np.random.default_rng(0)) for i in range(5): buffer.add(np.array([i, i]), float(i % 2), 1.0) assert len(buffer) == 3 x, y, w = buffer.sample(3) assert x.shape == (3, 2) assert set(np.unique(x[:, 0])).issubset({2.0, 3.0, 4.0}) # nur die letzten drei def test_logistic_regression_learns_a_separable_problem(): rng = np.random.default_rng(0) model = OnlineLogisticRegression(2, learning_rate=0.1) x = rng.normal(0, 1, size=(400, 2)) y = (x[:, 0] + x[:, 1] > 0).astype(float) for _ in range(60): model.partial_fit(x, y) predictions = model.predict_proba(x) >= 0.5 assert (predictions == (y > 0.5)).mean() > 0.9 # -------------------------------------------------------------- Lernverhalten def test_learner_is_not_ready_before_warmup(tmp_path): learner = make_learner(tmp_path, warmup_samples=10) assert learner.ready is False for _ in range(10): learner.observe(np.zeros(4), 1.0) assert learner.ready is True def test_learner_separates_good_from_bad_setups(tmp_path): """Feature 0 entscheidet über den Ausgang – das muss das Modell finden.""" learner = make_learner(tmp_path, warmup_samples=10, learning_rate=0.1) rng = np.random.default_rng(3) for _ in range(800): good = rng.random() < 0.5 features = np.array([1.0 if good else -1.0, *rng.normal(0, 0.5, 3)]) learner.observe(features, 1.0 if good else 0.0) good_score = learner.score(np.array([1.0, 0.0, 0.0, 0.0])) bad_score = learner.score(np.array([-1.0, 0.0, 0.0, 0.0])) assert good_score > 0.7 assert bad_score < 0.3 assert learner.stats.accuracy > 0.8 def test_real_trades_are_weighted_higher(tmp_path): learner = make_learner(tmp_path) learner.learn_from_trade(np.array([1.0, 0.0, 0.0, 0.0]), pnl_quote=12.5) assert learner.stats.trade_samples == 1 assert learner.stats.shadow_samples == 0 assert learner.buffer.w[0] == pytest.approx(learner.config.trade_sample_weight) def test_trade_without_features_is_ignored(tmp_path): learner = make_learner(tmp_path) learner.learn_from_trade(None, pnl_quote=1.0) assert learner.stats.samples_seen == 0 def test_wrong_feature_length_is_dropped(tmp_path): learner = make_learner(tmp_path) learner.observe(np.zeros(9), 1.0) assert learner.stats.samples_seen == 0 def test_frozen_learner_scores_but_does_not_train(tmp_path): learner = make_learner(tmp_path) learner.frozen = True before = learner.model.w.copy() for _ in range(50): learner.observe(np.array([1.0, 0.0, 0.0, 0.0]), 1.0) assert np.allclose(learner.model.w, before) assert learner.stats.samples_seen == 50 # Beobachtungen werden trotzdem gesammelt # --------------------------------------------------- Verzögerte Shadow-Labels def test_pending_label_resolves_on_target_hit(tmp_path): learner = make_learner(tmp_path) learner.config.label_target_bps = 100.0 # 1 % learner.register_candidate("BTC/USDT", np.ones(4), price=100.0, bar_index=0) assert learner.pending_count == 1 resolved = learner.resolve_pending("BTC/USDT", 1, high=101.5, low=99.9, close=101.0) assert resolved == 1 assert learner.pending_count == 0 assert learner.buffer.y[0] == 1.0 def test_pending_label_resolves_on_stop_hit(tmp_path): learner = make_learner(tmp_path) learner.config.label_target_bps = 100.0 learner.register_candidate("BTC/USDT", np.ones(4), price=100.0, bar_index=0) learner.resolve_pending("BTC/USDT", 1, high=100.2, low=98.5, close=98.7) assert learner.buffer.y[0] == 0.0 def test_pending_label_expires_after_horizon(tmp_path): learner = make_learner(tmp_path) learner.config.label_horizon_bars = 3 learner.config.label_target_bps = 500.0 # wird nicht erreicht learner.register_candidate("BTC/USDT", np.ones(4), price=100.0, bar_index=0) for bar in range(1, 3): learner.resolve_pending("BTC/USDT", bar, 100.1, 99.9, 100.05) assert learner.pending_count == 1 learner.resolve_pending("BTC/USDT", 3, 100.1, 99.9, 100.05) assert learner.pending_count == 0 assert learner.buffer.y[0] == 1.0 # Schluss über dem Einstieg def test_pending_labels_are_kept_per_symbol(tmp_path): learner = make_learner(tmp_path) learner.register_candidate("BTC/USDT", np.ones(4), 100.0, 0) learner.register_candidate("ETH/USDT", np.ones(4), 100.0, 0) learner.resolve_pending("BTC/USDT", 1, 200.0, 199.0, 199.5) assert learner.pending_count == 1 # ETH bleibt offen # ------------------------------------------------------------- Persistenz def test_save_and_load_round_trip(tmp_path): learner = make_learner(tmp_path) rng = np.random.default_rng(5) for _ in range(200): features = rng.normal(0, 1, 4) learner.observe(features, 1.0 if features[0] > 0 else 0.0) probe = np.array([0.7, -0.2, 0.1, 0.4]) expected = learner.score(probe) path = learner.save() assert path.is_file() restored = make_learner(tmp_path) assert restored.load() is True assert restored.score(probe) == pytest.approx(expected) assert restored.stats.samples_seen == learner.stats.samples_seen assert len(restored.buffer) == len(learner.buffer) def test_load_without_file_returns_false(tmp_path): assert make_learner(tmp_path).load() is False def test_model_with_wrong_feature_count_is_ignored(tmp_path): learner = make_learner(tmp_path) learner.observe(np.zeros(4), 1.0) learner.save() other = AdaptiveLearner( LearnerConfig(model_path=str(tmp_path / "model.npz")), n_features=9, seed=1 ) assert other.load() is False def test_corrupt_model_file_is_tolerated(tmp_path): path = tmp_path / "model.npz" path.write_bytes(b"kein gueltiges npz") assert make_learner(tmp_path).load() is False def test_autosave_can_be_disabled(tmp_path): learner = make_learner(tmp_path) learner.autosave = False learner.config.save_every_n_updates = 1 for _ in range(50): learner.observe(np.ones(4), 1.0) learner.maybe_save() assert not (tmp_path / "model.npz").exists() # ------------------------------------------------------------- NullLearner def test_null_learner_accepts_everything(): learner = NullLearner() assert learner.ready is True assert learner.score(np.zeros(3)) == 1.0 assert learner.explore() is False learner.observe(np.zeros(3), 1.0) assert learner.snapshot() == {"enabled": False}