Files
Trademind/src/trademind/config.py
T
Tobias Zimmermann 9f10f9000e Konfiguration im Dashboard änderbar machen
Neuer Abschnitt "Konfiguration" im Dashboard, aus dem pydantic-Schema erzeugt:
76 Felder in 13 Bereichen mit Kurzbeschreibung, typgerechtem Eingabefeld und den
Grenzen aus dem Modell. 70 davon sind änderbar, 45 greifen sofort, 31 sind als
neustartpflichtig markiert. Dazu GET und POST /control/config sowie
/control/config/reset.

Overlay statt Direktschreiben
- config.yaml ist im Container read-only eingehängt. Änderungen landen deshalb in
  /data/config.overrides.yaml und werden beim Start über die Basiskonfiguration
  gelegt. Rangfolge: YAML, dann ${ENV}, dann TRADEMIND__-Variablen, dann Overlay.
- Gespeichert wird nur, was vom Basiswert abweicht. Ein auf den Ausgangswert
  zurückgestelltes Feld verschwindet wieder aus dem Overlay, damit spätere
  Änderungen an config.yaml dort erneut durchschlagen.
- Beschädigte oder ungültige Overlays werden protokolliert und ignoriert, statt
  den Start zu blockieren.

Übernehmen zur Laufzeit
- apply_config hängt die Laufzeitobjekte auf die neuen Teilkonfigurationen um
  (Risiko, Strategie, Regelwerk, Lernmodell, Paper-Broker, Notifier, Log-Level).
- Was nur beim Aufbau ausgewertet wird - Börsenclient, Symbole, Timeframe,
  Startkapital, Datenbank, Socket - meldet der Store als neustartpflichtig.

Nebenbei behoben: Der Handels-Loop las Abfrageintervall und Historienlänge nur
einmal vor der Schleife. Eine Änderung an poll_interval_seconds hätte nie
gegriffen; beide werden jetzt je Durchlauf frisch gelesen.

Drei Ausnahmen von "komplett", bewusst gesetzt
- exchange.api_key/api_secret/password/uid werden maskiert angezeigt und nicht
  entgegengenommen. Sonst könnte jeder mit Zugriff auf den Port die
  Börsenschlüssel auslesen oder austauschen.
- mode lässt sich zwischen paper und backtest umstellen, nicht auf live.
- live_confirmation ist nicht schreibbar.
Zusammen verhindern sie, dass sich der Bot über das Netz schrittweise auf
Echtgeldhandel umstellen lässt.

222 Tests (36 neue), ruff sauber. Darunter Prüfungen, dass Geheimnisse in keiner
Antwort auftauchen und dass Tippfehler in RESTART_REQUIRED oder NON_WRITABLE
auffallen. Im Browser durchgeklickt: Änderung sofort wirksam, neustartpflichtige
Felder korrekt gemeldet und nach Neustart aktiv, Zurücksetzen einzeln und
komplett, alle Schutzregeln mit HTTP 400 samt lesbarer Begründung.
2026-08-23 13:25:33 +02:00

353 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)