Files
Trademind/tests/test_learner.py
T
Tobias Zimmermann 65ed73977e Initial commit: TradeMind – Krypto-Trading-Bot mit Lernmodus
Per Podman deploybarer Bot, der Käufe und Verkäufe simuliert ausführt und sich
aus den Ergebnissen weiter antrainiert.

Aufbau
- Einheitliche Bar-Verarbeitung für paper, backtest und live; ausgetauscht werden
  nur Datenquelle und Broker.
- Börsenanbindung über ccxt: rund 100 Börsen allein über exchange.id erreichbar.
  Zugangsdaten kommen über ENV-Platzhalter, der Live-Modus ist doppelt abgesichert.
- Paper-Broker mit Gebühren, Slippage, Börsenpräzision und Volumengrenzen.
- Online trainierte logistische Regression bewertet jedes Einstiegssignal. Sie lernt
  aus realen Trade-Ergebnissen, aus Shadow-Labels aller Kandidaten – auch der
  abgelehnten – und aus Hintergrund-Stichproben; beim Kaltstart wird sie aus der
  Kurshistorie vorgelernt.
- Risikomanagement: Positions- und Exposure-Grenzen, ATR-Stops, Cooldown sowie
  Tagesverlust- und Drawdown-Notbremsen.
- SQLite-Persistenz, HTTP-Status mit Prometheus-Metriken und Dashboard, Webhooks.

Deployment
- Containerfile (zweistufig, non-root UID 10001), podman-compose, systemd-Quadlet.
- Modell und Datenbank liegen im Volume /data und überleben Neustarts.

128 Tests, ruff sauber. Verifiziert gegen echte Marktdaten sowie im gebauten
Container inklusive Healthcheck und Zustandswiederherstellung.
2026-08-22 08:53:04 +02:00

238 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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}