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:
@@ -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)
|
||||
Reference in New Issue
Block a user