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
+81 -5
View File
@@ -13,11 +13,11 @@ from dataclasses import dataclass
import numpy as np
from .config import RuleConfig
from .config import DerivativesConfig, RuleConfig
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
from .models import Candles
FEATURE_NAMES: tuple[str, ...] = (
BASE_FEATURE_NAMES: tuple[str, ...] = (
"ema_spread", # (EMA_fast - EMA_slow) / Preis [%]
"ema_fast_dist", # (Preis - EMA_fast) / Preis [%]
"trend_dist", # (Preis - EMA_trend) / Preis [%]
@@ -38,11 +38,38 @@ FEATURE_NAMES: tuple[str, ...] = (
"time_cos",
)
N_FEATURES = len(FEATURE_NAMES)
# Zusatzmerkmale aus dem Terminmarkt nur aktiv, wenn strategy.derivatives.enabled.
FUNDING_FEATURE_NAMES: tuple[str, ...] = (
"funding_bps", # Funding Rate der laufenden Periode, in Basispunkten
"funding_trend", # Abweichung vom Mittel der letzten Perioden
)
OI_FEATURE_NAMES: tuple[str, ...] = (
"oi_change", # Veränderung des Open Interest über oi_change_bars [%]
"oi_price_divergence", # OI-Veränderung × Kursrichtung: neue Positionen oder Glattstellung
)
# Rückwärtskompatibler Name für die Basisausstattung.
FEATURE_NAMES = BASE_FEATURE_NAMES
N_FEATURES = len(BASE_FEATURE_NAMES)
MIN_BARS = 140
_CLIP_LIMIT = 8.0
def feature_names(derivatives: DerivativesConfig | None = None) -> tuple[str, ...]:
"""Merkmalsnamen für die gegebene Konfiguration die Anzahl hängt davon ab."""
names = BASE_FEATURE_NAMES
if derivatives is not None and derivatives.enabled:
if derivatives.funding_rate:
names += FUNDING_FEATURE_NAMES
if derivatives.open_interest:
names += OI_FEATURE_NAMES
return names
def n_features(derivatives: DerivativesConfig | None = None) -> int:
return len(feature_names(derivatives))
@dataclass(slots=True)
class FeatureSnapshot:
"""Merkmalsvektor plus Roh-Kennzahlen, die Risiko und Regelwerk zusätzlich brauchen."""
@@ -96,6 +123,7 @@ class FeatureMatrix:
ema_slow: np.ndarray
trend_ema: np.ndarray
first_valid: int # ab hier sind die Zeilen belastbar
names: tuple[str, ...] = BASE_FEATURE_NAMES
def __len__(self) -> int:
return int(self.values.shape[0])
@@ -112,7 +140,7 @@ class FeatureMatrix:
prev = max(idx - 1, 0)
return FeatureSnapshot(
values=self.values[idx].copy(),
names=FEATURE_NAMES,
names=self.names,
index=idx,
price=float(self.price[idx]),
atr=float(self.atr[idx]),
@@ -127,9 +155,54 @@ class FeatureMatrix:
)
def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix | None:
def _derivative_columns(
close: np.ndarray, series, config: DerivativesConfig
) -> list[np.ndarray]:
"""Spalten aus Funding Rate und Open Interest, in derselben Reihenfolge wie die Namen."""
columns: list[np.ndarray] = []
n = close.size
if config.funding_rate:
rate = np.asarray(getattr(series, "funding_rate", None), dtype=np.float64) \
if series is not None else np.full(n, np.nan)
rate = _clean(rate, 0.0)
# Anteil je 8h → Basispunkte. Typisch ±1 bp, in Extremphasen ±10 bp.
funding_bps = rate * 10_000.0
# Abweichung vom gleitenden Mittel der letzten Perioden: Zuspitzung oder Entspannung.
baseline = _clean(sma(funding_bps, 24), 0.0)
columns += [funding_bps, funding_bps - baseline]
if config.open_interest:
oi = np.asarray(getattr(series, "open_interest", None), dtype=np.float64) \
if series is not None else np.full(n, np.nan)
oi = _clean(oi, 0.0)
lag = min(config.oi_change_bars, max(n - 1, 1))
previous = np.concatenate([np.full(min(lag, n), oi[0] if n else 0.0), oi[:-lag]])[:n]
with np.errstate(divide="ignore", invalid="ignore"):
oi_change = np.where(previous > 0, (oi - previous) / previous * 100.0, 0.0)
prev_close = np.concatenate([np.full(min(lag, n), close[0] if n else 0.0), close[:-lag]])[:n]
with np.errstate(divide="ignore", invalid="ignore"):
price_change = np.where(prev_close > 0, (close - prev_close) / prev_close * 100.0, 0.0)
# Gleiches Vorzeichen = frisches Geld in Richtung des Trends, Gegenzeichen =
# Glattstellungen. Das Produkt fasst beides in einer Zahl zusammen.
divergence = np.sign(oi_change) * np.abs(price_change)
columns += [_clean(oi_change), _clean(divergence)]
return columns
def build_feature_matrix(
candles: Candles,
rules: RuleConfig,
derivatives: DerivativesConfig | None = None,
series=None,
) -> FeatureMatrix | None:
"""Berechnet Indikatoren und Merkmalsvektoren für die gesamte Serie.
``series`` ist eine :class:`~trademind.derivatives.DerivativeSeries` passend zu den
Kerzen; fehlt sie bei aktivierten Terminmarktmerkmalen, werden die Spalten neutral
gefüllt, damit die Modelldimension gleich bleibt.
Gibt ``None`` zurück, wenn die Historie kürzer als ``required_bars`` ist.
"""
n = len(candles)
@@ -201,6 +274,8 @@ def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix |
np.sin(angle),
np.cos(angle),
]
if derivatives is not None and derivatives.enabled:
columns += _derivative_columns(close, series, derivatives)
values = np.column_stack([_clean(col) for col in columns])
np.clip(values, -_CLIP_LIMIT, _CLIP_LIMIT, out=values)
@@ -214,6 +289,7 @@ def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix |
ema_slow=_clean(ema_slow, close),
trend_ema=_clean(trend_ema, close),
first_valid=need - 1,
names=feature_names(derivatives),
)