Initial release: TradeMind crypto trading bot with paper/live modes and strategy training
This commit is contained in:
+10
@@ -0,0 +1,10 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.pytest_cache/
|
||||||
|
.venv/
|
||||||
|
state/
|
||||||
|
tmp/
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
*.swp
|
||||||
+23
@@ -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"]
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
[pytest]
|
||||||
|
testpaths = tests
|
||||||
|
addopts = -v
|
||||||
|
filterwarnings =
|
||||||
|
ignore::DeprecationWarning
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
ccxt>=4.3.0
|
||||||
|
pandas>=2.0
|
||||||
|
numpy>=1.26
|
||||||
|
PyYAML>=6.0
|
||||||
|
click>=8.1
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""TradeMind – Krypto-Tradingbot mit Simulations- und Live-Modus."""
|
||||||
|
|
||||||
|
__version__ = "0.1.0"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .cli import main # noqa: F401
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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 {}
|
||||||
@@ -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))
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user