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.
This commit is contained in:
Tobias Zimmermann
2026-08-22 08:53:04 +02:00
commit 65ed73977e
40 changed files with 7033 additions and 0 deletions
+285
View File
@@ -0,0 +1,285 @@
"""Konfiguration: YAML laden, ``${ENV}``-Platzhalter auflösen, per pydantic validieren."""
from __future__ import annotations
import os
import re
from enum import Enum
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
LIVE_CONFIRMATION_PHRASE = "I_UNDERSTAND_THE_RISK"
class Mode(str, Enum):
PAPER = "paper"
LIVE = "live"
BACKTEST = "backtest"
class _Base(BaseModel):
model_config = ConfigDict(extra="forbid")
class ExchangeConfig(_Base):
"""Anbindung an eine Börse. ``id`` ist eine beliebige ccxt-Exchange-ID."""
id: str = "binance"
api_key: str | None = None
api_secret: str | None = None
password: str | None = None # OKX, KuCoin, Coinbase Advanced ...
uid: str | None = None
sandbox: bool = True
enable_rate_limit: bool = True
timeout_ms: int = Field(default=20_000, ge=1_000)
options: dict[str, Any] = Field(default_factory=lambda: {"defaultType": "spot"})
@field_validator("id")
@classmethod
def _lower(cls, v: str) -> str:
return v.strip().lower()
def has_credentials(self) -> bool:
return bool(self.api_key and self.api_secret)
class MarketConfig(_Base):
symbols: list[str] = Field(default_factory=lambda: ["BTC/USDT"])
timeframe: str = "5m"
history_bars: int = Field(default=500, ge=60, le=5_000)
poll_interval_seconds: float = Field(default=20.0, gt=0)
@field_validator("symbols")
@classmethod
def _non_empty(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("market.symbols darf nicht leer sein")
return [s.strip().upper() for s in v]
class PaperConfig(_Base):
"""Parameter des simulierten Brokers."""
starting_balance: float = Field(default=10_000.0, gt=0)
quote_currency: str = "USDT"
fee_rate: float = Field(default=0.001, ge=0, le=0.05)
slippage_bps: float = Field(default=5.0, ge=0, le=500)
# Teilausführungen bei zu großem Ordervolumen relativ zum Bar-Volumen
max_volume_participation: float = Field(default=0.1, gt=0, le=1.0)
class RiskConfig(_Base):
max_position_pct: float = Field(default=0.2, gt=0, le=1.0)
max_total_exposure_pct: float = Field(default=0.6, gt=0, le=1.0)
max_open_positions: int = Field(default=3, ge=1)
stop_loss_atr_mult: float = Field(default=2.0, ge=0)
take_profit_atr_mult: float = Field(default=3.0, ge=0)
trailing_stop_atr_mult: float = Field(default=0.0, ge=0)
max_holding_bars: int = Field(default=0, ge=0) # 0 = unbegrenzt
max_daily_loss_pct: float = Field(default=0.05, ge=0, le=1.0)
max_drawdown_pct: float = Field(default=0.25, ge=0, le=1.0)
min_notional: float = Field(default=10.0, ge=0)
cooldown_bars_after_exit: int = Field(default=3, ge=0)
class RuleConfig(_Base):
fast_ema: int = Field(default=12, ge=2)
slow_ema: int = Field(default=26, ge=3)
rsi_period: int = Field(default=14, ge=2)
rsi_oversold: float = Field(default=35.0, ge=1, le=99)
rsi_overbought: float = Field(default=70.0, ge=1, le=99)
atr_period: int = Field(default=14, ge=2)
trend_filter_period: int = Field(default=100, ge=0) # 0 = aus
# Mindesthaltedauer für signalbasierte Ausstiege. Verhindert, dass ein frischer
# Einstieg sofort wieder ausgestoppt wird. Stop-Loss und Take-Profit gelten immer.
min_holding_bars: int = Field(default=3, ge=0)
@model_validator(mode="after")
def _ema_order(self) -> RuleConfig:
if self.fast_ema >= self.slow_ema:
raise ValueError("strategy.rules.fast_ema muss kleiner als slow_ema sein")
return self
class LearnerConfig(_Base):
"""Online-Lernen: Bewertung von Einstiegssignalen anhand realisierter Ergebnisse."""
enabled: bool = True
model_path: str = "/data/models/adaptive.npz"
entry_threshold: float = Field(default=0.55, ge=0.0, le=1.0)
exploration_rate: float = Field(default=0.05, ge=0.0, le=1.0)
learning_rate: float = Field(default=0.02, gt=0)
l2: float = Field(default=1e-4, ge=0)
replay_size: int = Field(default=5_000, ge=100)
batch_size: int = Field(default=64, ge=1)
train_every_n_samples: int = Field(default=5, ge=1)
warmup_samples: int = Field(default=200, ge=1)
label_horizon_bars: int = Field(default=12, ge=1)
label_target_bps: float = Field(default=30.0, ge=0)
trade_sample_weight: float = Field(default=3.0, gt=0)
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands beschleunigen
# die Aufwärmphase erheblich (0 = aus).
background_sample_every_n_bars: int = Field(default=10, ge=0)
background_sample_weight: float = Field(default=0.5, gt=0)
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, statt
# tagelang auf genügend Live-Beobachtungen zu warten (0 = aus).
bootstrap_bars: int = Field(default=3_000, ge=0)
freeze_in_live: bool = False
save_every_n_updates: int = Field(default=50, ge=1)
class StrategyConfig(_Base):
name: str = "adaptive" # adaptive | rules
rules: RuleConfig = Field(default_factory=RuleConfig)
learner: LearnerConfig = Field(default_factory=LearnerConfig)
@field_validator("name")
@classmethod
def _known(cls, v: str) -> str:
v = v.strip().lower()
if v not in {"adaptive", "rules"}:
raise ValueError("strategy.name muss 'adaptive' oder 'rules' sein")
return v
class StorageConfig(_Base):
database_path: str = "/data/trademind.sqlite3"
class ServerConfig(_Base):
enabled: bool = True
host: str = "0.0.0.0"
port: int = Field(default=8080, ge=1, le=65535)
enable_metrics: bool = True
class NotificationConfig(_Base):
webhook_url: str | None = None
notify_on_trade: bool = True
notify_on_risk_halt: bool = True
class BacktestConfig(_Base):
start: str | None = None # ISO-8601, z.B. 2024-01-01T00:00:00Z
end: str | None = None
bars: int = Field(default=5_000, ge=100)
csv_dir: str | None = None # optional: OHLCV aus CSV statt von der Börse
class Config(_Base):
mode: Mode = Mode.PAPER
log_level: str = "INFO"
live_confirmation: str | None = None
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
market: MarketConfig = Field(default_factory=MarketConfig)
paper: PaperConfig = Field(default_factory=PaperConfig)
risk: RiskConfig = Field(default_factory=RiskConfig)
strategy: StrategyConfig = Field(default_factory=StrategyConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
server: ServerConfig = Field(default_factory=ServerConfig)
notifications: NotificationConfig = Field(default_factory=NotificationConfig)
backtest: BacktestConfig = Field(default_factory=BacktestConfig)
@field_validator("log_level")
@classmethod
def _level(cls, v: str) -> str:
v = v.strip().upper()
if v not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError(f"Unbekannter log_level: {v}")
return v
@model_validator(mode="after")
def _live_guard(self) -> Config:
if self.mode is Mode.LIVE:
if self.live_confirmation != LIVE_CONFIRMATION_PHRASE:
raise ValueError(
"Live-Modus erfordert 'live_confirmation: "
f"{LIVE_CONFIRMATION_PHRASE}' in der Konfiguration."
)
if not self.exchange.has_credentials():
raise ValueError("Live-Modus erfordert exchange.api_key und exchange.api_secret.")
return self
@property
def is_simulated(self) -> bool:
return self.mode in (Mode.PAPER, Mode.BACKTEST)
def _substitute_env(node: Any) -> Any:
"""Ersetzt ``${VAR}`` / ``${VAR:-default}`` rekursiv durch Umgebungsvariablen."""
if isinstance(node, dict):
return {k: _substitute_env(v) for k, v in node.items()}
if isinstance(node, list):
return [_substitute_env(v) for v in node]
if not isinstance(node, str):
return node
def repl(match: re.Match[str]) -> str:
name, default = match.group(1), match.group(2)
return os.environ.get(name, default if default is not None else "")
result = _ENV_PATTERN.sub(repl, node)
# Ein Platzhalter, der zu einem leeren String auflöst, gilt als "nicht gesetzt".
if result == "" and _ENV_PATTERN.search(node):
return None
return result
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
def _coerce(raw: str) -> Any:
low = raw.strip().lower()
if low in _TRUE:
return True
if low in _FALSE:
return False
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
if "," in raw:
return [part.strip() for part in raw.split(",") if part.strip()]
return raw
def _apply_env_overrides(data: dict[str, Any], prefix: str = "TRADEMIND__") -> dict[str, Any]:
"""``TRADEMIND__RISK__MAX_OPEN_POSITIONS=5`` überschreibt ``risk.max_open_positions``."""
for key, value in os.environ.items():
if not key.startswith(prefix) or not value:
continue
path = [part.lower() for part in key[len(prefix) :].split("__") if part]
if not path:
continue
cursor: dict[str, Any] = data
for part in path[:-1]:
nxt = cursor.get(part)
if not isinstance(nxt, dict):
nxt = {}
cursor[part] = nxt
cursor = nxt
cursor[path[-1]] = _coerce(value)
return data
def load_config(path: str | Path) -> Config:
"""Lädt und validiert die Konfigurationsdatei."""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"Konfigurationsdatei nicht gefunden: {p}")
raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise ValueError(f"{p}: erwartet wurde ein YAML-Mapping auf oberster Ebene")
data = _substitute_env(raw)
data = _apply_env_overrides(data)
return Config.model_validate(data)