Files
Trademind/tests/test_engine.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

335 lines
13 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.backtest import BacktestRunner, trades_csv
from trademind.broker import PaperBroker
from trademind.config import Config
from trademind.data import DataFeed
from trademind.engine import TradingEngine
from trademind.features import N_FEATURES
from trademind.models import Candles, ExitReason, Side
from trademind.portfolio import Portfolio
from trademind.risk import RiskManager
from trademind.storage import NullStorage, Storage
from trademind.strategy import build_strategy
from .conftest import make_candles
class StaticFeed(DataFeed):
"""Liefert immer dasselbe Fenster für Tests, die den Feed nicht brauchen."""
def __init__(self, candles: Candles | None = None) -> None:
self.candles = candles
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
if self.candles is None:
raise AssertionError("Feed sollte in diesem Test nicht abgefragt werden")
return self.candles
class GrowingFeed(DataFeed):
"""Gibt bei jedem Abruf ein um eine Kerze längeres Fenster zurück (simuliert Live-Betrieb)."""
def __init__(self, candles: Candles, start: int) -> None:
self.full = candles
self.cursor = start
self.calls = 0
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
self.calls += 1
stop = min(self.cursor, len(self.full))
start = max(0, stop - limit)
return self.full.slice(start, stop)
def advance(self) -> None:
self.cursor += 1
def build_engine(config: Config, feed: DataFeed | None = None, storage=None) -> TradingEngine:
broker = PaperBroker(config.paper)
strategy = build_strategy(config.strategy, N_FEATURES, seed=3, load_model=False)
learner = getattr(strategy, "learner", None)
if learner is not None:
learner.autosave = False
return TradingEngine(
config=config,
broker=broker,
feed=feed or StaticFeed(),
strategy=strategy,
portfolio=Portfolio(config.paper.starting_balance, config.paper.quote_currency),
risk=RiskManager(config.risk),
storage=storage or NullStorage(),
)
def cyclical_series(symbol: str = "BTC/USDT", n: int = 900, seed: int = 4) -> Candles:
"""Schwingender Verlauf erzeugt zuverlässig Ein- und Ausstiegssignale."""
return make_candles(symbol=symbol, n=n, trend=0.0001, noise=0.0015, cycle=0.0025,
cycle_period=60, seed=seed)
# ------------------------------------------------------------------ Backtest
async def test_backtest_runs_and_produces_trades(base_config):
engine = build_engine(base_config)
await engine.prepare()
report = await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert report.bars > 0
assert report.portfolio["trades"] > 0
assert engine.portfolio.positions == {} # am Ende glattgestellt
assert report.portfolio["equity"] > 0
async def test_cash_and_equity_stay_consistent(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
cash = await engine.broker.cash()
realized = sum(t.pnl_quote for t in engine.portfolio.trades)
assert cash == pytest.approx(base_config.paper.starting_balance + realized, abs=1e-6)
assert cash >= 0.0
async def test_stop_loss_bounds_the_worst_trade(base_config):
config = Config.model_validate(
{**base_config.model_dump(), "risk": {**base_config.risk.model_dump(),
"stop_loss_atr_mult": 1.0,
"take_profit_atr_mult": 10.0}}
)
engine = build_engine(config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
stopped = [t for t in engine.portfolio.trades if t.exit_reason is ExitReason.STOP_LOSS]
assert stopped, "Bei engem Stop sollten Stop-Ausstiege vorkommen"
for trade in stopped:
assert trade.pnl_pct > -0.25 # ein Stop begrenzt den Verlust deutlich
async def test_position_limit_is_never_exceeded(base_config):
config = Config.model_validate(
{
**base_config.model_dump(),
"market": {**base_config.market.model_dump(), "symbols": ["BTC/USDT", "ETH/USDT", "SOL/USDT"]},
"risk": {**base_config.risk.model_dump(), "max_open_positions": 2},
}
)
engine = build_engine(config)
await engine.prepare()
observed_max = 0
original = engine.process_bar
async def spy(symbol, snapshot, bar):
nonlocal observed_max
await original(symbol, snapshot, bar)
observed_max = max(observed_max, len(engine.portfolio.positions))
engine.process_bar = spy # type: ignore[method-assign]
series = {
"BTC/USDT": cyclical_series("BTC/USDT", seed=4),
"ETH/USDT": cyclical_series("ETH/USDT", seed=5),
"SOL/USDT": cyclical_series("SOL/USDT", seed=6),
}
await BacktestRunner(engine, series, progress_every=0).run()
assert observed_max <= 2
async def test_learner_collects_samples_during_a_backtest(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
learner = engine.strategy.learner
assert learner.stats.samples_seen > 0
assert learner.stats.trade_samples > 0 # aus echten Trades gelernt
assert learner.stats.shadow_samples > 0 # und aus nicht gehandelten Signalen
assert learner.stats.updates > 0
async def test_rules_strategy_needs_no_learner(base_config):
config = Config.model_validate({**base_config.model_dump(), "strategy": {"name": "rules"}})
engine = build_engine(config)
await engine.prepare()
report = await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert report.strategy == {"strategy": "rules"}
async def test_higher_threshold_trades_less(base_config):
def run_config(threshold: float) -> Config:
learner = {**base_config.strategy.learner.model_dump(),
"entry_threshold": threshold, "exploration_rate": 0.0, "warmup_samples": 30}
return Config.model_validate(
{**base_config.model_dump(),
"strategy": {**base_config.strategy.model_dump(), "learner": learner}}
)
rates = []
for threshold in (0.0, 0.95):
engine = build_engine(run_config(threshold))
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert engine.strategy.candidates_seen >= 10, "zu wenige Signale für einen Vergleich"
rates.append(engine.strategy.candidates_accepted / engine.strategy.candidates_seen)
assert rates[0] == pytest.approx(1.0) # Schwelle 0 lässt alles durch
assert rates[1] < rates[0] # hohe Schwelle filtert
async def test_trades_csv_has_one_row_per_trade(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
lines = trades_csv(engine).strip().splitlines()
assert len(lines) == len(engine.portfolio.trades) + 1
async def test_short_series_is_rejected(base_config):
engine = build_engine(base_config)
await engine.prepare()
with pytest.raises(ValueError, match="genug Kerzen"):
await BacktestRunner(engine, {"BTC/USDT": make_candles(n=50)}, progress_every=0).run()
# ----------------------------------------------------------- Live-artiger Loop
async def test_tick_only_acts_on_new_bars(base_config):
series = cyclical_series(n=400)
feed = GrowingFeed(series, start=300)
engine = build_engine(base_config, feed=feed)
await engine.prepare()
await engine._tick(300)
first = dict(engine.bar_counter)
await engine._tick(300) # keine neue Kerze
assert engine.bar_counter == first
feed.advance()
await engine._tick(300)
assert engine.bar_counter["BTC/USDT"] == first["BTC/USDT"] + 1
async def test_bootstrap_trains_the_model_from_history(base_config):
"""Ein Kaltstart muss das Modell aus der Historie vorlernen, nicht tagelang warten."""
series = cyclical_series(n=900)
engine = build_engine(base_config, feed=GrowingFeed(series, start=900))
await engine.prepare()
assert engine.strategy.learner.ready is False
await engine.bootstrap_learner()
learner = engine.strategy.learner
assert learner.stats.samples_seen > base_config.strategy.learner.warmup_samples
assert learner.ready is True
assert engine.portfolio.trades == [] # Vorlernen handelt nicht
assert engine.bar_counter["BTC/USDT"] > 0 # Zähler schließt an die Historie an
assert engine.last_bar_ts["BTC/USDT"] == int(series.timestamp[engine.bar_counter["BTC/USDT"]])
async def test_bootstrap_is_skipped_for_a_trained_model(base_config):
engine = build_engine(base_config, feed=GrowingFeed(cyclical_series(n=900), start=900))
await engine.prepare()
for _ in range(base_config.strategy.learner.warmup_samples):
engine.strategy.learner.observe(np.zeros(N_FEATURES), 1.0)
seen = engine.strategy.learner.stats.samples_seen
await engine.bootstrap_learner()
assert engine.strategy.learner.stats.samples_seen == seen
async def test_bootstrap_survives_a_broken_feed(base_config):
class BrokenFeed(DataFeed):
async def fetch(self, symbol, timeframe, limit):
raise RuntimeError("Börse nicht erreichbar")
engine = build_engine(base_config, feed=BrokenFeed())
await engine.prepare()
await engine.bootstrap_learner() # darf nicht werfen
assert engine.strategy.learner.ready is False
async def test_tick_waits_for_enough_history(base_config):
feed = GrowingFeed(cyclical_series(n=400), start=60)
engine = build_engine(base_config, feed=feed)
await engine.prepare()
await engine._tick(300)
assert engine.bar_counter["BTC/USDT"] == 0 # Indikatoren noch nicht warm
# --------------------------------------------------------------- Persistenz
async def test_state_survives_a_restart(base_config, tmp_path):
storage = Storage(tmp_path / "state.sqlite3")
config = Config.model_validate({**base_config.model_dump(), "mode": "paper"})
engine = build_engine(config, feed=StaticFeed(), storage=storage)
await engine.prepare()
# Position künstlich eröffnen und Zustand sichern.
fill = await engine.broker.execute("BTC/USDT", Side.BUY, 0.05, 30_000.0)
engine.portfolio.open_position(
fill, stop_loss=29_000.0, take_profit=32_000.0,
features=np.ones(N_FEATURES), confidence=0.7, exploratory=False,
)
engine._cash = await engine.broker.cash()
engine._persist_state()
revived = build_engine(config, feed=StaticFeed(), storage=storage)
await revived.prepare()
assert "BTC/USDT" in revived.portfolio.positions
restored = revived.portfolio.positions["BTC/USDT"]
assert restored.amount == pytest.approx(0.05)
assert restored.stop_loss == pytest.approx(29_000.0)
assert restored.entry_features is not None
assert await revived.broker.cash() == pytest.approx(await engine.broker.cash())
storage.close()
async def test_state_from_another_mode_is_ignored(base_config, tmp_path):
storage = Storage(tmp_path / "state.sqlite3")
paper = Config.model_validate({**base_config.model_dump(), "mode": "paper"})
engine = build_engine(paper, storage=storage)
await engine.prepare()
engine._persist_state()
backtest = Config.model_validate({**base_config.model_dump(), "mode": "backtest"})
other = build_engine(backtest, storage=storage)
await other.prepare()
assert other.portfolio.positions == {}
storage.close()
async def test_trades_are_written_to_the_database(base_config, tmp_path):
storage = Storage(tmp_path / "trades.sqlite3")
engine = build_engine(base_config, storage=storage)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series()}, progress_every=0).run()
assert storage.trade_count() == len(engine.portfolio.trades)
per_symbol = storage.performance_by_symbol()
assert per_symbol and per_symbol[0]["symbol"] == "BTC/USDT"
storage.close()
# ------------------------------------------------------------------- Status
async def test_status_payload_is_serialisable(base_config):
engine = build_engine(base_config)
await engine.prepare()
await BacktestRunner(engine, {"BTC/USDT": cyclical_series(n=400)}, progress_every=0).run()
import json
status = engine.status()
json.dumps(status, default=str) # darf nicht werfen
assert status["mode"] == "backtest"
assert "portfolio" in status and "strategy" in status
assert len(status["feature_weights"]) == N_FEATURES