"""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)