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.
This commit is contained in:
Tobias Zimmermann
2026-08-23 14:46:24 +02:00
parent db31bc93f7
commit d36ed142cf
13 changed files with 1403 additions and 23 deletions
+262
View File
@@ -0,0 +1,262 @@
"""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