"""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" # Felder, die niemals über das Netz ausgeliefert oder entgegengenommen werden. Sie werden # im Dashboard maskiert angezeigt und bleiben ausschließlich über die Umgebung setzbar. SECRET_FIELDS: frozenset[str] = frozenset( {"exchange.api_key", "exchange.api_secret", "exchange.password", "exchange.uid"} ) # Felder, deren Änderung erst nach einem Neustart greift: Sie werden beim Aufbau der # Laufzeitobjekte einmalig ausgewertet (Börsenclient, Broker, Datenbank, Socket …). RESTART_REQUIRED: frozenset[str] = frozenset( { "mode", "live_confirmation", "market.symbols", "market.timeframe", "market.history_bars", "paper.starting_balance", "paper.quote_currency", # Wirkt erst beim nächsten Start – der aktuelle Handelszustand bleibt, wie er ist. "trading.autostart", "strategy.name", "strategy.learner.enabled", "strategy.learner.model_path", "strategy.learner.replay_size", "storage.database_path", "storage.overrides_path", "server.enabled", "server.host", "server.port", "server.enable_metrics", } ) # Ganze Abschnitte, die nur beim Start ausgewertet werden. RESTART_REQUIRED_PREFIXES: tuple[str, ...] = ("exchange.", "backtest.") def requires_restart(path: str) -> bool: return path in RESTART_REQUIRED or path.startswith(RESTART_REQUIRED_PREFIXES) def is_secret(path: str) -> bool: return path in SECRET_FIELDS 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 TradingConfig(_Base): """Steuerung des automatisierten Handels.""" # true = der Bot handelt ab dem Start automatisch (bisheriges Verhalten). # false = er sammelt Daten und lernt, eröffnet aber erst nach Freigabe Positionen. autostart: bool = True # Nur für mode: live – verlangt beim Einschalten über das Dashboard eine # ausdrückliche Bestätigung im Request-Body. require_confirmation_for_live: bool = True class StorageConfig(_Base): database_path: str = "/data/trademind.sqlite3" # Im Dashboard geänderte Werte. Liegt bewusst im beschreibbaren Datenvolume, weil # die Konfigurationsdatei üblicherweise read-only eingehängt ist. overrides_path: str = "/data/config.overrides.yaml" 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 # Steuerbefehle (Training anstoßen, Lernen ein-/ausschalten) über das Dashboard. enable_control: bool = True # Wenn gesetzt, verlangen alle Steuerbefehle den Header X-TradeMind-Token. # Dringend empfohlen, sobald der Port nicht nur lokal erreichbar ist. control_token: str | None = None @property def publicly_reachable(self) -> bool: return self.host not in ("127.0.0.1", "localhost", "::1") 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) trading: TradingConfig = Field(default_factory=TradingConfig) 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)