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.
335 lines
13 KiB
Python
335 lines
13 KiB
Python
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
|