Initial release: TradeMind crypto trading bot with paper/live modes and strategy training
This commit is contained in:
@@ -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