d36ed142cf
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.
302 lines
11 KiB
Python
302 lines
11 KiB
Python
"""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)
|