d36ed142cf
Drei Erweiterungen aus der vorangegangenen Analyse. Lerngeschwindigkeit - background_sample_every_n_bars von 10 auf 5. Gemessen stammen nur rund 3 % der Beobachtungen aus echten Trades; diese Stichproben sind der wirksamste Hebel. Nicht weiter gesenkt, weil benachbarte Kerzen stark korreliert sind und sich die Label-Fenster überlappen - mehr Gradientenschritte heißt dort nicht mehr Information. Funding Rate und Open Interest als Merkmale (strategy.derivatives, standardmäßig aus) - Vier zusätzliche Merkmale vom Perpetual zum jeweiligen Spot-Paar. Gehandelt wird weiterhin Spot, die Kennzahlen kommen über einen zweiten ccxt-Client mit defaultType=future. - Die Zuordnung ist lookahead-frei: Für jede Kerze gilt nur der Wert, der zu diesem Zeitpunkt bereits veröffentlicht war. - Fällt eine Quelle aus oder deckt sie weniger als min_coverage ab, bleiben die Spalten neutral. Die Modelldimension bleibt dabei stabil. - Gemessene API-Grenzen bei Binance: Open Interest reicht 30 Tage zurück, 500 Zeilen je Abruf; Funding Rate über ein Jahr. Beide Merkmale sind deshalb einzeln abschaltbar. ERGEBNIS: kein Nutzen. Zwei Backtests mit identischen Kerzen und Seed - 5m/20 Tage: Rendite -1,79 % auf -1,90 %, Accuracy 51,2 % auf 50,4 %; 15m/28 Tage: Rendite -2,22 % auf -2,67 %, LogLoss praktisch unverändert. Die Anbindung arbeitet einwandfrei (100 % Datenabdeckung), das Modell gewichtet die neuen Merkmale aber nur mit 0,01 bis 0,09 gegenüber 0,39 für ema_spread. Die Funktion bleibt aus und ist dafür da, das auf anderen Zeiträumen selbst zu prüfen - nicht weil sie sich bewährt hätte. Ollama-Erklärungen (llm, standardmäßig aus) - Neuer Dashboard-Bereich und POST /control/explain. Das Modell bekommt den Zustand als Text und gibt Text zurück; es entscheidet nichts, beeinflusst keine Order und wird nie aus dem Handels-Loop heraus aufgerufen. Der System-Prompt untersagt Anlageempfehlungen und Kursprognosen. - Bewusst nicht als Entscheider: nicht reproduzierbar, kaum backtestbar, und es würde die Nachvollziehbarkeit des linearen Modells zerstören. - Beim Test an qwen3.8:27b zeigte sich ein echter Fehler: Reasoning-Modelle legen ihre Denkschritte in ein eigenes Antwortfeld und verbrauchten dafür das gesamte Token-Budget, response blieb leer. llm.think ist jetzt standardmäßig false, die Fehlermeldung nennt Ursache und Ausweg statt nur "leere Antwort", und für ältere Ollama-Versionen ohne das Feld gibt es einen Wiederholungsversuch ohne es. Bewusst nicht enthalten: News- und Google-Trends-Sentiment. Es fehlt eine Quelle mit Point-in-Time-Historie; ohne die lässt sich das Merkmal nicht backtesten. Nach dem Ergebnis oben wäre ein unvalidiertes Merkmal der falsche Schritt. test_stop_loss_bounds_the_worst_trade läuft jetzt mit strategy.name: rules. Er hing über die geänderte Voreinstellung an zufälligem Modellverhalten, geprüft werden soll aber die Stop-Logik. 270 Tests (36 neue), ruff sauber. Gegen echte Binance-Daten und eine laufende Ollama-Instanz geprüft, Dashboard-Bereich im Browser bedient.
340 lines
13 KiB
Python
340 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):
|
||
# 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
|