Files
Trademind/tests/test_derivatives.py
T
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

263 lines
9.1 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.
"""Terminmarktdaten: Zuordnung ohne Blick in die Zukunft, Merkmale, Ausfallverhalten."""
from __future__ import annotations
import numpy as np
import pytest
from trademind.config import Config, DerivativesConfig
from trademind.derivatives import (
DerivativeSeries,
DerivativesProvider,
forward_fill_to_bars,
perpetual_symbol,
)
from trademind.features import (
BASE_FEATURE_NAMES,
build_feature_matrix,
feature_names,
n_features,
)
from trademind.models import Candles
from .conftest import make_candles
BAR = 300_000
# ------------------------------------------------------------------ Symbolik
@pytest.mark.parametrize(
("spot", "perp"),
[
("BTC/USDT", "BTC/USDT:USDT"),
("ETH/USDC", "ETH/USDC:USDC"),
("SOL/USDT:USDT", "SOL/USDT:USDT"), # schon ein Perpetual
],
)
def test_perpetual_symbol(spot: str, perp: str):
assert perpetual_symbol(spot) == perp
# ------------------------------------------------- Zuordnung ohne Lookahead
def test_forward_fill_uses_only_past_values():
bars = np.array([1000, 2000, 3000, 4000], dtype=np.int64)
src_ts = np.array([1500, 3500], dtype=np.int64)
src_val = np.array([10.0, 20.0])
values, known = forward_fill_to_bars(bars, src_ts, src_val)
assert not known[0], "vor dem ersten Quellwert darf nichts bekannt sein"
assert values[1] == 10.0 # 1500 <= 2000
assert values[2] == 10.0 # 3500 liegt in der Zukunft von 3000
assert values[3] == 20.0 # 3500 <= 4000
assert list(known) == [False, True, True, True]
def test_value_exactly_on_the_bar_counts_as_known():
values, known = forward_fill_to_bars(
np.array([2000], dtype=np.int64), np.array([2000], dtype=np.int64), np.array([7.0])
)
assert known[0] and values[0] == 7.0
def test_unsorted_source_is_handled():
values, _ = forward_fill_to_bars(
np.array([5000], dtype=np.int64),
np.array([3000, 1000, 2000], dtype=np.int64),
np.array([30.0, 10.0, 20.0]),
)
assert values[0] == 30.0, "der jüngste Wert vor der Kerze zählt"
def test_empty_source_yields_nothing_known():
values, known = forward_fill_to_bars(
np.arange(3, dtype=np.int64), np.empty(0, np.int64), np.empty(0)
)
assert not known.any()
assert np.isnan(values).all()
# --------------------------------------------------------- Merkmalsanzahl
def test_feature_count_depends_on_configuration():
assert n_features(None) == len(BASE_FEATURE_NAMES) == 18
assert n_features(DerivativesConfig(enabled=False)) == 18
assert n_features(DerivativesConfig(enabled=True)) == 22
assert n_features(DerivativesConfig(enabled=True, open_interest=False)) == 20
assert n_features(DerivativesConfig(enabled=True, funding_rate=False)) == 20
def test_feature_names_are_unique_and_ordered():
names = feature_names(DerivativesConfig(enabled=True))
assert names[:18] == BASE_FEATURE_NAMES
assert len(set(names)) == len(names)
assert names[18:] == ("funding_bps", "funding_trend", "oi_change", "oi_price_divergence")
# ------------------------------------------------------ Merkmalsberechnung
def series_for(candles, funding: float = 0.0001, oi_growth: float = 0.0) -> DerivativeSeries:
n = len(candles)
oi = 100_000.0 * (1.0 + oi_growth * np.arange(n) / max(n - 1, 1))
return DerivativeSeries(
symbol=candles.symbol,
timestamp=candles.timestamp,
funding_rate=np.full(n, funding),
open_interest=oi,
funding_coverage=1.0,
oi_coverage=1.0,
)
def test_matrix_gains_columns_when_enabled(candles, rules):
plain = build_feature_matrix(candles, rules)
enriched = build_feature_matrix(
candles, rules, DerivativesConfig(enabled=True), series_for(candles)
)
assert plain.values.shape[1] == 18
assert enriched.values.shape[1] == 22
assert np.allclose(plain.values, enriched.values[:, :18]), "Basismerkmale dürfen sich nicht ändern"
def test_funding_is_converted_to_basis_points(candles, rules):
matrix = build_feature_matrix(
candles, rules, DerivativesConfig(enabled=True), series_for(candles, funding=0.0003)
)
snapshot = matrix.snapshot(-1).as_dict()
assert snapshot["funding_bps"] == pytest.approx(3.0) # 0,03 % = 3 bps
assert snapshot["funding_trend"] == pytest.approx(0.0, abs=1e-9) # konstant, kein Trend
def test_rising_open_interest_shows_up_as_positive_change(candles, rules):
matrix = build_feature_matrix(
candles, rules, DerivativesConfig(enabled=True), series_for(candles, oi_growth=0.5)
)
assert matrix.snapshot(-1).as_dict()["oi_change"] > 0
def test_divergence_sign_follows_open_interest(rules):
up = make_candles(n=400, trend=0.002, noise=0.0002, seed=3)
rising = build_feature_matrix(
up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=0.5)
).snapshot(-1).as_dict()
falling = build_feature_matrix(
up, rules, DerivativesConfig(enabled=True), series_for(up, oi_growth=-0.3)
).snapshot(-1).as_dict()
# Steigender Kurs mit steigendem OI = neue Positionen, mit fallendem OI = Glattstellung.
assert rising["oi_price_divergence"] > 0
assert falling["oi_price_divergence"] < 0
def test_missing_series_keeps_the_dimension_stable(candles, rules):
"""Fällt die Datenquelle aus, bleiben die Spalten erhalten neutral gefüllt."""
matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), None)
assert matrix.values.shape[1] == 22
assert np.isfinite(matrix.values).all()
snapshot = matrix.snapshot(-1).as_dict()
assert snapshot["funding_bps"] == 0.0
assert snapshot["oi_change"] == 0.0
def test_values_stay_within_the_clip_limit(candles, rules):
"""Auch absurde Terminmarktwerte dürfen die Normierung nicht sprengen."""
n = len(candles)
extreme = DerivativeSeries(
symbol=candles.symbol,
timestamp=candles.timestamp,
funding_rate=np.full(n, 0.75), # 7500 bps
open_interest=np.geomspace(1.0, 1e9, n),
funding_coverage=1.0,
oi_coverage=1.0,
)
matrix = build_feature_matrix(candles, rules, DerivativesConfig(enabled=True), extreme)
assert np.abs(matrix.values).max() <= 8.0
assert np.isfinite(matrix.values).all()
# ---------------------------------------------------------- Ausfallverhalten
class FlakyExchange:
"""Börse, die für Funding funktioniert und bei Open Interest scheitert."""
rateLimit = 0
def __init__(self, funding_rows=None):
self.funding_rows = funding_rows or []
self.oi_calls = 0
async def fetch_funding_rate_history(self, symbol, since=None, limit=None):
rows = [r for r in self.funding_rows if since is None or r["timestamp"] >= since]
return rows[:limit] if limit else rows
async def fetch_open_interest_history(self, symbol, timeframe, since=None, limit=None):
self.oi_calls += 1
raise RuntimeError("startTime is invalid")
async def close(self):
return None
async def test_failing_source_is_recorded_not_raised(candles):
start = int(candles.timestamp[0])
rows = [{"timestamp": start - 3_600_000, "fundingRate": 0.0002}]
provider = DerivativesProvider(FlakyExchange(rows), DerivativesConfig(enabled=True))
series = await provider.series_for(candles)
assert series.funding_coverage == 1.0
assert series.oi_coverage == 0.0
assert any("open_interest" in key for key in provider.failures)
assert provider.snapshot()["failures"]
async def test_provider_returns_empty_series_for_empty_candles():
blank = Candles.from_rows("BTC/USDT", "5m", [])
provider = DerivativesProvider(FlakyExchange(), DerivativesConfig(enabled=True))
series = await provider.series_for(blank)
assert series.timestamp.size == 0
assert not series.usable
async def test_incremental_fetch_does_not_refetch_everything(candles):
start = int(candles.timestamp[0])
rows = [{"timestamp": start - 3_600_000 + i * 8 * 3_600_000, "fundingRate": 0.0001}
for i in range(4)]
class Counting(FlakyExchange):
def __init__(self, rows):
super().__init__(rows)
self.funding_calls = 0
async def fetch_funding_rate_history(self, symbol, since=None, limit=None):
self.funding_calls += 1
return await super().fetch_funding_rate_history(symbol, since, limit)
exchange = Counting(rows)
provider = DerivativesProvider(exchange, DerivativesConfig(enabled=True, open_interest=False))
await provider.series_for(candles)
first = exchange.funding_calls
await provider.series_for(candles)
assert exchange.funding_calls - first <= 1, "der zweite Lauf darf nur nachladen"
# -------------------------------------------------------------- Konfiguration
def test_derivatives_switches_require_a_restart():
from trademind.config import requires_restart
for path in ("strategy.derivatives.enabled", "strategy.derivatives.funding_rate",
"strategy.derivatives.open_interest"):
assert requires_restart(path), f"{path} ändert die Modelldimension"
def test_derivatives_are_off_by_default():
assert Config().strategy.derivatives.enabled is False