9f10f9000e
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.
353 lines
13 KiB
Python
353 lines
13 KiB
Python
"""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)
|