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.
This commit is contained in:
Tobias Zimmermann
2026-08-22 08:53:04 +02:00
commit 65ed73977e
40 changed files with 7033 additions and 0 deletions
+237
View File
@@ -0,0 +1,237 @@
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}