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:
@@ -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())
|
||||
Reference in New Issue
Block a user