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:
@@ -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}
|
||||
Reference in New Issue
Block a user