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.
This commit is contained in:
Tobias Zimmermann
2026-08-22 08:53:04 +02:00
commit 65ed73977e
40 changed files with 7033 additions and 0 deletions
+368
View File
@@ -0,0 +1,368 @@
"""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())