Files
Tobias Zimmermann d36ed142cf Terminmarktmerkmale, Ollama-Erklärungen, schnelleres Lernen
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.
2026-08-23 14:46:24 +02:00

340 lines
13 KiB
Python
Raw Permalink 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):
# 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