Files
Trademind/src/trademind/app.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

280 lines
11 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.
"""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)