diff --git a/README.md b/README.md index 780423d..ab82775 100644 --- a/README.md +++ b/README.md @@ -349,9 +349,46 @@ Slack als auch Discord. --- -## Training aus dem Dashboard anstoßen +## Handel und Training aus dem Dashboard steuern -Im Abschnitt **Training** des Dashboards stehen zwei Bedienelemente: +### Automatisierter Handel + +Der Abschnitt **Automatisierter Handel** zeigt den Zustand und schaltet ihn um — in beiden +Modi, simuliert wie echt. Standardmäßig startet der Bot handelnd (`trading.autostart: true`); +mit `autostart: false` sammelt er zunächst nur Daten und wartet auf die Freigabe. + +Pausiert bedeutet **nur: keine neuen Einstiege**. Alles andere läuft weiter: + +| Auch pausiert | Pausiert ausgesetzt | +|---|---| +| Marktdaten werden abgerufen | Neue Positionen eröffnen | +| Signale werden ausgewertet und gelabelt | | +| Das Modell trainiert weiter | | +| Offene Positionen bleiben unter Stop-/Ziel-Überwachung | | + +Der Bot lernt also durchgehend weiter, auch wenn er nicht handelt. Auf identischen Daten: + +``` +Handel aktiv Trades=9 Beobachtungen=99 Shadow=90 Trade-Labels=9 nur-gelernte Signale=0 +Handel pausiert Trades=0 Beobachtungen=90 Shadow=90 Trade-Labels=0 nur-gelernte Signale=9 +``` + +Der Unterschied sind genau die neun Trade-Labels, die ohne Handel nicht entstehen können — +die Shadow-Labels aus dem Marktgeschehen laufen unverändert weiter. + +> **Live-Modus.** Das Starten verlangt zusätzlich die Bestätigung `START_LIVE_TRADING` +> (das Dashboard fragt sie ab, per HTTP als `"confirm"` im Body). **Pausieren** geht immer +> ohne Bestätigung — im Zweifel muss man schnell anhalten können. Abschalten lässt sich die +> Rückfrage mit `trading.require_confirmation_for_live: false`. +> +> Die Kombination *Live-Modus + Port nicht nur lokal + kein `control_token`* wird **nicht +> bedient**: Der Bot schaltet die Steuerung dann beim Start ab und sagt das im Log. Sonst +> könnte jeder im Netz echten Handel starten. Mit Token oder auf `127.0.0.1` steht sie +> wieder zur Verfügung. + +### Training + +Im Abschnitt **Training** stehen zwei Bedienelemente: **Historisch nachtrainieren.** Anzahl Kerzen wählen (500 – 50 000 je Symbol), Knopf drücken. Der Bot lädt die Historie, läuft sie mit derselben Logik wie ein Backtest durch — **ohne zu @@ -377,6 +414,8 @@ curl -X POST localhost:8080/control/train/history -H 'Content-Type: application/ | Endpunkt | Methode | Wirkung | |--------------------------------|---------|--------------------------------------------| +| `/control/trading` | GET | Zustand des automatisierten Handels | +| `/control/trading` | POST | `{"enabled": true}` — Handel starten/pausieren (live zusätzlich `"confirm"`) | | `/control/training` | GET | Zustand des letzten/laufenden Trainings | | `/control/train/history` | POST | `{"bars": 5000}` — Nachtraining anstoßen | | `/control/train/live` | POST | `{"enabled": false}` — Lernen ein/aus | diff --git a/config/config.example.yaml b/config/config.example.yaml index 2928eac..3822985 100644 --- a/config/config.example.yaml +++ b/config/config.example.yaml @@ -55,6 +55,16 @@ paper: slippage_bps: 5 # 5 Basispunkte Ausführungsnachteil max_volume_participation: 0.1 # max. 10 % des Kerzenvolumens pro Order +# ────────────────────────── Automatisierter Handel ────────────────────────── +trading: + # true = der Bot handelt ab dem Start automatisch (bisheriges Verhalten) + # false = er sammelt Daten und lernt, eröffnet aber erst nach Freigabe im Dashboard + # Positionen. Für den ersten Live-Einsatz die sicherere Wahl. + autostart: true + # Nur mode: live – Start des Handels über das Dashboard verlangt die ausdrückliche + # Bestätigung START_LIVE_TRADING. Pausieren geht immer ohne Bestätigung. + require_confirmation_for_live: true + # ──────────────────────────────── Risikoregeln ────────────────────────────── risk: max_position_pct: 0.20 # je Position, gemessen an der Equity diff --git a/src/trademind/app.py b/src/trademind/app.py index 11d43d9..2758e85 100644 --- a/src/trademind/app.py +++ b/src/trademind/app.py @@ -80,22 +80,57 @@ class Runtime: self.storage.close() -def _warn_about_exposure(config: Config) -> None: - """Warnen, wenn Steuerbefehle ungeschützt aus dem Netz erreichbar sind.""" +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: - return + 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") - return - if config.server.publicly_reachable: + elif unprotected: log.warning( - "Steuerbefehle (Training anstoßen, Lernen umschalten) sind ohne Token auf %s:%d " + "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: @@ -119,6 +154,11 @@ async def build_runtime( seed: int | 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() ) @@ -165,10 +205,7 @@ async def build_runtime( notifier=notifier, ) - server = None - if with_server and config.server.enabled: - server = StatusServer(config.server, engine.status, controller=engine) - _warn_about_exposure(config) + server = StatusServer(config.server, engine.status, controller=engine) if serving else None storage.start_run( mode=config.mode.value, @@ -213,6 +250,9 @@ def describe_config(config: Config) -> str: 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}", ] @@ -224,9 +264,13 @@ def describe_config(config: Config) -> str: ) if config.server.enabled: lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/") - if config.server.enable_control: + if control_effective(config): guard = "Token gesetzt" if config.server.control_token else "OHNE Token" - lines.append(f"Steuerung Training über das Dashboard möglich ({guard})") + 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) diff --git a/src/trademind/config.py b/src/trademind/config.py index 1ebeed9..c6e6948 100644 --- a/src/trademind/config.py +++ b/src/trademind/config.py @@ -147,6 +147,17 @@ class StrategyConfig(_Base): 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" @@ -187,6 +198,7 @@ class Config(_Base): 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) diff --git a/src/trademind/engine.py b/src/trademind/engine.py index 513a8c2..d04b287 100644 --- a/src/trademind/engine.py +++ b/src/trademind/engine.py @@ -35,6 +35,9 @@ STATE_KEY = "engine_state" MIN_TRAINING_BARS = 500 MAX_TRAINING_BARS = 50_000 +# Muss mitgesendet werden, um den Handel im Live-Modus über das Dashboard zu starten. +LIVE_TRADING_CONFIRMATION = "START_LIVE_TRADING" + @dataclass class TrainingJob: @@ -171,6 +174,12 @@ class TradingEngine: self._engine_lock = asyncio.Lock() self._training_task: asyncio.Task[TrainingJob] | None = None self.training = TrainingJob() + # Automatisierter Handel. Pausiert unterbindet nur neue Einstiege – Marktdaten, + # Signalauswertung und Lernen laufen weiter, offene Positionen werden weiter + # überwacht (Stop-Loss und Take-Profit greifen also auch im Pausenzustand). + self.trading_active = config.trading.autostart + self.trading_changed_at: float | None = None + self.paused_signals = 0 # ------------------------------------------------------------ Lebenszyklus @@ -307,6 +316,64 @@ class TradingEngine: log.info("Historisches Training angefordert: %d Kerzen je Symbol", requested) return {"accepted": True, "reason": "", **self.training.as_dict()} + def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]: + """Automatisierten Handel starten oder pausieren. + + Pausiert werden ausschließlich neue Einstiege. Marktdaten, Signalauswertung und + Lernen laufen weiter, offene Positionen bleiben unter Stop-Loss-Überwachung – ein + pausierter Bot lässt also niemanden ungeschützt im Markt stehen. + """ + if not self.config.server.enable_control: + return {"accepted": False, "reason": "Steuerung ist deaktiviert", + **self.trading_control_status()} + + # Echtgeld zusätzlich absichern: Ein versehentlicher Klick soll nicht reichen. + if enabled and self.requires_trade_confirmation and confirm != LIVE_TRADING_CONFIRMATION: + return { + "accepted": False, + "reason": ( + "Im Live-Modus wird echtes Geld eingesetzt. Zum Starten " + f"'confirm': '{LIVE_TRADING_CONFIRMATION}' mitsenden." + ), + **self.trading_control_status(), + } + + if self.trading_active == enabled: + return {"accepted": True, "reason": "Zustand war bereits gesetzt", + **self.trading_control_status()} + + self.trading_active = enabled + self.trading_changed_at = time.time() + level = log.warning if (enabled and not self.config.is_simulated) else log.info + level( + "Automatisierter Handel %s (Modus %s)", + "GESTARTET" if enabled else "pausiert", self.config.mode.value, + ) + if self.notifier is not None: + state = "gestartet" if enabled else "pausiert" + self.notifier.send_soon(f"⚙️ Automatisierter Handel {state} (Modus {self.config.mode.value})") + return {"accepted": True, "reason": "", **self.trading_control_status()} + + @property + def requires_trade_confirmation(self) -> bool: + return not self.config.is_simulated and self.config.trading.require_confirmation_for_live + + def trading_control_status(self) -> dict[str, Any]: + return { + "control_enabled": self.config.server.enable_control, + "mode": self.config.mode.value, + "simulated": self.config.is_simulated, + "active": self.trading_active, + "autostart": self.config.trading.autostart, + "requires_confirmation": self.requires_trade_confirmation, + "confirmation_phrase": LIVE_TRADING_CONFIRMATION if self.requires_trade_confirmation else None, + "paused_signals": self.paused_signals, + "open_positions": len(self.portfolio.positions), + "halted": self.risk.trading_halted, + "halt_reason": self.risk.halt.reason, + "changed_at": self.trading_changed_at, + } + def set_online_learning(self, enabled: bool) -> dict[str, Any]: """Kontinuierliches Lernen im laufenden Betrieb ein- oder ausschalten.""" if not self.config.server.enable_control: @@ -434,7 +501,10 @@ class TradingEngine: if position is not None: if await self._manage_open_position(symbol, position, snapshot, bar): return - elif not self.risk.trading_halted: + else: + # Auch bei pausiertem Handel und bei aktiver Notbremse ausgewertet: Nur so + # bekommt das Modell weiter Kandidaten zum Labeln. Die Entscheidung, ob + # tatsächlich gekauft wird, fällt in _maybe_enter. await self._maybe_enter(symbol, snapshot, bar) async def _manage_open_position( @@ -460,12 +530,21 @@ class TradingEngine: return False async def _maybe_enter(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None: + # Die Auswertung läuft immer – sie meldet den Kandidaten zum verzögerten Labeln an. signal = self.strategy.evaluate(symbol, snapshot, None) if signal.action is not Action.ENTER_LONG: if signal.confidence and log.isEnabledFor(logging.DEBUG): log.debug("%s: kein Einstieg – %s", symbol, signal.reason) return + if not self.trading_active: + self.paused_signals += 1 + log.info( + "%s: Einstiegssignal (%s) – Handel ist pausiert, es wird nur gelernt", + symbol, signal.reason, + ) + return + decision = self.risk.can_open(symbol, self.portfolio, self._cash, bar.close) if not decision: log.debug("%s: Einstieg durch Risikoprüfung verhindert – %s", symbol, decision.reason) @@ -638,6 +717,7 @@ class TradingEngine: "recent_trades": self.portfolio.recent_trades(25), "strategy": self.strategy.snapshot(), "risk": self.risk.snapshot(), + "trading": self.trading_control_status(), "training": self.training_status(), "feature_weights": ( learner.feature_importance(FEATURE_NAMES) if learner is not None else {} diff --git a/src/trademind/server.py b/src/trademind/server.py index 8d1c61f..3eb68ec 100644 --- a/src/trademind/server.py +++ b/src/trademind/server.py @@ -25,8 +25,12 @@ class Controller(Protocol): def set_online_learning(self, enabled: bool) -> dict[str, Any]: ... + def set_trading(self, enabled: bool, confirm: str | None = None) -> dict[str, Any]: ... + def training_status(self) -> dict[str, Any]: ... + def trading_control_status(self) -> dict[str, Any]: ... + _DASHBOARD = """