"""Kommandozeile: ``trademind run|backtest|validate|report|exchanges``.""" from __future__ import annotations import argparse import asyncio import json import logging import os import sys from pathlib import Path from . import __version__ from .app import build_runtime, describe_config, setup_logging from .backtest import BacktestRunner, equity_curve_csv, summarize_returns, trades_csv from .config import Config, Mode, load_config from .data import align_series, load_csv, parse_iso8601 from .engine import install_signal_handlers from .exchange import available_exchanges from .models import Candles from .storage import Storage log = logging.getLogger("trademind.cli") DEFAULT_CONFIG = os.environ.get("TRADEMIND_CONFIG", "/config/config.yaml") # --------------------------------------------------------------------- Parser def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="trademind", description="Selbstlernender Krypto-Trading-Bot (Paper, Backtest, Live).", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=( "Beispiele:\n" " trademind run --config config/config.yaml\n" " trademind backtest --config config/config.yaml --bars 20000 --fresh-model --save-model\n" " trademind validate --config config/config.yaml\n" " trademind exchanges --search kraken\n" ), ) parser.add_argument("--version", action="version", version=f"trademind {__version__}") sub = parser.add_subparsers(dest="command", required=True) def add_common(p: argparse.ArgumentParser) -> None: p.add_argument( "-c", "--config", default=DEFAULT_CONFIG, help=f"Konfigurationsdatei (Standard: {DEFAULT_CONFIG})", ) p.add_argument( "--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"], help="Log-Level überschreiben", ) run_p = sub.add_parser("run", help="Bot dauerhaft laufen lassen (paper oder live)") add_common(run_p) run_p.add_argument("--mode", choices=[m.value for m in Mode], help="Modus überschreiben") run_p.add_argument( "--liquidate-on-exit", action="store_true", help="Beim Beenden alle Positionen schließen" ) run_p.add_argument("--no-server", action="store_true", help="Status-Server nicht starten") bt_p = sub.add_parser("backtest", help="Strategie auf historischen Daten durchspielen") add_common(bt_p) bt_p.add_argument("--bars", type=int, help="Anzahl Kerzen (Standard aus backtest.bars)") bt_p.add_argument("--start", help="Startzeit ISO-8601, z. B. 2024-01-01T00:00:00Z") bt_p.add_argument("--end", help="Endzeit ISO-8601") bt_p.add_argument("--csv-dir", help="OHLCV aus CSV-Dateien statt von der Börse laden") bt_p.add_argument("--fresh-model", action="store_true", help="Mit untrainiertem Modell starten") bt_p.add_argument("--save-model", action="store_true", help="Trainiertes Modell nach dem Lauf speichern") bt_p.add_argument("--out-dir", help="Trades und Equity-Kurve als CSV hier ablegen") bt_p.add_argument("--json", action="store_true", help="Ergebnis als JSON ausgeben") bt_p.add_argument("--seed", type=int, default=42, help="Zufallszahlen-Seed für reproduzierbare Läufe") val_p = sub.add_parser("validate", help="Konfiguration prüfen und Börsenverbindung testen") add_common(val_p) val_p.add_argument( "--offline", action="store_true", help="Nur die Datei prüfen, keine Verbindung aufbauen" ) rep_p = sub.add_parser("report", help="Ergebnisse aus der Datenbank zusammenfassen") add_common(rep_p) rep_p.add_argument("--limit", type=int, default=20, help="Anzahl der zuletzt gezeigten Trades") rep_p.add_argument("--json", action="store_true", help="Ausgabe als JSON") ex_p = sub.add_parser("exchanges", help="Von ccxt unterstützte Börsen auflisten") ex_p.add_argument("--search", help="Nach Namensbestandteil filtern") return parser # ---------------------------------------------------------------- Hilfsroutinen def _load(args: argparse.Namespace) -> Config: try: config = load_config(args.config) except FileNotFoundError as exc: print(f"Fehler: {exc}", file=sys.stderr) print( "Tipp: Beispielkonfiguration kopieren – cp config/config.example.yaml config/config.yaml", file=sys.stderr, ) raise SystemExit(2) from None except Exception as exc: # noqa: BLE001 - Validierungsfehler leserlich ausgeben print(f"Konfiguration ungültig ({args.config}):\n{exc}", file=sys.stderr) raise SystemExit(2) from None if getattr(args, "log_level", None): config = config.model_copy(update={"log_level": args.log_level}) return config def _symbol_to_filename(symbol: str) -> str: return symbol.replace("/", "_").replace(":", "_") async def _load_series(config: Config, args: argparse.Namespace, runtime) -> dict[str, Candles]: """Historische Kerzen laden – aus CSV oder von der Börse.""" timeframe = config.market.timeframe bars = args.bars or config.backtest.bars csv_dir = args.csv_dir or config.backtest.csv_dir series: dict[str, Candles] = {} if csv_dir: directory = Path(csv_dir) for symbol in config.market.symbols: candidates = [ directory / f"{_symbol_to_filename(symbol)}.csv", directory / f"{_symbol_to_filename(symbol)}_{timeframe}.csv", directory / f"{symbol.split('/')[0]}.csv", ] path = next((p for p in candidates if p.is_file()), None) if path is None: raise SystemExit( f"Keine CSV für {symbol} in {directory} gefunden " f"(erwartet z. B. {_symbol_to_filename(symbol)}.csv)" ) series[symbol] = load_csv(path, symbol, timeframe) else: since = parse_iso8601(args.start or config.backtest.start) until = parse_iso8601(args.end or config.backtest.end) for symbol in config.market.symbols: series[symbol] = await runtime.feed.fetch_history(symbol, timeframe, bars, since, until) empty = [s for s, c in series.items() if len(c) == 0] if empty: raise SystemExit(f"Keine Daten für: {', '.join(empty)}") return align_series(series) # ------------------------------------------------------------------- Kommandos async def cmd_run(args: argparse.Namespace) -> int: config = _load(args) if args.mode and args.mode != config.mode.value: # Über model_validate, damit die Live-Schutzprüfungen erneut greifen. try: config = Config.model_validate({**config.model_dump(), "mode": args.mode}) except Exception as exc: # noqa: BLE001 print(f"Modus '{args.mode}' nicht möglich:\n{exc}", file=sys.stderr) return 2 setup_logging(config.log_level) print("\nTradeMind startet:\n" + describe_config(config) + "\n") if config.mode is Mode.LIVE: log.warning("LIVE-MODUS: Es werden echte Orders mit echtem Guthaben ausgeführt.") runtime = await build_runtime(config, with_server=not args.no_server) try: await runtime.start_services() await runtime.engine.prepare() await runtime.engine.bootstrap_learner() runtime.notifier.startup( config.mode.value, config.exchange.id, config.market.symbols, config.market.timeframe ) install_signal_handlers(runtime.engine) await runtime.engine.run() except asyncio.CancelledError: log.info("Abbruch empfangen") finally: await runtime.engine.shutdown(liquidate=args.liquidate_on_exit) await runtime.close() summary = runtime.portfolio.summary(runtime.engine._cash) print( f"\nBeendet. Equity {summary['equity']:.2f} {runtime.broker.quote_currency}, " f"{summary['trades']} Trades, Rendite {summary['total_return_pct']:+.2f} %" ) return 0 async def cmd_backtest(args: argparse.Namespace) -> int: config = _load(args) if config.mode is Mode.LIVE: config = config.model_copy(update={"mode": Mode.BACKTEST}) setup_logging(config.log_level) runtime = await build_runtime( config, with_server=False, with_storage=False, load_model=not args.fresh_model, seed=args.seed, ) learner = getattr(runtime.strategy, "learner", None) if learner is not None: learner.autosave = False try: series = await _load_series(config, args, runtime) await runtime.engine.prepare() report = await BacktestRunner(runtime.engine, series).run() if args.json: payload = report.as_dict() payload["return_distribution"] = summarize_returns(runtime.engine) print(json.dumps(payload, indent=2, ensure_ascii=False, default=str)) else: print(report.render(runtime.broker.quote_currency)) distribution = summarize_returns(runtime.engine) if distribution: print( f" Trade-Renditen Median {distribution['median_pct']:+.2f} %, " f"5%-Quantil {distribution['p05_pct']:+.2f} %, " f"95%-Quantil {distribution['p95_pct']:+.2f} %\n" ) if args.out_dir: out = Path(args.out_dir) out.mkdir(parents=True, exist_ok=True) (out / "trades.csv").write_text(trades_csv(runtime.engine), encoding="utf-8") (out / "equity.csv").write_text(equity_curve_csv(runtime.engine), encoding="utf-8") (out / "report.json").write_text( json.dumps(report.as_dict(), indent=2, ensure_ascii=False, default=str), encoding="utf-8" ) print(f" Ergebnisdateien in {out.resolve()}") if args.save_model and learner is not None: path = learner.save() print(f" Modell gespeichert: {path}") finally: await runtime.close() return 0 async def cmd_validate(args: argparse.Namespace) -> int: config = _load(args) setup_logging(config.log_level) print("\nKonfiguration gültig:\n" + describe_config(config) + "\n") if args.offline: return 0 runtime = await build_runtime(config, with_server=False, with_storage=False, load_model=False) try: candles = await runtime.feed.fetch( config.market.symbols[0], config.market.timeframe, min(config.market.history_bars, 100) ) print( f" Verbindung zu {config.exchange.id} steht: {len(candles)} Kerzen für " f"{config.market.symbols[0]}, letzter Kurs {candles.last_price():.6f}" ) if config.mode is Mode.LIVE: cash = await runtime.broker.cash() print(f" Live-Guthaben: {cash:.2f} {runtime.broker.quote_currency}") print() finally: await runtime.close() return 0 def cmd_report(args: argparse.Namespace) -> int: config = _load(args) setup_logging(config.log_level) path = Path(config.storage.database_path) if not path.is_file(): print(f"Keine Datenbank unter {path} – noch kein Lauf aufgezeichnet.", file=sys.stderr) return 1 storage = Storage(path) try: total = storage.trade_count() per_symbol = storage.performance_by_symbol() recent = storage.recent_trades(args.limit) if args.json: print(json.dumps( {"trades": total, "per_symbol": per_symbol, "recent": recent}, indent=2, ensure_ascii=False, default=str, )) return 0 print(f"\n Datenbank: {path}") print(f" Trades gesamt: {total}\n") if per_symbol: print(f" {'Symbol':<14}{'Trades':>8}{'Gewinne':>9}{'Netto-P/L':>14}{'Ø %':>9}") print(" " + "─" * 54) for row in per_symbol: print( f" {row['symbol']:<14}{row['trades']:>8}{row['wins']:>9}" f"{row['net_pnl']:>14.2f}{(row['avg_pnl_pct'] or 0) * 100:>9.2f}" ) if recent: print(f"\n Letzte {len(recent)} Trades") print(f" {'Symbol':<12}{'Grund':<15}{'P/L':>12}{'%':>9}{'Konfidenz':>11}") print(" " + "─" * 59) for row in recent: print( f" {row['symbol']:<12}{row['exit_reason']:<15}{row['pnl_quote']:>12.2f}" f"{row['pnl_pct'] * 100:>9.2f}{row['entry_confidence']:>11.2f}" ) print() finally: storage.close() return 0 def cmd_exchanges(args: argparse.Namespace) -> int: names = available_exchanges() if args.search: needle = args.search.lower() names = [n for n in names if needle in n] if not names: print("Keine passende Börse gefunden.") return 1 print(f"\n {len(names)} Börsen über ccxt ansprechbar (exchange.id in der Konfiguration):\n") for i in range(0, len(names), 5): print(" " + "".join(f"{n:<20}" for n in names[i : i + 5])) print() return 0 # ------------------------------------------------------------------ Einstieg def main(argv: list[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) try: if args.command == "exchanges": return cmd_exchanges(args) if args.command == "report": return cmd_report(args) if args.command == "run": return asyncio.run(cmd_run(args)) if args.command == "backtest": return asyncio.run(cmd_backtest(args)) if args.command == "validate": return asyncio.run(cmd_validate(args)) except KeyboardInterrupt: print("\nAbgebrochen.") return 130 except SystemExit: raise except Exception as exc: # noqa: BLE001 - oberste Fehlerbarriere der CLI logging.getLogger("trademind").exception("Unbehandelter Fehler") print(f"\nFehler: {exc}", file=sys.stderr) return 1 parser.error(f"Unbekanntes Kommando: {args.command}") return 2 if __name__ == "__main__": raise SystemExit(main())