Files
Trademind/src/trademind/features.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

302 lines
11 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.
"""Feature-Engineering: aus einer Kerzenserie normierte Merkmalsvektoren bauen.
Alle Features sind bewusst skalenfrei (Verhältnisse, Prozentwerte, z-Scores), damit ein
Modell über verschiedene Symbole und Preisniveaus hinweg lernen kann.
Die Indikatoren werden einmal über die gesamte Serie berechnet (``build_feature_matrix``);
Backtests laufen dadurch linear statt quadratisch.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .config import DerivativesConfig, RuleConfig
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
from .models import Candles
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 [%]
"rsi_norm", # (RSI - 50) / 50
"rsi_slope", # RSI-Änderung über 3 Bars / 50
"macd_hist", # MACD-Histogramm / Preis [%]
"macd_hist_slope",
"atr_pct", # ATR / Preis [%]
"vol_ratio", # kurzfristige vs. langfristige Kursvolatilität
"roc_3",
"roc_12",
"donchian_pos", # Lage in der 20-Bar-Range, zentriert auf 0
"volume_z", # z-Score des Volumens
"body_ratio", # Kerzenkörper / Range
"upper_wick",
"lower_wick",
"time_sin", # zyklische Tageszeit
"time_cos",
)
# 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."""
values: np.ndarray
names: tuple[str, ...]
index: int
price: float
atr: float
rsi: float
rsi_prev: float
ema_fast: float
ema_slow: float
ema_fast_prev: float
ema_slow_prev: float
trend_ema: float
timestamp: int
def as_dict(self) -> dict[str, float]:
return {name: float(v) for name, v in zip(self.names, self.values, strict=True)}
def required_bars(rules: RuleConfig) -> int:
"""Minimale Anzahl Kerzen, damit alle Indikatoren belastbare Werte liefern."""
return max(MIN_BARS, rules.trend_filter_period + 10, rules.slow_ema * 3, rules.rsi_period * 4)
def _clean(arr: np.ndarray, fill: float | np.ndarray = 0.0) -> np.ndarray:
out = np.asarray(arr, dtype=np.float64).copy()
bad = ~np.isfinite(out)
if np.any(bad):
out[bad] = fill[bad] if isinstance(fill, np.ndarray) else fill
return out
def _pct(numerator: np.ndarray, price: np.ndarray) -> np.ndarray:
with np.errstate(divide="ignore", invalid="ignore"):
return np.where(price > 0, numerator / price * 100.0, 0.0)
@dataclass(slots=True)
class FeatureMatrix:
"""Alle Merkmalsvektoren einer Serie plus die Roh-Indikatoren."""
values: np.ndarray # (n, N_FEATURES)
timestamp: np.ndarray
price: np.ndarray
atr: np.ndarray
rsi: np.ndarray
ema_fast: np.ndarray
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])
def is_valid(self, index: int) -> bool:
idx = index if index >= 0 else len(self) + index
return self.first_valid <= idx < len(self)
def snapshot(self, index: int = -1) -> FeatureSnapshot | None:
n = len(self)
idx = index if index >= 0 else n + index
if not self.is_valid(idx):
return None
prev = max(idx - 1, 0)
return FeatureSnapshot(
values=self.values[idx].copy(),
names=self.names,
index=idx,
price=float(self.price[idx]),
atr=float(self.atr[idx]),
rsi=float(self.rsi[idx]),
rsi_prev=float(self.rsi[prev]),
ema_fast=float(self.ema_fast[idx]),
ema_slow=float(self.ema_slow[idx]),
ema_fast_prev=float(self.ema_fast[prev]),
ema_slow_prev=float(self.ema_slow[prev]),
trend_ema=float(self.trend_ema[idx]),
timestamp=int(self.timestamp[idx]),
)
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)
need = required_bars(rules)
if n < need:
return None
close = np.asarray(candles.close, dtype=np.float64)
high = np.asarray(candles.high, dtype=np.float64)
low = np.asarray(candles.low, dtype=np.float64)
open_ = np.asarray(candles.open, dtype=np.float64)
volume = np.asarray(candles.volume, dtype=np.float64)
price = np.where(close > 0, close, np.nan)
ema_fast = ema(close, rules.fast_ema)
ema_slow = ema(close, rules.slow_ema)
trend_period = rules.trend_filter_period or rules.slow_ema * 4
trend_ema = ema(close, trend_period)
rsi_arr = _clean(rsi(close, rules.rsi_period), 50.0)
atr_raw = atr(high, low, close, rules.atr_period)
atr_arr = _clean(atr_raw, close * 0.005)
atr_arr = np.where(atr_arr > 0, atr_arr, np.maximum(close * 0.005, 1e-9))
_, _, macd_hist = macd(close, rules.fast_ema, rules.slow_ema, 9)
macd_hist = _clean(macd_hist)
roc3 = _clean(roc(close, 3))
roc12 = _clean(roc(close, 12))
dpos = _clean(donchian_position(high, low, close, 20), 0.5)
vol_short = _clean(rolling_std(close, 10))
vol_long = _clean(rolling_std(close, 50))
with np.errstate(divide="ignore", invalid="ignore"):
vol_ratio = np.where(vol_long > 1e-12, vol_short / vol_long, 1.0)
volume_mean = _clean(sma(volume, 50), float(np.mean(volume)) if volume.size else 0.0)
volume_std = _clean(rolling_std(volume, 50))
with np.errstate(divide="ignore", invalid="ignore"):
volume_z = np.where(volume_std > 1e-12, (volume - volume_mean) / volume_std, 0.0)
bar_range = np.maximum(high - low, 1e-12)
body = np.abs(close - open_) / bar_range
upper_wick = (high - np.maximum(open_, close)) / bar_range
lower_wick = (np.minimum(open_, close) - low) / bar_range
seconds_of_day = (np.asarray(candles.timestamp, dtype=np.int64) // 1000) % 86_400
angle = 2.0 * np.pi * seconds_of_day.astype(np.float64) / 86_400.0
rsi_prev = np.concatenate([np.full(min(3, n), rsi_arr[0]), rsi_arr[:-3]])[:n] if n > 3 else rsi_arr
hist_prev = np.concatenate([macd_hist[:1], macd_hist[:-1]])
columns = [
_pct(ema_fast - ema_slow, price),
_pct(close - ema_fast, price),
_pct(close - trend_ema, price),
(rsi_arr - 50.0) / 50.0,
(rsi_arr - rsi_prev) / 50.0,
_pct(macd_hist, price),
_pct(macd_hist - hist_prev, price),
_pct(atr_arr, price),
vol_ratio - 1.0,
roc3 * 100.0,
roc12 * 100.0,
dpos - 0.5,
volume_z,
body,
upper_wick,
lower_wick,
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)
return FeatureMatrix(
values=values,
timestamp=np.asarray(candles.timestamp, dtype=np.int64),
price=_clean(close),
atr=atr_arr,
rsi=rsi_arr,
ema_fast=_clean(ema_fast, close),
ema_slow=_clean(ema_slow, close),
trend_ema=_clean(trend_ema, close),
first_valid=need - 1,
names=feature_names(derivatives),
)
def compute_features(candles: Candles, rules: RuleConfig, index: int = -1) -> FeatureSnapshot | None:
"""Bequemlichkeits-Wrapper für einen einzelnen Zeitpunkt (Live-Loop, Tests)."""
matrix = build_feature_matrix(candles, rules)
if matrix is None:
return None
return matrix.snapshot(index)