65ed73977e
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.
226 lines
7.8 KiB
Python
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)
|