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): # Bewusst ohne Lernmodell: Geprüft wird die Stop-Logik, nicht welche Signale das # Modell gerade durchlässt. Mit "adaptive" hinge das Ergebnis daran, wie weit das # Modell aufgewärmt ist – das hat mit Stops nichts zu tun. config = Config.model_validate( {**base_config.model_dump(), "strategy": {"name": "rules"}, "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