65ed73977e
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.
238 lines
8.0 KiB
Python
238 lines
8.0 KiB
Python
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}
|