From 7959dd71ff8e2e45da6d2d1fea16cf1798e33bda Mon Sep 17 00:00:00 2001 From: Tobias Zimmermann Date: Sat, 22 Aug 2026 11:53:59 +0200 Subject: [PATCH] Initial release: TradeMind crypto trading bot with paper/live modes and strategy training --- .gitignore | 10 ++ Podmanfile | 23 +++++ README.md | 136 +++++++++++++++++++++++++ config.yaml | 65 ++++++++++++ podman-compose.yml | 59 +++++++++++ pytest.ini | 5 + requirements.txt | 5 + tests/test_trademind.py | 103 +++++++++++++++++++ trademind/__init__.py | 3 + trademind/__main__.py | 4 + trademind/cli.py | 199 ++++++++++++++++++++++++++++++++++++ trademind/config.py | 180 ++++++++++++++++++++++++++++++++ trademind/engine.py | 221 ++++++++++++++++++++++++++++++++++++++++ trademind/exchange.py | 187 ++++++++++++++++++++++++++++++++++ trademind/indicators.py | 38 +++++++ trademind/strategy.py | 149 +++++++++++++++++++++++++++ trademind/trader.py | 54 ++++++++++ trademind/trainer.py | 163 +++++++++++++++++++++++++++++ 18 files changed, 1604 insertions(+) create mode 100644 .gitignore create mode 100644 Podmanfile create mode 100644 README.md create mode 100644 config.yaml create mode 100644 podman-compose.yml create mode 100644 pytest.ini create mode 100644 requirements.txt create mode 100644 tests/test_trademind.py create mode 100644 trademind/__init__.py create mode 100644 trademind/__main__.py create mode 100644 trademind/cli.py create mode 100644 trademind/config.py create mode 100644 trademind/engine.py create mode 100644 trademind/exchange.py create mode 100644 trademind/indicators.py create mode 100644 trademind/strategy.py create mode 100644 trademind/trader.py create mode 100644 trademind/trainer.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..24199af --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +__pycache__/ +*.pyc +*.pyo +.pytest_cache/ +.venv/ +state/ +tmp/ +*.log +.env +*.swp diff --git a/Podmanfile b/Podmanfile new file mode 100644 index 0000000..08f38c2 --- /dev/null +++ b/Podmanfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# Python-Dependencies zuerst (bessere Layer-Cache) +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Anwendungssource +COPY trademind ./trademind +COPY config.yaml config.yaml + +RUN useradd --create-home --shell /bin/bash trademind \ + && chown trademind:trademind /app +USER trademind + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + TM_CONFIG=/app/config.yaml + +# Default: Paper-Modus. Für Live: TM_MODE=live +ENTRYPOINT ["python", "-m", "trademind"] +CMD ["paper", "--config", "config.yaml"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e8e794 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# TradeMind + +Krypto-Tradingbot (Python) mit **Paper-/Simulationsmodus**, **Live-Trading** und **Antrainierung** der Strategie. + +Per [Podman](https://podman.io) deploybar. Alle API-Keys werden aus der `config.yaml` oder aus Environment-Variablen gelesen. + +## Features +- **Paper / Simulation**: führt Käufe & Verkäufe gegen einen Candles-Feed durch, ohne echtes Geld. +- **Live**: platziert reale Markerorders über [ccxt](https://github.com/ccxt/ccxt) (Binance, Kraken, Coinbase, KuCoin, OKX, Bybit, BitMEX …). +- **Antrainieren** (`train`): evolutionäre Optimierung der Strategie-Parameter + Signalgewichte auf Simulationsdaten (Fitness = gewichteter Return + Sharpe − Max-Drawdown). +- **Kernstrategie**: MACD-Cross + RSI + ATR-Stop-Loss. +- **Konfiguration**: eine `config.yaml`, mehrere Exchanges in `state/weights.json` persistierbar. + +## Installation (lokal / Dev) +```bash +python -m venv .venv +.venv\Scripts\activate # Windows +pip install -r requirements.txt +python -m trademind --help +``` + +## Podman (empfohlener Deploy) +```bash +# 1. Build +podman build -t trademind:latest -f Podmanfile . + +# 2. Paper-Modus (kein API-Key nötig) +podman run --rm trademind:latest paper --config /app/config.yaml + +# 3. Training (Strategie wird antrainiert) +podman run --rm -v $PWD/state:/app/state trademind:latest train --config /app/config.yaml + +# 4. Live-Trading (NUR mit echten Keys + sandbox: false) +podman run -it --rm \ + -e BINANCE_API_KEY=... \ + -e BINANCE_API_SECRET=... \ + trademind:latest live --config /app/config.yaml +``` + +### Mit `podman-compose` (optional) +```bash +pip install podman-compose # ein Mal +podman-compose build +podman-compose up paper # Simulationslauf +podman-compose run --rm train # Antrainieren +podman-compose run --rm live # Live-Zyklus +``` + +## Konfiguration (`config.yaml`) +```yaml +trading: + base_currency: BTC + quote_currency: USDT + initial_balance: 10000 + position_size_pct: 0.10 + fee_pct: 0.001 + slippage_pct: 0.0005 + timeframe: 1h + candles: 500 + +strategy: + fast_period: 12 + slow_period: 26 + rsi_overbought: 70 + rsi_oversold: 30 + atr_stop_mult: 2.5 + allow_long: true + allow_short: false + +training: + generations: 15 + population: 30 + seed: 42 + state_file: state/weights.json + +exchanges: + binance: + api_key: ${BINANCE_API_KEY} + api_secret: ${BINANCE_API_SECRET} + sandbox: true + kraken: + api_key: ${KRAKEN_API_KEY} + api_secret: ${KRAKEN_API_SECRET} + sandbox: true + coinbase: + api_key: ${COINBASE_API_KEY} + api_secret: ${COINBASE_API_SECRET} + sandbox: true + kucoin: + api_key: ${KUCOIN_API_KEY} + api_secret: ${KUCOIN_API_SECRET} + password: ${KUCOIN_PASSPHRASE} + sandbox: true +``` + +## CLI +``` +trademind paper # Paper-/Simulationslauf (Echte Marktkurse, Orders nur simuliert) +trademind live # ein Live-Zyklus (echte Orders) +trademind train # optimiert die Strategie-Parameter & -gewichte +trademind show # zeigt die geladene Konfiguration (Keys maskiert) +``` + +### Kursdatenquellen (paper & train) +Die Standard-Modus `--data auto` nutzt **echte Marktkurse** über die öffentliche +ccxt-API (Binance ist default) – keine API-Keys nötig. Bei Erreichbarkeitsproblemen +fällt automatisch auf generierte mock-Daten zurück. + +``` +trademind paper --data live --exchange kraken # zwingend live (sonst Fehler) +trademind paper --data mock # deterministische Offline-Daten +trademind train --data live --exchange coinbase # Training auf echten Kursdaten +``` + +## Antrainieren im Detail +`trademind train` erzeugt eine Population randomisierter Parameter +(`fast/slow/signal`, `rsi_*`, `atr_stop_mult`, Signalgewichte) und optimiert sie per +Elitismus + Crossover + Mutation so, dass die gewichtete Fitness gestiegen ist: + +``` +Fitness = 0.6 × TotalReturn + 0.3 × (Sharpe/10) − 0.1 × MaxDrawdown +``` + +Das Ergebnis wird in `state/weights.json` gespeichert und bei `paper` / `live` +automatisch geladen. + +## Sicherheit / Haftung +- Paper-/Simulationsmodus nutzt **keine** echten Order. +- Live-Orders sind **auf eigenes Risiko**. Es gibt keine Garantie für Erträge. +- API-Keys niemals committen. + +## Tests +```bash +pip install pytest +python -m pytest +``` diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..99a6d6d --- /dev/null +++ b/config.yaml @@ -0,0 +1,65 @@ +# TradeMind – Beispielskonfiguration +# +# Alle API-Keys können als Klartext oder als ${ENV_VAR} gesetzt werden. +# Setze zB. in podman-compose.yml: +# environment: +# - BINANCE_API_KEY=xxxxxx +# - BINANCE_API_SECRET=yyyy +# und in der config.yaml: +# api_key: ${BINANCE_API_KEY} + +trading: + base_currency: BTC + quote_currency: USDT + initial_balance: 10000 + position_size_pct: 0.10 # 10% des Portfolios pro Trade + max_open_positions: 1 + fee_pct: 0.001 # 0.1% Ordergebühr + slippage_pct: 0.0005 # 0.05% Slippage (nur Simulation/Live-Annäherung) + timeframe: 1h + candles: 500 + dry_run: true # true = Simulation, false = Live (in Kombination mit `live`) + +strategy: + fast_period: 12 + slow_period: 26 + signal_period: 9 + rsi_period: 14 + rsi_overbought: 70 + rsi_oversold: 30 + atr_period: 14 + atr_stop_mult: 2.5 + allow_long: true + allow_short: false + +training: + mode: walk-forward + train_ratio: 0.7 + generations: 15 + population: 30 + mutation_rate: 0.2 + crossover_rate: 0.4 + fitness_weight_return: 0.6 + fitness_weight_sharpe: 0.3 + fitness_weight_drawdown: 0.1 + seed: 42 + state_file: state/weights.json + +exchanges: + binance: + api_key: ${BINANCE_API_KEY} + api_secret: ${BINANCE_API_SECRET} + sandbox: true + kraken: + api_key: ${KRAKEN_API_KEY} + api_secret: ${KRAKEN_API_SECRET} + sandbox: true + coinbase: + api_key: ${COINBASE_API_KEY} + api_secret: ${COINBASE_API_SECRET} + sandbox: true + kucoin: + api_key: ${KUCOIN_API_KEY} + api_secret: ${KUCOIN_API_SECRET} + password: ${KUCOIN_PASSPHRASE} + sandbox: true diff --git a/podman-compose.yml b/podman-compose.yml new file mode 100644 index 0000000..1f108d8 --- /dev/null +++ b/podman-compose.yml @@ -0,0 +1,59 @@ +# podman-compose-Datei für TradeMind. +# +# Nutzung: +# 1. paper (Simulation mit ECHTEN Marktkursen, ohne API-Keys): +# podman-compose up paper +# +# 2. train (Strategie optimieren): +# podman-compose run --rm train +# +# 3. live (mit echten API-Keys, NUR wenn du bereit bist!): +# export BINANCE_API_KEY=xxx BINANCE_API_SECRET=yyy +# # in config.yaml: exchanges.binance.sandbox: false +# podman-compose run --rm live +# +# Alle Env-Variablen in config.yaml werden durch ${NAME} aus Environment aufgelöst. + +version: "3" + +services: + paper: + build: + context: . + dockerfile: Podmanfile + image: trademind:latest + container_name: tm-paper + command: ["paper", "--config", "/app/config.yaml"] + environment: + - TZ=Europe/Berlin + # API-Keys werden optional durchgelassen (nur in live genutzt): + # - BINANCE_API_KEY=${BINANCE_API_KEY:-} + # - BINANCE_API_SECRET=${BINANCE_API_SECRET:-} + restart: "no" + + train: + image: trademind:latest + container_name: tm-train + command: ["train", "--config", "/app/config.yaml"] + environment: + - TZ=Europe/Berlin + volumes: + - ./state:/app/state + restart: "no" + + live: + image: trademind:latest + container_name: tm-live + command: ["live", "--config", "/app/config.yaml"] + environment: + - TZ=Europe/Berlin + - BINANCE_API_KEY=${BINANCE_API_KEY:-} + - BINANCE_API_SECRET=${BINANCE_API_SECRET:-} + - KRAKEN_API_KEY=${KRAKEN_API_KEY:-} + - KRAKEN_API_SECRET=${KRAKEN_API_SECRET:-} + - COINBASE_API_KEY=${COINBASE_API_KEY:-} + - COINBASE_API_SECRET=${COINBASE_API_SECRET:-} + - KUCOIN_API_KEY=${KUCOIN_API_KEY:-} + - KUCOIN_API_SECRET=${KUCOIN_API_SECRET:-} + - KUCOIN_PASSPHRASE=${KUCOIN_PASSPHRASE:-} + restart: "no" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..816029e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +testpaths = tests +addopts = -v +filterwarnings = + ignore::DeprecationWarning diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a2a46a3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +ccxt>=4.3.0 +pandas>=2.0 +numpy>=1.26 +PyYAML>=6.0 +click>=8.1 diff --git a/tests/test_trademind.py b/tests/test_trademind.py new file mode 100644 index 0000000..21e015e --- /dev/null +++ b/tests/test_trademind.py @@ -0,0 +1,103 @@ +"""Smoke-Tests für TradeMind (ohne Netzwerk & ohne echte Keys).""" +import numpy as np +import pandas as pd +import pytest + +from trademind.config import Config, StrategyConfig, TradingConfig, TrainingConfig +from trademind.strategy import Strategy +from trademind.engine import Engine +from trademind.trader import Trader +from trademind.exchange import MockBroker, CcxtBroker +from trademind.trainer import Trainer, eval_params, random_params + + +@pytest.fixture +def cfg() -> Config: + return Config( + trading=TradingConfig(), + strategy=StrategyConfig(), + training=TrainingConfig(generations=3, population=6), + exchanges={}, + ) + + +@pytest.fixture +def candles() -> pd.DataFrame: + rng = np.random.default_rng(0) + n = 300 + close = 50_000 + np.cumsum(rng.standard_normal(n) * 500) + open_ = np.roll(close, 1) + open_[0] = 50_000 + spread = np.abs(rng.standard_normal(n)) * 100 + 5 + high = np.maximum(open_, close) + spread + low = np.minimum(open_, close) - spread + idx = pd.date_range(end=pd.Timestamp.utcnow().floor("h"), periods=n, freq="h") + return pd.DataFrame( + {"time": idx, "open": open_, "high": high, "low": low, "close": close, + "volume": rng.uniform(10, 100, n)} + ) + + +def test_strategy_prepare_and_decide(cfg, candles): + st = Strategy(cfg.strategy) + prep = st.prepare(candles) + assert all(k in prep for k in ("ema_fast", "ema_slow", "rsi", "atr")) + sigs = st.decide(prep) + assert len(sigs) == len(candles) + assert sigs[-1].price > 0 + + +def test_engine_run_produces_result(cfg, candles): + st = Strategy(cfg.strategy) + eng = Engine(cfg.trading, st) + res = eng.run(candles) + assert res.final_equity > 0 + assert isinstance(res.summary().get("num_trades"), int) + assert res.equity_curve[0] > 0 + + +def test_paper_trader_roundtrip(cfg, candles): + broker = MockBroker(seed=1) + st = Strategy(cfg.strategy) + tr = Trader(cfg, broker, st) + res = tr.simulate() + assert res.final_equity >= 0 + assert res.num_trades >= 0 + + +def test_trainer_improves_and_sets_weights(cfg, candles): + base = Strategy(cfg.strategy) + trainer = Trainer(cfg.training, base) + p0 = random_params(__import__("random").Random(1)) + before = eval_params(p0, candles, cfg.trading, base, cfg.training) + best = trainer.train(candles, cfg.trading, base) + # after: best should be >= before (elitism guarantees) + after = eval_params(best, candles, cfg.trading, base, cfg.training) + assert after >= before - 1e-6 + + +def test_data_broker_falls_back_to_mock(cfg): + """Ohne Netzwerk/Exchange muss der Fallback auf Mock-Daten greifen.""" + from trademind.cli import _data_broker + + broker = _data_broker(cfg, data="auto", exchange="binance", seed=3) + df = broker.fetch_ohlcv("BTC/USDT", "1h", 50) + assert len(df) == 50 + for col in ("open", "high", "low", "close"): + assert (df[col] > 0).all() + + +def test_mock_broker_deterministic(): + b1 = MockBroker(seed=3).fetch_ohlcv("BTC/USDT", "1h", 100) + b2 = MockBroker(seed=3).fetch_ohlcv("BTC/USDT", "1h", 100) + assert b1.equals(b2) + + +def test_strategy_set_parameter(cfg): + st = Strategy(cfg.strategy) + p = st.parameters() + assert "fast_period" in p and "w_ema_cross" in p + st.set_parameter("fast_period", 20) + assert st.cfg.fast_period == 20 + st.set_parameter("w_ema_cross", 1.5) + assert st.weights["ema_cross"] == pytest.approx(1.5) diff --git a/trademind/__init__.py b/trademind/__init__.py new file mode 100644 index 0000000..13ae811 --- /dev/null +++ b/trademind/__init__.py @@ -0,0 +1,3 @@ +"""TradeMind – Krypto-Tradingbot mit Simulations- und Live-Modus.""" + +__version__ = "0.1.0" diff --git a/trademind/__main__.py b/trademind/__main__.py new file mode 100644 index 0000000..c9a251f --- /dev/null +++ b/trademind/__main__.py @@ -0,0 +1,4 @@ +from .cli import main # noqa: F401 + +if __name__ == "__main__": + main() diff --git a/trademind/cli.py b/trademind/cli.py new file mode 100644 index 0000000..692b0f4 --- /dev/null +++ b/trademind/cli.py @@ -0,0 +1,199 @@ +"""Command-line-Schnittstelle (click).""" + +from __future__ import annotations + +import json +import logging +import sys + +import click + +from . import __version__ +from .config import Config, load as load_config +from .engine import save_state +from .exchange import MockBroker, build_exchange +from .strategy import Strategy +from .trader import Trader +from .trainer import Trainer, apply_params + +log = logging.getLogger("trademind.cli") + + +def _setup_logging(verbose: bool) -> None: + logging.basicConfig( + level=logging.DEBUG if verbose else logging.INFO, + format="%(asctime)s %(levelname)-7s %(name)s %(message)s", + stream=sys.stderr, + ) + + +def get_strategy(cfg: Config, weights_path: str | None = None) -> Strategy: + import os + + st = Strategy(cfg.strategy) + if weights_path and os.path.exists(weights_path): + with open(weights_path, "r", encoding="utf-8") as fh: + wp = json.load(fh) + st.weights = {**st.weights, **{k: float(v) for k, v in wp.get("weights", {}).items()}} + return st + + +@click.group() +@click.version_option(__version__) +def cli() -> None: + """TradeMind – Krypto-Tradingbot (Paper/Sim + Live + Training).""" + + +@cli.command("paper") +@click.option("--config", "cfg_path", default="config.yaml", show_default=True) +@click.option("--symbol", default=None, help="zB. BTC/USDT") +@click.option("--candles", type=int, default=None) +@click.option("--data", type=click.Choice(["auto", "live", "mock"]), default="auto", + show_default=True, help="Kursdatenquelle: echte (live) oder generierte (mock)") +@click.option("--exchange", default=None, help="Exchange für Kursdaten (zB. binance, kraken)") +@click.option("--seed", type=int, default=None, help="Seed für deterministische Simulationsdaten") +@click.option("--state", default="state/paper.json") +def paper(cfg_path, symbol, candles, data, exchange, seed, state) -> None: + """Paper-/Simulationslauf: nutzt ECHTE Marktkurse (Standard), Orders bleiben simuliert. + + Falls keine Kursdaten abrufbar sind (Offline), wird automatisch auf + generierte mock-Daten zurückgefallen. + """ + cfg = load_config(cfg_path) + if symbol: + base, _, quote = symbol.partition("/") + cfg.trading.base_currency = base + cfg.trading.quote_currency = quote or "USDT" + if candles: + cfg.trading.candles = candles + + broker = _data_broker(cfg, data, exchange, seed) + log.info("Paper-Modus: Kursdaten-Quelle = %s", broker.name) + strat = get_strategy(cfg, cfg.training.state_file) + trader = Trader(cfg, broker, strat) + res = trader.simulate() + save_state(state, res, strat.parameters()) + click.echo(f"\n=== Paper-/Simulationslauf (Daten: {broker.name}) ===") + click.echo(json.dumps(res.summary(), indent=2)) + + +def _data_broker(cfg: Config, data: str, exchange: str | None, seed: int | None): + """Live-Marktdaten via ccxt (ohne Keys). Fallback auf MockBroker.""" + from .exchange import build_data_broker + + ex_name = exchange or (cfg.active_exchange().name if cfg.active_exchange() else "binance") + if data != "mock": + try: + broker = build_data_broker(ex_name) + broker.fetch_ticker(f"{cfg.trading.base_currency}/{cfg.trading.quote_currency}") + return broker + except Exception as e: # offline, Exchange down etc. + if data == "live": + raise SystemExit(f"Fehler beim Abruf der Live-Daten: {e}") + click.echo(f"Warnung: Live-Daten nicht erreichbar ({e}). Fallback: mock-Daten.", err=True) + return MockBroker(seed=seed if seed is not None else 7) + + +@cli.command("live") +@click.option("--config", "cfg_path", default="config.yaml", show_default=True) +def live(cfg_path) -> None: + """Ein Live-Zyklus: Bewertung + echte Order über die konfigurierte Exchange.""" + cfg = load_config(cfg_path) + ex = cfg.active_exchange() + if not ex or not (ex.api_key and ex.api_secret): + click.echo( + "Keine Exchange mit API-Keys konfiguriert.\n" + "Trage api_key/api_secret in config.yaml ein (oder über Env) und setze sandbox: false.", + err=True, + ) + sys.exit(2) + try: + broker = build_exchange(ex) + except Exception as e: # pragma: no cover + click.echo(f"Exchange-Init fehlgeschlagen: {e}", err=True) + sys.exit(1) + strat = get_strategy(cfg, cfg.training.state_file) + trader = Trader(cfg, broker, strat) + order = trader.live_cycle() + if order: + click.echo("Order: " + json.dumps(order, indent=2)) + else: + click.echo("Kein Handels-Signal (HOLD).") + + +@cli.command("train") +@click.option("--config", "cfg_path", default="config.yaml", show_default=True) +@click.option("--generations", type=int, default=None) +@click.option("--population", type=int, default=None) +@click.option("--data", type=click.Choice(["auto", "live", "mock"]), default="auto", + show_default=True, help="Trainingsdatenquelle: echte (live) oder generierte (mock)") +@click.option("--exchange", default=None, help="Exchange für Trainingsdaten (zB. binance, kraken)") +def train(cfg_path, generations, population, data, exchange) -> None: + """Trainiert die Strategie-Parameter auf Kursdaten (Standard: echte Marktkurse).""" + cfg = load_config(cfg_path) + if generations: + cfg.training.generations = generations + if population: + cfg.training.population = population + + broker = _data_broker(cfg, data, exchange, 42) + base_strat = get_strategy(cfg, cfg.training.state_file) + symbol = f"{cfg.trading.base_currency}/{cfg.trading.quote_currency}" + candles = broker.fetch_ohlcv(symbol, cfg.trading.timeframe, max(cfg.trading.candles, 500)) + log.info("Trainingsdaten: %s (%d Candles)", broker.name, len(candles)) + + def progress(gen: int, best: float) -> None: + click.echo(f"Generation {gen:>3}: beste Fitness = {best:.4f}") + + trainer = Trainer(cfg.training, base_strat) + params = trainer.train(candles, cfg.trading, base_strat, progress=progress) + + apply_params(cfg.strategy, params) + import os + + os.makedirs(os.path.dirname(cfg.training.state_file) or ".", exist_ok=True) + with open(cfg.training.state_file, "w", encoding="utf-8") as fh: + json.dump( + { + "params": params, + "weights": { + "ema_cross": params["w_ema_cross"], + "rsi_long": params["w_rsi_long"], + "rsi_exit": params["w_rsi_exit"], + }, + }, + fh, + indent=2, + ) + click.echo("\n=== Training abgeschlossen ===") + click.echo("Optimierte Parameter: " + json.dumps(params, indent=2)) + click.echo(f"Gewichte gespeichert in: {cfg.training.state_file}") + + +@cli.command("show") +@click.argument("path", default="config.yaml") +def show(path) -> None: + """Gibt die geladene Konfiguration aus (API-Keys werden maskiert).""" + cfg = load_config(path) + out = { + "trading": cfg.trading.__dict__, + "strategy": cfg.strategy.__dict__, + "training": cfg.training.__dict__, + "exchanges": { + n: { + "api_key": "***" if e.api_key else "", + "api_secret": "***" if e.api_secret else "", + "sandbox": e.sandbox, + } + for n, e in cfg.exchanges.items() + }, + } + click.echo(json.dumps(out, indent=2)) + + +def main() -> None: + cli() + + +if __name__ == "__main__": + main() diff --git a/trademind/config.py b/trademind/config.py new file mode 100644 index 0000000..bcb2e31 --- /dev/null +++ b/trademind/config.py @@ -0,0 +1,180 @@ +"""Lädt und validiert die YAML-Konfiguration (inkl. API-Keys aus Env).""" + +from __future__ import annotations + +import os +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +import yaml + +SUPPORTED_EXCHANGES = ( + "binance", + "kraken", + "coinbase", + "kucoin", + "bitmex", + "okx", + "bybit", +) + + +def _env(value: Optional[str]) -> str: + """Ersetzt ${ENV_VAR} Referenzen durch den jeweiligen Umgebungsvariable-Wert.""" + if value and value.startswith("${") and value.endswith("}"): + return os.environ.get(value[2:-1], "") + return value or "" + + +@dataclass +class ExchangeConfig: + name: str + api_key: str = "" + api_secret: str = "" + password: str = "" # zB. binance passphrase / kucoin passkey + sandbox: bool = True + + @classmethod + def from_dict(cls, name: str, data: Dict[str, Any]) -> "ExchangeConfig": + data = data or {} + return cls( + name=name, + api_key=_env(str(data.get("api_key", ""))), + api_secret=_env(str(data.get("api_secret", ""))), + password=_env(str(data.get("password", ""))), + sandbox=bool(data.get("sandbox", True)), + ) + + +@dataclass +class TradingConfig: + quote_currency: str = "USDT" + base_currency: str = "BTC" + initial_balance: float = 10_000.0 + position_size_pct: float = 0.10 # Anteil des Portfolios pro Trade + max_open_positions: int = 1 + fee_pct: float = 0.001 # 0.1 % pro Order (Spread/fee) + slippage_pct: float = 0.0005 + timeframe: str = "1h" + candles: int = 500 + dry_run: bool = True # True = Simulation / Paper-Trading + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TradingConfig": + data = data or {} + return cls( + quote_currency=data.get("quote_currency", "USDT"), + base_currency=data.get("base_currency", "BTC"), + initial_balance=float(data.get("initial_balance", 10_000.0)), + position_size_pct=float(data.get("position_size_pct", 0.10)), + max_open_positions=int(data.get("max_open_positions", 1)), + fee_pct=float(data.get("fee_pct", 0.001)), + slippage_pct=float(data.get("slippage_pct", 0.0005)), + timeframe=data.get("timeframe", "1h"), + candles=int(data.get("candles", 500)), + dry_run=bool(data.get("dry_run", True)), + ) + + +@dataclass +class StrategyConfig: + fast_period: int = 12 + slow_period: int = 26 + signal_period: int = 9 + rsi_period: int = 14 + rsi_overbought: float = 70.0 + rsi_oversold: float = 30.0 + atr_period: int = 14 + atr_stop_mult: float = 2.5 + allow_long: bool = True + allow_short: bool = False + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "StrategyConfig": + data = data or {} + return cls( + fast_period=int(data.get("fast_period", 12)), + slow_period=int(data.get("slow_period", 26)), + signal_period=int(data.get("signal_period", 9)), + rsi_period=int(data.get("rsi_period", 14)), + rsi_overbought=float(data.get("rsi_overbought", 70.0)), + rsi_oversold=float(data.get("rsi_oversold", 30.0)), + atr_period=int(data.get("atr_period", 14)), + atr_stop_mult=float(data.get("atr_stop_mult", 2.5)), + allow_long=bool(data.get("allow_long", True)), + allow_short=bool(data.get("allow_short", False)), + ) + + +@dataclass +class TrainingConfig: + mode: str = "walk-forward" # walk-forward | full + train_ratio: float = 0.7 + generations: int = 20 + population: int = 40 + mutation_rate: float = 0.2 + crossover_rate: float = 0.4 + fitness_weight_return: float = 0.6 + fitness_weight_sharpe: float = 0.3 + fitness_weight_drawdown: float = 0.1 + seed: int = 42 + state_file: str = "state/weights.json" + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "TrainingConfig": + data = data or {} + return cls( + mode=data.get("mode", "walk-forward"), + train_ratio=float(data.get("train_ratio", 0.7)), + generations=int(data.get("generations", 20)), + population=int(data.get("population", 40)), + mutation_rate=float(data.get("mutation_rate", 0.2)), + crossover_rate=float(data.get("crossover_rate", 0.4)), + fitness_weight_return=float(data.get("fitness_weight_return", 0.6)), + fitness_weight_sharpe=float(data.get("fitness_weight_sharpe", 0.3)), + fitness_weight_drawdown=float(data.get("fitness_weight_drawdown", 0.1)), + seed=int(data.get("seed", 42)), + state_file=data.get("state_file", "state/weights.json"), + ) + + +@dataclass +class Config: + trading: TradingConfig + strategy: StrategyConfig + training: TrainingConfig + exchanges: Dict[str, ExchangeConfig] = field(default_factory=dict) + + def active_exchange(self) -> Optional[ExchangeConfig]: + """Erste Konfiguration mit aktiver API-Anbindung (oder der ersten).""" + if not self.exchanges: + return None + for cfg in self.exchanges.values(): + if cfg.api_key and cfg.api_secret: + return cfg + return next(iter(self.exchanges.values())) + + +def load(path: str) -> Config: + with open(path, "r", encoding="utf-8") as fh: + raw = yaml.safe_load(fh) or {} + + trading = TradingConfig.from_dict(raw.get("trading", {})) + strategy = StrategyConfig.from_dict(raw.get("strategy", {})) + training = TrainingConfig.from_dict(raw.get("training", {})) + + exchanges: Dict[str, ExchangeConfig] = {} + for name, data in (raw.get("exchanges") or {}).items(): + name = name.lower() + if name not in SUPPORTED_EXCHANGES: + raise ValueError( + f"Unbekannte Exchange '{name}'. Erlaubt: {', '.join(SUPPORTED_EXCHANGES)}" + ) + exchanges[name] = ExchangeConfig.from_dict(name, data) + + return Config( + trading=trading, + strategy=strategy, + training=training, + exchanges=exchanges, + ) diff --git a/trademind/engine.py b/trademind/engine.py new file mode 100644 index 0000000..2262c38 --- /dev/null +++ b/trademind/engine.py @@ -0,0 +1,221 @@ +"""Trading-Engine: führt Long/Short über Candles aus und rechnet PnL. + +Wird sowohl für den Paper-/Simulations-Modus (live auf aktuellen Candles) als +auch für die Backtests (Historie) genutzt. Im Simulations-Modus werden Käufe & +Verkäufe nur simuliert (kein echtes Geld). +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass, asdict +from typing import Dict, List, Optional + +import numpy as np +import pandas as pd + +from .config import TradingConfig +from .strategy import Strategy + +log = logging.getLogger("trademind.engine") + + +@dataclass +class Position: + side: str # long + entry_price: float + size: float # base currency amount + entry_time: str + stop: float = 0.0 + entry_cost: float = 0.0 + + +@dataclass +class Trade: + side: str + entry_price: float + exit_price: float + size: float + entry_time: str + exit_time: str + pnl: float + pnl_pct: float + fees: float + reason: str = "" + + +@dataclass +class Result: + final_equity: float + total_return_pct: float + num_trades: int + win_rate: float + max_drawdown_pct: float + avg_win: float + avg_loss: float + sharpe: float + equity_curve: List[float] + trades: List[Trade] + + def summary(self) -> Dict: + return { + "final_equity": round(self.final_equity, 2), + "total_return_pct": round(self.total_return_pct, 3), + "num_trades": self.num_trades, + "win_rate": round(self.win_rate, 3), + "max_drawdown_pct": round(self.max_drawdown_pct, 3), + "avg_win": round(self.avg_win, 2), + "avg_loss": round(self.avg_loss, 2), + "sharpe": round(self.sharpe, 3), + } + + +class Engine: + def __init__(self, trading: TradingConfig, strategy: Strategy): + self.t = trading + self.strategy = strategy + + # --- Rechenkerne --------------------------------------------------- + def _position_size(self, equity: float, price: float) -> float: + cash = equity * self.t.position_size_pct + return cash / price if price > 0 else 0.0 + + def run(self, candles: pd.DataFrame) -> Result: + """Führt die Strategie über die Candles aus (Simulierung).""" + prep = self.strategy.prepare(candles) + signals = self.strategy.decide(prep) + + equity = self.t.initial_balance + cash = equity + pos: Optional[Position] = None + trades: List[Trade] = [] + curve: List[float] = [] + running_max = equity + + def equity_at(i: int, close: float) -> float: + nonlocal pos + if pos is None: + return cash + val = cash + pos.size * close + return val + + for i in range(1, len(candles)): + row = prep.iloc[i] + close = float(row["close"]) + sig = signals[i] + time = str(row["time"]) + + # Stop-Loss-Check am Candle (intrabar low für Long) + if pos is not None and pos.side == "long" and pos.stop > 0: + if float(row["low"]) <= pos.stop: + exit_price = min(close, pos.stop) + cash = self._realize(pos, exit_price, time, "stop_loss", cash) + trades.append(pos._trade) # type: ignore[attr-defined] + pos = None + + if pos is None and sig.action == 1: + size = self._position_size(equity_at(i, close), close) + if size > 0: + fee = size * close * self.t.fee_pct + exit_px = close * (1 - self.t.slippage_pct) + if size * close + fee <= cash: + cash -= size * exit_px + fee + pos = Position( + side="long", + entry_price=exit_px, + size=size, + entry_time=time, + stop=sig.stop, + entry_cost=fee, + ) + elif pos is not None and sig.action in (-1, 2): + exit_px = close * (1 + self.t.slippage_pct) + cash = self._realize(pos, exit_px, time, "signal_exit", cash) + trades.append(pos._trade) # type: ignore[attr-defined] + pos = None + + eq = equity_at(i, close) + curve.append(eq) + running_max = max(running_max, eq) + + # Ende: offene Position zu Schlusskurs schließen + if pos is not None: + last_close = float(candles["close"].iloc[-1]) + last_time = str(candles["time"].iloc[-1]) + cash = self._realize(pos, last_close, last_time, "end_of_data", cash) + trades.append(pos._trade) # type: ignore[attr-defined] + pos = None + final = cash + if curve: + curve[-1] = final + else: + final = cash + + return self._summarize(final, curve, trades) + + def _realize(self, pos: Position, exit_price: float, time: str, reason: str, cash: float) -> float: + """Schließt eine Position; legt das Ergebnis in pos._trade und gibt neues Cash zurück.""" + exit_fee = pos.size * exit_price * self.t.fee_pct + proceeds = pos.size * exit_price - exit_fee + gross = pos.size * (exit_price - pos.entry_price) + total_fees = exit_fee + pos.entry_cost + pnl = gross - total_fees + pnl_pct = (pnl / max(pos.size * pos.entry_price, 1e-9)) * 100 if pos.size else 0.0 + pos._trade = Trade( # type: ignore[attr-defined] + side=pos.side, + entry_price=pos.entry_price, + exit_price=exit_price, + size=pos.size, + entry_time=pos.entry_time, + exit_time=time, + pnl=pnl, + pnl_pct=pnl_pct, + fees=total_fees, + reason=reason, + ) + return cash + proceeds + + def _summarize( + self, final: float, curve: list, trades: List[Trade] + ) -> Result: + curve = curve or [self.t.initial_balance] + arr = np.array(curve, dtype=float) + peak = np.maximum.accumulate(arr) + dd = (peak - arr) / np.where(peak > 0, peak, 1) + max_dd = float(dd.max()) if len(dd) else 0.0 + rets = arr[1:] / arr[:-1] - 1 if len(arr) > 1 else np.array([0.0]) + std = float(np.std(rets)) + sharpe = float(np.mean(rets) / std * np.sqrt(len(rets))) if std > 0 else 0.0 + + wins = [t.pnl for t in trades if t.pnl > 0] + losses = [t.pnl for t in trades if t.pnl <= 0] + win_rate = (len(wins) / len(trades)) if trades else 0.0 + return Result( + final_equity=final, + total_return_pct=(final / self.t.initial_balance - 1) * 100, + num_trades=len(trades), + win_rate=win_rate, + max_drawdown_pct=max_dd * 100, + avg_win=float(np.mean(wins)) if wins else 0.0, + avg_loss=float(np.mean(losses)) if losses else 0.0, + sharpe=sharpe, + equity_curve=[round(x, 2) for x in arr], + trades=trades, + ) + + +def save_state(path: str, result: Result, params: Dict) -> None: + import os + + os.makedirs(os.path.dirname(path) or ".", exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump( + { + "summary": result.summary(), + "params": params, + "trades": [asdict(t) for t in result.trades], + }, + fh, + indent=2, + ) diff --git a/trademind/exchange.py b/trademind/exchange.py new file mode 100644 index 0000000..bd70e42 --- /dev/null +++ b/trademind/exchange.py @@ -0,0 +1,187 @@ +"""Austauschschicht: abstrakter Broker + ccxt-Implementierung + Mock. + +Der Broker versorgt die Engine mit Kursen (für Simulation) und platziert echte +Orders (nur Live-Modus). Für Backtesting/Simulation ohne Netzwerk steht ein +OfflineMock bereit. +""" + +from __future__ import annotations + +import abc +import logging +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +import pandas as pd + +from .config import ExchangeConfig + +log = logging.getLogger("trademind.exchange") + + +@dataclass +class Quote: + bid: float + ask: float + ts: str = "" + + +class Broker(abc.ABC): + name: str = "abstract" + + @abc.abstractmethod + def fetch_ohlcv( + self, symbol: str, timeframe: str, limit: int, since: Optional[int] = None + ) -> pd.DataFrame: + """Liefert OHLCV-Candles als DataFrame mit open/high/low/close/volume.""" + + @abc.abstractmethod + def fetch_ticker(self, symbol: str) -> Quote: + """Letztes Bid/Ask.""" + + @abc.abstractmethod + def create_market_order( + self, symbol: str, side: str, amount: float + ) -> Dict[str, Any]: + """Platziert eine Markerorder. side = 'buy' | 'sell'.""" + + @abc.abstractmethod + def fetch_balance(self) -> Dict[str, float]: + """Verfügbare Balancen (free).""" + + def close(self) -> None: # pragma: no cover - optional + pass + + +def build_exchange(cfg: ExchangeConfig) -> Broker: + """Erzeugt aus der Konfiguration einen konkreten Broker (via ccxt).""" + import ccxt # lazy import für schnellere Tests ohne ccxt + + if cfg.name not in ccxt.exchanges: + raise ValueError(f"ccxt kennt Exchange '{cfg.name}' nicht") + + klass = getattr(ccxt, cfg.name) + params: Dict[str, Any] = { + "apiKey": cfg.api_key, + "secret": cfg.api_secret, + "password": cfg.password, + "enableRateLimit": True, + } + broker = klass(params) + if cfg.sandbox: + broker.set_sandbox_mode(True) + broker.name = cfg.name + return broker + + +def build_data_broker(name: str) -> Broker: + """Erzeugt einen Broker nur für öffentliche Kursdaten (ohne Keys, ohne Sandbox). + + `fetch_ohlcv`/`fetch_ticker` sind öffentliche Endpunkte – ideal für den + Paper-/Simulationsmodus, der echte Marktkurse nutzt, aber keine Orders platziert. + """ + import ccxt + + if name not in ccxt.exchanges: + raise ValueError(f"ccxt kennt Exchange '{name}' nicht") + exchange = getattr(ccxt, name)({"enableRateLimit": True}) + return CcxtBroker(exchange, name) + + +class CcxtBroker(Broker): + """Wrapper rund um eine ccxt-Exchange-Instanz.""" + + def __init__(self, exchange: Any, name: str = "ccxt"): + self._ex = exchange + self.name = name + + def _symbol(self, symbol: str) -> str: + return symbol if "/" in symbol else symbol + + def fetch_ohlcv( + self, symbol: str, timeframe: str, limit: int, since: Optional[int] = None + ) -> pd.DataFrame: + raw = self._ex.fetch_ohlcv(self._symbol(symbol), timeframe, since=since, limit=limit) + df = pd.DataFrame(raw, columns=["ts", "open", "high", "low", "close", "volume"]) + df["time"] = pd.to_datetime(df["ts"], unit="ms") + return df[["time", "open", "high", "low", "close", "volume"]] + + def fetch_ticker(self, symbol: str) -> Quote: + t = self._ex.fetch_ticker(self._symbol(symbol)) + return Quote(bid=float(t.get("bid") or t.get("last")), + ask=float(t.get("ask") or t.get("last")), ts=str(t.get("timestamp", ""))) + + def create_market_order(self, symbol: str, side: str, amount: float) -> Dict[str, Any]: + log.info("LIVE order: %s %s %.8f", side, symbol, amount) + order = self._ex.create_order(self._symbol(symbol), "market", side, amount) + return {"id": order.get("id"), "side": side, "amount": amount, "price": order.get("average")} + + def fetch_balance(self) -> Dict[str, float]: + bal = self._ex.fetch_balance() + return {k: float(v.get("free") or 0.0) for k, v in bal.items() if isinstance(v, dict)} + + def close(self) -> None: + try: + self._ex.close() + except Exception: # pragma: no cover + pass + + +class MockBroker(Broker): + """Erzeugt deterministische OHLCV-Daten, damit Simulation & Backtest offline laufen.""" + + def __init__( + self, + name: str = "mock", + seed: int = 7, + start_price: float = 50_000.0, + drift: float = 0.0002, + vol: float = 0.02, + quote: Optional[Quote] = None, + ): + import numpy as np + + self._seed = seed + self._start = start_price + self._drift = drift + self._vol = vol + self._quote = quote + self.name = name + + def fetch_ohlcv( + self, symbol: str, timeframe: str, limit: int, since: Optional[int] = None + ) -> pd.DataFrame: + import numpy as np + + rng = np.random.default_rng(self._seed * 1000 + limit) + n = limit + drift = self._drift + vol = self._vol + steps = drift + vol * rng.standard_normal(n) + close = self._start * np.exp(np.cumsum(steps)) + open_ = np.roll(close, 1) + open_[0] = self._start + spread = np.abs(rng.standard_normal(n)) * self._vol * close * 0.5 + high = np.maximum(open_, close) + spread + low = np.minimum(open_, close) - spread + volume = np.abs(rng.standard_normal(n)).sum() * 10 + rng.uniform(1, 100, n) + idx = pd.date_range(end=pd.Timestamp.utcnow().floor("h"), periods=n, freq="h") + return pd.DataFrame( + {"time": idx, "open": open_, "high": high, "low": low, "close": close, "volume": volume} + ) + + def fetch_ticker(self, symbol: str) -> Quote: + if self._quote: + return self._quote + df = self.fetch_ohlcv(symbol, "1h", 1) + last = float(df["close"].iloc[-1]) + return Quote(bid=last * 0.99999, ask=last * 1.00001) + + def create_market_order(self, symbol: str, side: str, amount: float) -> Dict[str, Any]: + q = self.fetch_ticker(symbol) + price = q.ask if side == "buy" else q.bid + log.info("MOCK order: %s %s %.8f @ %.4f", side, symbol, amount, price) + return {"id": "mock", "side": side, "amount": amount, "price": price} + + def fetch_balance(self) -> Dict[str, float]: + return {} diff --git a/trademind/indicators.py b/trademind/indicators.py new file mode 100644 index 0000000..50bbd57 --- /dev/null +++ b/trademind/indicators.py @@ -0,0 +1,38 @@ +"""Technische Indikatoren (reine pandas/numpy, ohne TA-Lib).""" + +from __future__ import annotations + +import numpy as np +import pandas as pd + + +def ema(series: pd.Series, period: int) -> pd.Series: + return series.ewm(span=period, adjust=False).mean() + + +def rsi(close: pd.Series, period: int = 14) -> pd.Series: + delta = close.diff() + gain = delta.clip(lower=0.0) + loss = -delta.clip(upper=0.0) + avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean() + avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean() + rs = avg_gain / avg_loss.replace(0.0, np.nan) + out = 100 - (100 / (1 + rs)) + return out.fillna(50.0) + + +def atr(df: pd.DataFrame, period: int = 14) -> pd.Series: + high, low, close = df["high"], df["low"], df["close"] + prev_close = close.shift(1) + tr = pd.concat( + [(high - low), (high - prev_close).abs(), (low - prev_close).abs()], axis=1 + ).max(axis=1) + return tr.ewm(alpha=1 / period, adjust=False).mean() + + +def crossover(a: pd.Series, b: pd.Series) -> pd.Series: + return (a > b) & (a.shift(1) <= b.shift(1)) + + +def crossunder(a: pd.Series, b: pd.Series) -> pd.Series: + return (a < b) & (a.shift(1) >= b.shift(1)) diff --git a/trademind/strategy.py b/trademind/strategy.py new file mode 100644 index 0000000..a2aae7f --- /dev/null +++ b/trademind/strategy.py @@ -0,0 +1,149 @@ +"""Kernstrategie: Signal-Score aus EMA-Cross + RSI, gewichtet durch antrainierbare Gewichte. + +Die Gewichte werden beim Training (Parameter-Optimierung) so angepasst, dass die +Fitness (Return/Sharpe/Drawdown) gestiegen wird. Dadurch 'lernt' der Bot aus den +Simulations-Ergebnissen. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, Optional + +import numpy as np +import pandas as pd + +from .config import StrategyConfig +from .indicators import atr, crossover, crossunder, ema, rsi + + +@dataclass +class Signal: + action: int # +1 long-ein, -1 long-aus, +2 short-ein (fakultativ), 0 halten + score: float = 0.0 + price: float = 0.0 + stop: float = 0.0 + reason: str = "" + + +def default_weights() -> Dict[str, float]: + return {"ema_cross": 1.0, "rsi_long": 0.8, "rsi_exit": 0.6} + + +class Strategy: + def __init__(self, cfg: StrategyConfig, weights: Optional[Dict[str, float]] = None): + self.cfg = cfg + self.weights = {**default_weights(), **(weights or {})} + + def parameters(self) -> Dict[str, float]: + """Alle anpassbaren Parameter (für den Optimierer).""" + return { + "fast_period": self.cfg.fast_period, + "slow_period": self.cfg.slow_period, + "signal_period": self.cfg.signal_period, + "rsi_period": self.cfg.rsi_period, + "rsi_overbought": self.cfg.rsi_overbought, + "rsi_oversold": self.cfg.rsi_oversold, + "atr_stop_mult": self.cfg.atr_stop_mult, + **{f"w_{k}": v for k, v in self.weights.items()}, + } + + def set_parameter(self, key: str, value: float) -> None: + if key.startswith("w_"): + self.weights[key[2:]] = max(0.0, value) + return + if hasattr(self.cfg, key): + v = int(round(float(value))) if isinstance(getattr(self.cfg, key), int) and key != "rsi_overbought" and key != "rsi_oversold" and key != "atr_stop_mult" else float(value) + if key.endswith("_period"): + v = max(2, int(round(float(value)))) + setattr(self.cfg, key, v) + + def prepare(self, df: pd.DataFrame) -> pd.DataFrame: + """Berechnet alle Indikatoren auf dem Candles-Frame. Liefert neuen Frame.""" + out = df.copy() + c = self.cfg + out["ema_fast"] = ema(out["close"], c.fast_period) + out["ema_slow"] = ema(out["close"], c.slow_period) + out["macd"] = out["ema_fast"] - out["ema_slow"] + out["signal"] = ema(out["macd"], c.signal_period) + out["rsi"] = rsi(out["close"], c.rsi_period) + out["atr"] = atr(out, c.atr_period) + out["cross_up"] = crossover(out["macd"], out["signal"]) + out["cross_dn"] = crossunder(out["macd"], out["signal"]) + return out + + def score_row(self, row: pd.Series) -> float: + """Gewichteter Score: > 0 Kauf, < 0 Verkauf/Ausstieg.""" + w = self.weights + s = 0.0 + if row["cross_up"]: + s += w["ema_cross"] + if row["cross_dn"]: + s -= w["ema_cross"] + rsi = row["rsi"] + if rsi <= self.cfg.rsi_oversold: + s += w["rsi_long"] + if rsi >= self.cfg.rsi_overbought: + s -= w["rsi_exit"] + return s + + def decide(self, prep: pd.DataFrame) -> list[Signal]: + """Erzeugt pro Candle ein Signal (für Backtest) bzw. das letzte (Live).""" + signals: list[Signal] = [] + for i in range(len(prep)): + row = prep.iloc[i] + if i < max(self.cfg.slow_period, self.cfg.signal_period): + signals.append(Signal(0, 0.0, float(row["close"]), 0.0, "warmup")) + continue + score = self.score_row(row) + price = float(row["close"]) + stop = price - self.cfg.atr_stop_mult * row["atr"] if self.cfg.allow_long else price + if score > 0 and self.cfg.allow_long: + action = 1 + elif score < 0: + action = -1 + else: + action = 0 + reasons = [] + if row["cross_up"]: + reasons.append("macd_cross_up") + if row["rsi"] <= self.cfg.rsi_oversold: + reasons.append("rsi_oversold") + if score < 0: + if row["cross_dn"]: + reasons.append("macd_cross_down") + if row["rsi"] >= self.cfg.rsi_overbought: + reasons.append("rsi_overbought") + signals.append( + Signal( + action, + score, + price, + float(stop) if action == 1 else 0.0, + ",".join(reasons) or "neutral", + ) + ) + return signals + + def last_signal(self, prep: pd.DataFrame) -> Signal: + return self.decide(prep)[-1] + + +def fitness( + returns: np.ndarray, + final_equity: float, + initial: float, + max_drawdown: float, + w_return: float = 0.6, + w_sharpe: float = 0.3, + w_dd: float = 0.1, +) -> float: + """Fitness-Bewertung für den Optimierer (maximieren).""" + if len(returns) == 0: + return -1.0 + total_return = (final_equity / initial) - 1.0 + std = float(np.std(returns)) + sharpe = (float(np.mean(returns)) / std * np.sqrt(len(returns))) if std > 0 else 0.0 + dd_penalty = max_drawdown # 0.3 -> 0.3 Abzug + score = w_return * total_return + w_sharpe * (sharpe / 10.0) - w_dd * dd_penalty + return float(score) diff --git a/trademind/trader.py b/trademind/trader.py new file mode 100644 index 0000000..0ebb350 --- /dev/null +++ b/trademind/trader.py @@ -0,0 +1,54 @@ +"""Orchestrierung von Paper-/Simulations- und Live-Trading über den Broker. + +- Paper-Modus: Simulationslauf über die Engine (Käufe/Verkäufe werden lokal + gegen den Kurs-Feed gebucht, kein echtes Geld). +- Live-Modus: platziert echte Markerorders über die Exchange-API. +""" + +from __future__ import annotations + +import logging + +from .config import Config +from .engine import Engine, Result +from .exchange import Broker +from .strategy import Strategy + +log = logging.getLogger("trademind.trader") + + +class Trader: + def __init__(self, cfg: Config, broker: Broker, strategy: Strategy): + self.cfg = cfg + self.t = cfg.trading + self.broker = broker + self.strategy = strategy + self.engine = Engine(self.t, strategy) + + def symbol(self) -> str: + base = self.t.base_currency.upper() + quote = self.t.quote_currency.upper() + if self.broker.name == "coinbase": + return f"{base}-{quote}" + return f"{base}/{quote}" + + def fetch_candles(self): + return self.broker.fetch_ohlcv(self.symbol(), self.t.timeframe, self.t.candles) + + def simulate(self) -> Result: + """Simulationslauf (Paper-Trading) über die verfügbaren Candles.""" + candles = self.fetch_candles() + return self.engine.run(candles) + + def live_cycle(self): + """Ein Live-Zyklus: Signal bewerten und ggf. echte Order platzieren.""" + candles = self.fetch_candles() + prep = self.strategy.prepare(candles) + sig = self.strategy.last_signal(prep) + if sig.action == 1: + size = (self.t.initial_balance * self.t.position_size_pct) / max(sig.price, 1e-9) + return self.broker.create_market_order(self.symbol(), "buy", size) + if sig.action in (-1, 2): + size = (self.t.initial_balance * self.t.position_size_pct) / max(sig.price, 1e-9) + return self.broker.create_market_order(self.symbol(), "sell", size) + return None diff --git a/trademind/trainer.py b/trademind/trainer.py new file mode 100644 index 0000000..71a3561 --- /dev/null +++ b/trademind/trainer.py @@ -0,0 +1,163 @@ +"""Antrainieren: evolutionäres Optimieren der Strategie-Parameter & Signalgewichte. + +Der Bot wird über viele Simulationen/Backtests (auf Simulations-Daten) darauf +trainiert, seine Strategie-Parameter so anzupassen, dass die Fitness gestiegen +ist. Die 'trainings'-Fähigkeit kommt dadurch zustande, dass die Ergebnisse der +Simulationsläufe als Fitness-Signal (Return, Sharpe, Max Drawdown) genutzt werden. +""" + +from __future__ import annotations + +import logging +import random +from dataclasses import dataclass +from typing import Callable, Dict, List, Sequence + +import numpy as np +import pandas as pd + +from .config import StrategyConfig, TradingConfig, TrainingConfig +from .engine import Engine, Result +from .strategy import Strategy, fitness, default_weights + +log = logging.getLogger("trademind.trainer") + + +# --- Parameter-Räume ----------------------------------------------------- +def _param_bounds() -> Dict[str, tuple]: + return { + "fast_period": (5, 30), + "slow_period": (20, 60), + "signal_period": (5, 15), + "rsi_period": (7, 21), + "rsi_overbought": (60, 80), + "rsi_oversold": (20, 40), + "atr_stop_mult": (1.5, 4.0), + "w_ema_cross": (0.2, 2.0), + "w_rsi_long": (0.0, 1.5), + "w_rsi_exit": (0.0, 1.5), + } + + +def random_params(rng: random.Random) -> Dict[str, float]: + b = _param_bounds() + p = {k: rng.uniform(lo, hi) for k, (lo, hi) in b.items()} + # slow muss immer > fast sein + p["slow_period"] = max(int(p["slow_period"]), int(p["fast_period"]) + 5) + return p + + +def build_strategy(base: StrategyConfig, p: Dict[str, float]) -> Strategy: + sc = StrategyConfig( + fast_period=int(round(p["fast_period"])), + slow_period=int(round(p["slow_period"])), + signal_period=int(round(p["signal_period"])), + rsi_period=int(round(p["rsi_period"])), + rsi_overbought=float(p["rsi_overbought"]), + rsi_oversold=float(p["rsi_oversold"]), + atr_period=base.atr_period, + atr_stop_mult=float(p["atr_stop_mult"]), + allow_long=True, + allow_short=base.allow_short, + ) + w = { + "ema_cross": float(p["w_ema_cross"]), + "rsi_long": float(p["w_rsi_long"]), + "rsi_exit": float(p["w_rsi_exit"]), + } + return Strategy(sc, w) + + +def eval_params( + p: Dict[str, float], + candles: pd.DataFrame, + trading: TradingConfig, + base_strategy: Strategy, + tcfg: TrainingConfig, +) -> float: + st = build_strategy(base_strategy.cfg, p) + eng = Engine(trading, st) + try: + res: Result = eng.run(candles) + except Exception: # pragma: no cover - defensive + return -10.0 + return fitness( + np.array(res.equity_curve[1:] or [1.0]), + res.final_equity, + trading.initial_balance, + res.max_drawdown_pct / 100.0, + tcfg.fitness_weight_return, + tcfg.fitness_weight_sharpe, + tcfg.fitness_weight_drawdown, + ) + + +def mutate(p: Dict[str, float], rate: float, rng: random.Random) -> Dict[str, float]: + b = _param_bounds() + out = dict(p) + for k, (lo, hi) in b.items(): + if rng.random() < rate: + width = (hi - lo) * 0.2 + out[k] = min(hi, max(lo, p[k] + rng.uniform(-width, width))) + out["slow_period"] = max(int(out["slow_period"]), int(out["fast_period"]) + 5) + return out + + +def crossover(a: Dict[str, float], b: Dict[str, float], rng: random.Random) -> Dict[str, float]: + return {k: (a[k] if rng.random() < 0.5 else b[k]) for k in a} + + +@dataclass +class Individual: + params: Dict[str, float] + fitness: float = -1e9 + + +class Trainer: + def __init__(self, tcfg: TrainingConfig, training: Strategy): + self.tcfg = tcfg + self._weights = default_weights() + + def train( + self, + candles: pd.DataFrame, + trading: TradingConfig, + base_strategy: Strategy, + progress: Callable[[int, float], None] | None = None, + ) -> Dict[str, float]: + """Liefert optimierte Parameter (inkl. Gewichte).""" + rng = random.Random(self.tcfg.seed) + pop = [Individual(random_params(rng)) for _ in range(self.tcfg.population)] + + best_params: Dict[str, float] = pop[0].params + best_fit = -1e18 + + for gen in range(self.tcfg.generations): + for ind in pop: + ind.fitness = eval_params( + ind.params, candles, trading, base_strategy, self.tcfg + ) + ranked = sorted(pop, key=lambda x: x.fitness, reverse=True) + if ranked[0].fitness > best_fit: + best_fit = ranked[0].fitness + best_params = ranked[0].params + + if progress: + progress(gen + 1, ranked[0].fitness) + + # Elternteile (Elitismus) + Kinder + elite = ranked[: max(2, self.tcfg.population // 5)] + new_pop: List[Individual] = [Individual(dict(ind.params), ind.fitness) for ind in elite] + while len(new_pop) < self.tcfg.population: + pa, pb = rng.sample(elite, 2) + child = crossover(pa.params, pb.params, rng) + child = mutate(child, self.tcfg.mutation_rate, rng) + new_pop.append(Individual(child)) + pop = new_pop + + log.info("Training abgeschlossen. Beste Fitness: %.4f", best_fit) + return best_params + + +def apply_params(base: StrategyConfig, params: Dict[str, float]) -> Strategy: + return build_strategy(base, params)