65ed73977e
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.
369 lines
14 KiB
Python
369 lines
14 KiB
Python
"""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())
|