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.
280 lines
11 KiB
Python
280 lines
11 KiB
Python
"""Zusammenbau aller Komponenten aus der Konfiguration."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import sys
|
||
from dataclasses import dataclass
|
||
from typing import Any
|
||
|
||
from .broker import Broker, LiveBroker, PaperBroker
|
||
from .config import Config, Mode
|
||
from .configstore import ConfigStore
|
||
from .data import CcxtDataFeed, DataFeed
|
||
from .engine import TradingEngine
|
||
from .exchange import build_exchange, load_market_info
|
||
from .features import N_FEATURES
|
||
from .notify import Notifier
|
||
from .portfolio import Portfolio
|
||
from .risk import RiskManager
|
||
from .server import StatusServer
|
||
from .storage import NullStorage, Storage
|
||
from .strategy import Strategy, build_strategy
|
||
|
||
log = logging.getLogger(__name__)
|
||
|
||
|
||
def setup_logging(level: str = "INFO") -> None:
|
||
root = logging.getLogger()
|
||
if root.handlers:
|
||
root.setLevel(level)
|
||
return
|
||
# Umlaute und Symbole sollen auch auf Konsolen mit anderer Codepage lesbar bleiben.
|
||
for stream in (sys.stdout, sys.stderr):
|
||
reconfigure = getattr(stream, "reconfigure", None)
|
||
if reconfigure is not None:
|
||
try:
|
||
reconfigure(encoding="utf-8", errors="replace")
|
||
except (ValueError, OSError): # pragma: no cover - je nach Konsole
|
||
pass
|
||
handler = logging.StreamHandler(sys.stdout)
|
||
handler.setFormatter(
|
||
logging.Formatter("%(asctime)s %(levelname)-7s %(name)-22s %(message)s", "%Y-%m-%d %H:%M:%S")
|
||
)
|
||
root.addHandler(handler)
|
||
root.setLevel(level)
|
||
# ccxt und aiohttp sind im INFO-Level sehr gesprächig.
|
||
logging.getLogger("ccxt").setLevel(logging.WARNING)
|
||
logging.getLogger("aiohttp").setLevel(logging.WARNING)
|
||
|
||
|
||
@dataclass
|
||
class Runtime:
|
||
"""Alle Laufzeitobjekte eines Laufs, inklusive geordnetem Herunterfahren."""
|
||
|
||
config: Config
|
||
engine: TradingEngine
|
||
broker: Broker
|
||
feed: DataFeed
|
||
strategy: Strategy
|
||
portfolio: Portfolio
|
||
risk: RiskManager
|
||
storage: Storage | NullStorage
|
||
notifier: Notifier
|
||
server: StatusServer | None
|
||
exchange: Any | None
|
||
|
||
async def start_services(self) -> None:
|
||
await self.notifier.start()
|
||
if self.server is not None:
|
||
await self.server.start()
|
||
|
||
async def close(self) -> None:
|
||
if self.server is not None:
|
||
await self.server.close()
|
||
await self.notifier.close()
|
||
if self.exchange is not None:
|
||
await self.exchange.close()
|
||
else:
|
||
await self.broker.close()
|
||
await self.feed.close()
|
||
self.storage.close()
|
||
|
||
|
||
def control_is_unsafe(config: Config) -> bool:
|
||
"""Live-Handel, der ungeschützt aus dem Netz steuerbar wäre – wird nicht bedient."""
|
||
return (
|
||
config.server.enable_control
|
||
and config.mode is Mode.LIVE
|
||
and not config.server.control_token
|
||
and config.server.publicly_reachable
|
||
)
|
||
|
||
|
||
def control_effective(config: Config) -> bool:
|
||
"""Ob die Steuerbefehle tatsächlich bereitgestellt werden."""
|
||
return config.server.enable_control and not control_is_unsafe(config)
|
||
|
||
|
||
def _guard_control_exposure(config: Config) -> Config:
|
||
"""Steuerbefehle absichern, bevor der Server startet.
|
||
|
||
Im Live-Modus könnte ein ungeschützter Endpunkt aus dem Netz echten Handel starten.
|
||
Diese Kombination wird deshalb nicht bedient: Die Steuerung wird abgeschaltet, der Bot
|
||
läuft aber normal weiter.
|
||
"""
|
||
if not config.server.enable_control:
|
||
log.info("Steuerbefehle sind deaktiviert – nur lesende Endpunkte")
|
||
return config
|
||
|
||
unprotected = not config.server.control_token and config.server.publicly_reachable
|
||
if control_is_unsafe(config):
|
||
log.error(
|
||
"Steuerung abgeschaltet: Im Live-Modus dürfen Handel und Training nicht ohne "
|
||
"Token aus dem Netz steuerbar sein (server.host=%s, kein server.control_token). "
|
||
"Token setzen oder server.host auf 127.0.0.1 begrenzen, dann steht die "
|
||
"Dashboard-Steuerung wieder zur Verfügung.",
|
||
config.server.host,
|
||
)
|
||
return config.model_copy(
|
||
update={"server": config.server.model_copy(update={"enable_control": False})}
|
||
)
|
||
|
||
if config.server.control_token:
|
||
log.info("Steuerbefehle sind aktiv und durch ein Token geschützt")
|
||
elif unprotected:
|
||
log.warning(
|
||
"Steuerbefehle (Handel starten, Training anstoßen) sind ohne Token auf %s:%d "
|
||
"erreichbar. Jeder, der den Port erreicht, kann sie auslösen – bitte "
|
||
"server.control_token setzen oder den Zugriff per Firewall/Reverse Proxy begrenzen.",
|
||
config.server.host, config.server.port,
|
||
)
|
||
else:
|
||
log.info("Steuerbefehle sind aktiv (nur lokal erreichbar, kein Token gesetzt)")
|
||
return config
|
||
|
||
|
||
def _quote_currency(market_info: dict[str, dict[str, Any]], symbols: list[str], fallback: str) -> str:
|
||
quotes = {market_info.get(s, {}).get("quote") for s in symbols}
|
||
quotes.discard(None)
|
||
if len(quotes) > 1:
|
||
raise ValueError(
|
||
"Alle Symbole müssen dieselbe Quote-Währung haben (gefunden: "
|
||
+ ", ".join(sorted(str(q) for q in quotes))
|
||
+ "). Bitte market.symbols anpassen."
|
||
)
|
||
return str(next(iter(quotes))) if quotes else fallback
|
||
|
||
|
||
async def build_runtime(
|
||
config: Config,
|
||
*,
|
||
with_server: bool = True,
|
||
with_storage: bool = True,
|
||
load_model: bool = True,
|
||
seed: int | None = None,
|
||
config_store: ConfigStore | None = None,
|
||
) -> Runtime:
|
||
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste."""
|
||
serving = with_server and config.server.enabled
|
||
if serving:
|
||
# Muss vor dem Bau der Engine laufen – sie liest enable_control für ihre Steuerbefehle.
|
||
config = _guard_control_exposure(config)
|
||
|
||
storage: Storage | NullStorage = (
|
||
Storage(config.storage.database_path) if with_storage else NullStorage()
|
||
)
|
||
|
||
read_only = config.mode is not Mode.LIVE
|
||
exchange = build_exchange(config.exchange, read_only=read_only)
|
||
market_info = await load_market_info(exchange, config.market.symbols)
|
||
feed = CcxtDataFeed(exchange)
|
||
|
||
broker: Broker
|
||
if config.mode is Mode.LIVE:
|
||
quote = _quote_currency(market_info, config.market.symbols, "USDT")
|
||
broker = LiveBroker(exchange, quote_currency=quote, market_info=market_info)
|
||
starting_equity = 0.0 # wird beim Start aus dem echten Guthaben gesetzt
|
||
else:
|
||
quote = _quote_currency(market_info, config.market.symbols, config.paper.quote_currency)
|
||
if quote != config.paper.quote_currency:
|
||
log.info(
|
||
"Quote-Währung der Symbole ist %s – paper.quote_currency (%s) wird überschrieben",
|
||
quote, config.paper.quote_currency,
|
||
)
|
||
paper_config = config.paper.model_copy(update={"quote_currency": quote})
|
||
broker = PaperBroker(paper_config, market_info=market_info)
|
||
starting_equity = paper_config.starting_balance
|
||
|
||
strategy = build_strategy(config.strategy, N_FEATURES, seed=seed, load_model=load_model)
|
||
learner = getattr(strategy, "learner", None)
|
||
if learner is not None and config.mode is Mode.LIVE and config.strategy.learner.freeze_in_live:
|
||
learner.frozen = True
|
||
log.info("Live-Modus: Online-Lernen eingefroren (freeze_in_live=true)")
|
||
|
||
portfolio = Portfolio(starting_equity=starting_equity, quote_currency=broker.quote_currency)
|
||
risk = RiskManager(config.risk)
|
||
notifier = Notifier(config.notifications)
|
||
|
||
engine = TradingEngine(
|
||
config=config,
|
||
broker=broker,
|
||
feed=feed,
|
||
strategy=strategy,
|
||
portfolio=portfolio,
|
||
risk=risk,
|
||
storage=storage,
|
||
notifier=notifier,
|
||
config_store=config_store,
|
||
)
|
||
|
||
server = StatusServer(config.server, engine.status, controller=engine) if serving else None
|
||
|
||
storage.start_run(
|
||
mode=config.mode.value,
|
||
exchange=config.exchange.id,
|
||
symbols=config.market.symbols,
|
||
timeframe=config.market.timeframe,
|
||
strategy=config.strategy.name,
|
||
)
|
||
|
||
return Runtime(
|
||
config=config,
|
||
engine=engine,
|
||
broker=broker,
|
||
feed=feed,
|
||
strategy=strategy,
|
||
portfolio=portfolio,
|
||
risk=risk,
|
||
storage=storage,
|
||
notifier=notifier,
|
||
server=server,
|
||
exchange=exchange,
|
||
)
|
||
|
||
|
||
def describe_config(config: Config) -> str:
|
||
"""Kompakte Übersicht der wirksamen Konfiguration (ohne Geheimnisse)."""
|
||
ex = config.exchange
|
||
lines = [
|
||
f"Modus {config.mode.value}"
|
||
+ (" ⚠ ECHTES GELD" if config.mode is Mode.LIVE else ""),
|
||
f"Börse {ex.id}" + (" (Sandbox/Testnet)" if ex.sandbox else " (Produktiv)"),
|
||
f"Zugangsdaten {'gesetzt' if ex.has_credentials() else 'nicht gesetzt'}",
|
||
f"Symbole {', '.join(config.market.symbols)}",
|
||
f"Timeframe {config.market.timeframe} "
|
||
f"(Abfrage alle {config.market.poll_interval_seconds:g}s)",
|
||
f"Strategie {config.strategy.name}"
|
||
+ (f" (Lernen aktiv, Schwelle {config.strategy.learner.entry_threshold})"
|
||
if config.strategy.name == "adaptive" and config.strategy.learner.enabled
|
||
else " (kein Lernen)"),
|
||
f"Risiko max. {config.risk.max_open_positions} Positionen, "
|
||
f"{config.risk.max_position_pct:.0%} je Position, "
|
||
f"Stop {config.risk.stop_loss_atr_mult}×ATR, Ziel {config.risk.take_profit_atr_mult}×ATR",
|
||
f"Notbremsen Tagesverlust {config.risk.max_daily_loss_pct:.0%}, "
|
||
f"Drawdown {config.risk.max_drawdown_pct:.0%}",
|
||
"Handel "
|
||
+ ("startet automatisch" if config.trading.autostart
|
||
else "startet pausiert – Freigabe über das Dashboard"),
|
||
f"Datenbank {config.storage.database_path}",
|
||
f"Modelldatei {config.strategy.learner.model_path}",
|
||
]
|
||
if config.mode is not Mode.LIVE:
|
||
lines.insert(
|
||
3,
|
||
f"Startkapital {config.paper.starting_balance:g} {config.paper.quote_currency} "
|
||
f"(Gebühr {config.paper.fee_rate:.3%}, Slippage {config.paper.slippage_bps:g} bps)",
|
||
)
|
||
if config.server.enabled:
|
||
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/")
|
||
if control_effective(config):
|
||
guard = "Token gesetzt" if config.server.control_token else "OHNE Token"
|
||
lines.append(f"Steuerung Handel und Training über das Dashboard ({guard})")
|
||
elif control_is_unsafe(config):
|
||
lines.append(
|
||
"Steuerung ABGESCHALTET – Live-Modus ohne Token auf offenem Port"
|
||
)
|
||
else:
|
||
lines.append("Steuerung deaktiviert (nur lesende Endpunkte)")
|
||
return "\n".join(" " + line for line in lines)
|