Initial release: TradeMind crypto trading bot with paper/live modes and strategy training

This commit is contained in:
Tobias Zimmermann
2026-08-22 11:53:59 +02:00
commit b86e614037
18 changed files with 1604 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
__pycache__/
*.pyc
*.pyo
.pytest_cache/
.venv/
state/
tmp/
*.log
.env
*.swp
+23
View File
@@ -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"]
+136
View File
@@ -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
```
+65
View File
@@ -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
+59
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
[pytest]
testpaths = tests
addopts = -v
filterwarnings =
ignore::DeprecationWarning
+5
View File
@@ -0,0 +1,5 @@
ccxt>=4.3.0
pandas>=2.0
numpy>=1.26
PyYAML>=6.0
click>=8.1
+103
View File
@@ -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)
+3
View File
@@ -0,0 +1,3 @@
"""TradeMind Krypto-Tradingbot mit Simulations- und Live-Modus."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main # noqa: F401
if __name__ == "__main__":
main()
+199
View File
@@ -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()
+180
View File
@@ -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,
)
+221
View File
@@ -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,
)
+187
View File
@@ -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 {}
+38
View File
@@ -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))
+149
View File
@@ -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)
+54
View File
@@ -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
+163
View File
@@ -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)