Files
Trademind/src/trademind/cli.py
T
Tobias Zimmermann 65ed73977e Initial commit: TradeMind – Krypto-Trading-Bot mit Lernmodus
Per Podman deploybarer Bot, der Käufe und Verkäufe simuliert ausführt und sich
aus den Ergebnissen weiter antrainiert.

Aufbau
- Einheitliche Bar-Verarbeitung für paper, backtest und live; ausgetauscht werden
  nur Datenquelle und Broker.
- Börsenanbindung über ccxt: rund 100 Börsen allein über exchange.id erreichbar.
  Zugangsdaten kommen über ENV-Platzhalter, der Live-Modus ist doppelt abgesichert.
- Paper-Broker mit Gebühren, Slippage, Börsenpräzision und Volumengrenzen.
- Online trainierte logistische Regression bewertet jedes Einstiegssignal. Sie lernt
  aus realen Trade-Ergebnissen, aus Shadow-Labels aller Kandidaten – auch der
  abgelehnten – und aus Hintergrund-Stichproben; beim Kaltstart wird sie aus der
  Kurshistorie vorgelernt.
- Risikomanagement: Positions- und Exposure-Grenzen, ATR-Stops, Cooldown sowie
  Tagesverlust- und Drawdown-Notbremsen.
- SQLite-Persistenz, HTTP-Status mit Prometheus-Metriken und Dashboard, Webhooks.

Deployment
- Containerfile (zweistufig, non-root UID 10001), podman-compose, systemd-Quadlet.
- Modell und Datenbank liegen im Volume /data und überleben Neustarts.

128 Tests, ruff sauber. Verifiziert gegen echte Marktdaten sowie im gebauten
Container inklusive Healthcheck und Zustandswiederherstellung.
2026-08-22 08:53:04 +02:00

369 lines
14 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.
"""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())