Files
Trademind/src/trademind/features.py
T
Tobias Zimmermann 65ed73977e Initial commit: TradeMind – Krypto-Trading-Bot mit Lernmodus
Per Podman deploybarer Bot, der Käufe und Verkäufe simuliert ausführt und sich
aus den Ergebnissen weiter antrainiert.

Aufbau
- Einheitliche Bar-Verarbeitung für paper, backtest und live; ausgetauscht werden
  nur Datenquelle und Broker.
- Börsenanbindung über ccxt: rund 100 Börsen allein über exchange.id erreichbar.
  Zugangsdaten kommen über ENV-Platzhalter, der Live-Modus ist doppelt abgesichert.
- Paper-Broker mit Gebühren, Slippage, Börsenpräzision und Volumengrenzen.
- Online trainierte logistische Regression bewertet jedes Einstiegssignal. Sie lernt
  aus realen Trade-Ergebnissen, aus Shadow-Labels aller Kandidaten – auch der
  abgelehnten – und aus Hintergrund-Stichproben; beim Kaltstart wird sie aus der
  Kurshistorie vorgelernt.
- Risikomanagement: Positions- und Exposure-Grenzen, ATR-Stops, Cooldown sowie
  Tagesverlust- und Drawdown-Notbremsen.
- SQLite-Persistenz, HTTP-Status mit Prometheus-Metriken und Dashboard, Webhooks.

Deployment
- Containerfile (zweistufig, non-root UID 10001), podman-compose, systemd-Quadlet.
- Modell und Datenbank liegen im Volume /data und überleben Neustarts.

128 Tests, ruff sauber. Verifiziert gegen echte Marktdaten sowie im gebauten
Container inklusive Healthcheck und Zustandswiederherstellung.
2026-08-22 08:53:04 +02:00

226 lines
7.8 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 RuleConfig
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
from .models import Candles
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",
)
N_FEATURES = len(FEATURE_NAMES)
MIN_BARS = 140
_CLIP_LIMIT = 8.0
@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
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=FEATURE_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 build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix | None:
"""Berechnet Indikatoren und Merkmalsvektoren für die gesamte Serie.
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),
]
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,
)
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)