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

This commit is contained in:
Tobias Zimmermann
2026-08-22 11:53:59 +02:00
commit 7959dd71ff
18 changed files with 1604 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
"""TradeMind Krypto-Tradingbot mit Simulations- und Live-Modus."""
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from .cli import main # noqa: F401
if __name__ == "__main__":
main()
+199
View File
@@ -0,0 +1,199 @@
"""Command-line-Schnittstelle (click)."""
from __future__ import annotations
import json
import logging
import sys
import click
from . import __version__
from .config import Config, load as load_config
from .engine import save_state
from .exchange import MockBroker, build_exchange
from .strategy import Strategy
from .trader import Trader
from .trainer import Trainer, apply_params
log = logging.getLogger("trademind.cli")
def _setup_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)-7s %(name)s %(message)s",
stream=sys.stderr,
)
def get_strategy(cfg: Config, weights_path: str | None = None) -> Strategy:
import os
st = Strategy(cfg.strategy)
if weights_path and os.path.exists(weights_path):
with open(weights_path, "r", encoding="utf-8") as fh:
wp = json.load(fh)
st.weights = {**st.weights, **{k: float(v) for k, v in wp.get("weights", {}).items()}}
return st
@click.group()
@click.version_option(__version__)
def cli() -> None:
"""TradeMind Krypto-Tradingbot (Paper/Sim + Live + Training)."""
@cli.command("paper")
@click.option("--config", "cfg_path", default="config.yaml", show_default=True)
@click.option("--symbol", default=None, help="zB. BTC/USDT")
@click.option("--candles", type=int, default=None)
@click.option("--data", type=click.Choice(["auto", "live", "mock"]), default="auto",
show_default=True, help="Kursdatenquelle: echte (live) oder generierte (mock)")
@click.option("--exchange", default=None, help="Exchange für Kursdaten (zB. binance, kraken)")
@click.option("--seed", type=int, default=None, help="Seed für deterministische Simulationsdaten")
@click.option("--state", default="state/paper.json")
def paper(cfg_path, symbol, candles, data, exchange, seed, state) -> None:
"""Paper-/Simulationslauf: nutzt ECHTE Marktkurse (Standard), Orders bleiben simuliert.
Falls keine Kursdaten abrufbar sind (Offline), wird automatisch auf
generierte mock-Daten zurückgefallen.
"""
cfg = load_config(cfg_path)
if symbol:
base, _, quote = symbol.partition("/")
cfg.trading.base_currency = base
cfg.trading.quote_currency = quote or "USDT"
if candles:
cfg.trading.candles = candles
broker = _data_broker(cfg, data, exchange, seed)
log.info("Paper-Modus: Kursdaten-Quelle = %s", broker.name)
strat = get_strategy(cfg, cfg.training.state_file)
trader = Trader(cfg, broker, strat)
res = trader.simulate()
save_state(state, res, strat.parameters())
click.echo(f"\n=== Paper-/Simulationslauf (Daten: {broker.name}) ===")
click.echo(json.dumps(res.summary(), indent=2))
def _data_broker(cfg: Config, data: str, exchange: str | None, seed: int | None):
"""Live-Marktdaten via ccxt (ohne Keys). Fallback auf MockBroker."""
from .exchange import build_data_broker
ex_name = exchange or (cfg.active_exchange().name if cfg.active_exchange() else "binance")
if data != "mock":
try:
broker = build_data_broker(ex_name)
broker.fetch_ticker(f"{cfg.trading.base_currency}/{cfg.trading.quote_currency}")
return broker
except Exception as e: # offline, Exchange down etc.
if data == "live":
raise SystemExit(f"Fehler beim Abruf der Live-Daten: {e}")
click.echo(f"Warnung: Live-Daten nicht erreichbar ({e}). Fallback: mock-Daten.", err=True)
return MockBroker(seed=seed if seed is not None else 7)
@cli.command("live")
@click.option("--config", "cfg_path", default="config.yaml", show_default=True)
def live(cfg_path) -> None:
"""Ein Live-Zyklus: Bewertung + echte Order über die konfigurierte Exchange."""
cfg = load_config(cfg_path)
ex = cfg.active_exchange()
if not ex or not (ex.api_key and ex.api_secret):
click.echo(
"Keine Exchange mit API-Keys konfiguriert.\n"
"Trage api_key/api_secret in config.yaml ein (oder über Env) und setze sandbox: false.",
err=True,
)
sys.exit(2)
try:
broker = build_exchange(ex)
except Exception as e: # pragma: no cover
click.echo(f"Exchange-Init fehlgeschlagen: {e}", err=True)
sys.exit(1)
strat = get_strategy(cfg, cfg.training.state_file)
trader = Trader(cfg, broker, strat)
order = trader.live_cycle()
if order:
click.echo("Order: " + json.dumps(order, indent=2))
else:
click.echo("Kein Handels-Signal (HOLD).")
@cli.command("train")
@click.option("--config", "cfg_path", default="config.yaml", show_default=True)
@click.option("--generations", type=int, default=None)
@click.option("--population", type=int, default=None)
@click.option("--data", type=click.Choice(["auto", "live", "mock"]), default="auto",
show_default=True, help="Trainingsdatenquelle: echte (live) oder generierte (mock)")
@click.option("--exchange", default=None, help="Exchange für Trainingsdaten (zB. binance, kraken)")
def train(cfg_path, generations, population, data, exchange) -> None:
"""Trainiert die Strategie-Parameter auf Kursdaten (Standard: echte Marktkurse)."""
cfg = load_config(cfg_path)
if generations:
cfg.training.generations = generations
if population:
cfg.training.population = population
broker = _data_broker(cfg, data, exchange, 42)
base_strat = get_strategy(cfg, cfg.training.state_file)
symbol = f"{cfg.trading.base_currency}/{cfg.trading.quote_currency}"
candles = broker.fetch_ohlcv(symbol, cfg.trading.timeframe, max(cfg.trading.candles, 500))
log.info("Trainingsdaten: %s (%d Candles)", broker.name, len(candles))
def progress(gen: int, best: float) -> None:
click.echo(f"Generation {gen:>3}: beste Fitness = {best:.4f}")
trainer = Trainer(cfg.training, base_strat)
params = trainer.train(candles, cfg.trading, base_strat, progress=progress)
apply_params(cfg.strategy, params)
import os
os.makedirs(os.path.dirname(cfg.training.state_file) or ".", exist_ok=True)
with open(cfg.training.state_file, "w", encoding="utf-8") as fh:
json.dump(
{
"params": params,
"weights": {
"ema_cross": params["w_ema_cross"],
"rsi_long": params["w_rsi_long"],
"rsi_exit": params["w_rsi_exit"],
},
},
fh,
indent=2,
)
click.echo("\n=== Training abgeschlossen ===")
click.echo("Optimierte Parameter: " + json.dumps(params, indent=2))
click.echo(f"Gewichte gespeichert in: {cfg.training.state_file}")
@cli.command("show")
@click.argument("path", default="config.yaml")
def show(path) -> None:
"""Gibt die geladene Konfiguration aus (API-Keys werden maskiert)."""
cfg = load_config(path)
out = {
"trading": cfg.trading.__dict__,
"strategy": cfg.strategy.__dict__,
"training": cfg.training.__dict__,
"exchanges": {
n: {
"api_key": "***" if e.api_key else "",
"api_secret": "***" if e.api_secret else "",
"sandbox": e.sandbox,
}
for n, e in cfg.exchanges.items()
},
}
click.echo(json.dumps(out, indent=2))
def main() -> None:
cli()
if __name__ == "__main__":
main()
+180
View File
@@ -0,0 +1,180 @@
"""Lädt und validiert die YAML-Konfiguration (inkl. API-Keys aus Env)."""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional
import yaml
SUPPORTED_EXCHANGES = (
"binance",
"kraken",
"coinbase",
"kucoin",
"bitmex",
"okx",
"bybit",
)
def _env(value: Optional[str]) -> str:
"""Ersetzt ${ENV_VAR} Referenzen durch den jeweiligen Umgebungsvariable-Wert."""
if value and value.startswith("${") and value.endswith("}"):
return os.environ.get(value[2:-1], "")
return value or ""
@dataclass
class ExchangeConfig:
name: str
api_key: str = ""
api_secret: str = ""
password: str = "" # zB. binance passphrase / kucoin passkey
sandbox: bool = True
@classmethod
def from_dict(cls, name: str, data: Dict[str, Any]) -> "ExchangeConfig":
data = data or {}
return cls(
name=name,
api_key=_env(str(data.get("api_key", ""))),
api_secret=_env(str(data.get("api_secret", ""))),
password=_env(str(data.get("password", ""))),
sandbox=bool(data.get("sandbox", True)),
)
@dataclass
class TradingConfig:
quote_currency: str = "USDT"
base_currency: str = "BTC"
initial_balance: float = 10_000.0
position_size_pct: float = 0.10 # Anteil des Portfolios pro Trade
max_open_positions: int = 1
fee_pct: float = 0.001 # 0.1 % pro Order (Spread/fee)
slippage_pct: float = 0.0005
timeframe: str = "1h"
candles: int = 500
dry_run: bool = True # True = Simulation / Paper-Trading
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TradingConfig":
data = data or {}
return cls(
quote_currency=data.get("quote_currency", "USDT"),
base_currency=data.get("base_currency", "BTC"),
initial_balance=float(data.get("initial_balance", 10_000.0)),
position_size_pct=float(data.get("position_size_pct", 0.10)),
max_open_positions=int(data.get("max_open_positions", 1)),
fee_pct=float(data.get("fee_pct", 0.001)),
slippage_pct=float(data.get("slippage_pct", 0.0005)),
timeframe=data.get("timeframe", "1h"),
candles=int(data.get("candles", 500)),
dry_run=bool(data.get("dry_run", True)),
)
@dataclass
class StrategyConfig:
fast_period: int = 12
slow_period: int = 26
signal_period: int = 9
rsi_period: int = 14
rsi_overbought: float = 70.0
rsi_oversold: float = 30.0
atr_period: int = 14
atr_stop_mult: float = 2.5
allow_long: bool = True
allow_short: bool = False
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "StrategyConfig":
data = data or {}
return cls(
fast_period=int(data.get("fast_period", 12)),
slow_period=int(data.get("slow_period", 26)),
signal_period=int(data.get("signal_period", 9)),
rsi_period=int(data.get("rsi_period", 14)),
rsi_overbought=float(data.get("rsi_overbought", 70.0)),
rsi_oversold=float(data.get("rsi_oversold", 30.0)),
atr_period=int(data.get("atr_period", 14)),
atr_stop_mult=float(data.get("atr_stop_mult", 2.5)),
allow_long=bool(data.get("allow_long", True)),
allow_short=bool(data.get("allow_short", False)),
)
@dataclass
class TrainingConfig:
mode: str = "walk-forward" # walk-forward | full
train_ratio: float = 0.7
generations: int = 20
population: int = 40
mutation_rate: float = 0.2
crossover_rate: float = 0.4
fitness_weight_return: float = 0.6
fitness_weight_sharpe: float = 0.3
fitness_weight_drawdown: float = 0.1
seed: int = 42
state_file: str = "state/weights.json"
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "TrainingConfig":
data = data or {}
return cls(
mode=data.get("mode", "walk-forward"),
train_ratio=float(data.get("train_ratio", 0.7)),
generations=int(data.get("generations", 20)),
population=int(data.get("population", 40)),
mutation_rate=float(data.get("mutation_rate", 0.2)),
crossover_rate=float(data.get("crossover_rate", 0.4)),
fitness_weight_return=float(data.get("fitness_weight_return", 0.6)),
fitness_weight_sharpe=float(data.get("fitness_weight_sharpe", 0.3)),
fitness_weight_drawdown=float(data.get("fitness_weight_drawdown", 0.1)),
seed=int(data.get("seed", 42)),
state_file=data.get("state_file", "state/weights.json"),
)
@dataclass
class Config:
trading: TradingConfig
strategy: StrategyConfig
training: TrainingConfig
exchanges: Dict[str, ExchangeConfig] = field(default_factory=dict)
def active_exchange(self) -> Optional[ExchangeConfig]:
"""Erste Konfiguration mit aktiver API-Anbindung (oder der ersten)."""
if not self.exchanges:
return None
for cfg in self.exchanges.values():
if cfg.api_key and cfg.api_secret:
return cfg
return next(iter(self.exchanges.values()))
def load(path: str) -> Config:
with open(path, "r", encoding="utf-8") as fh:
raw = yaml.safe_load(fh) or {}
trading = TradingConfig.from_dict(raw.get("trading", {}))
strategy = StrategyConfig.from_dict(raw.get("strategy", {}))
training = TrainingConfig.from_dict(raw.get("training", {}))
exchanges: Dict[str, ExchangeConfig] = {}
for name, data in (raw.get("exchanges") or {}).items():
name = name.lower()
if name not in SUPPORTED_EXCHANGES:
raise ValueError(
f"Unbekannte Exchange '{name}'. Erlaubt: {', '.join(SUPPORTED_EXCHANGES)}"
)
exchanges[name] = ExchangeConfig.from_dict(name, data)
return Config(
trading=trading,
strategy=strategy,
training=training,
exchanges=exchanges,
)
+221
View File
@@ -0,0 +1,221 @@
"""Trading-Engine: führt Long/Short über Candles aus und rechnet PnL.
Wird sowohl für den Paper-/Simulations-Modus (live auf aktuellen Candles) als
auch für die Backtests (Historie) genutzt. Im Simulations-Modus werden Käufe &
Verkäufe nur simuliert (kein echtes Geld).
"""
from __future__ import annotations
import json
import logging
from dataclasses import dataclass, asdict
from typing import Dict, List, Optional
import numpy as np
import pandas as pd
from .config import TradingConfig
from .strategy import Strategy
log = logging.getLogger("trademind.engine")
@dataclass
class Position:
side: str # long
entry_price: float
size: float # base currency amount
entry_time: str
stop: float = 0.0
entry_cost: float = 0.0
@dataclass
class Trade:
side: str
entry_price: float
exit_price: float
size: float
entry_time: str
exit_time: str
pnl: float
pnl_pct: float
fees: float
reason: str = ""
@dataclass
class Result:
final_equity: float
total_return_pct: float
num_trades: int
win_rate: float
max_drawdown_pct: float
avg_win: float
avg_loss: float
sharpe: float
equity_curve: List[float]
trades: List[Trade]
def summary(self) -> Dict:
return {
"final_equity": round(self.final_equity, 2),
"total_return_pct": round(self.total_return_pct, 3),
"num_trades": self.num_trades,
"win_rate": round(self.win_rate, 3),
"max_drawdown_pct": round(self.max_drawdown_pct, 3),
"avg_win": round(self.avg_win, 2),
"avg_loss": round(self.avg_loss, 2),
"sharpe": round(self.sharpe, 3),
}
class Engine:
def __init__(self, trading: TradingConfig, strategy: Strategy):
self.t = trading
self.strategy = strategy
# --- Rechenkerne ---------------------------------------------------
def _position_size(self, equity: float, price: float) -> float:
cash = equity * self.t.position_size_pct
return cash / price if price > 0 else 0.0
def run(self, candles: pd.DataFrame) -> Result:
"""Führt die Strategie über die Candles aus (Simulierung)."""
prep = self.strategy.prepare(candles)
signals = self.strategy.decide(prep)
equity = self.t.initial_balance
cash = equity
pos: Optional[Position] = None
trades: List[Trade] = []
curve: List[float] = []
running_max = equity
def equity_at(i: int, close: float) -> float:
nonlocal pos
if pos is None:
return cash
val = cash + pos.size * close
return val
for i in range(1, len(candles)):
row = prep.iloc[i]
close = float(row["close"])
sig = signals[i]
time = str(row["time"])
# Stop-Loss-Check am Candle (intrabar low für Long)
if pos is not None and pos.side == "long" and pos.stop > 0:
if float(row["low"]) <= pos.stop:
exit_price = min(close, pos.stop)
cash = self._realize(pos, exit_price, time, "stop_loss", cash)
trades.append(pos._trade) # type: ignore[attr-defined]
pos = None
if pos is None and sig.action == 1:
size = self._position_size(equity_at(i, close), close)
if size > 0:
fee = size * close * self.t.fee_pct
exit_px = close * (1 - self.t.slippage_pct)
if size * close + fee <= cash:
cash -= size * exit_px + fee
pos = Position(
side="long",
entry_price=exit_px,
size=size,
entry_time=time,
stop=sig.stop,
entry_cost=fee,
)
elif pos is not None and sig.action in (-1, 2):
exit_px = close * (1 + self.t.slippage_pct)
cash = self._realize(pos, exit_px, time, "signal_exit", cash)
trades.append(pos._trade) # type: ignore[attr-defined]
pos = None
eq = equity_at(i, close)
curve.append(eq)
running_max = max(running_max, eq)
# Ende: offene Position zu Schlusskurs schließen
if pos is not None:
last_close = float(candles["close"].iloc[-1])
last_time = str(candles["time"].iloc[-1])
cash = self._realize(pos, last_close, last_time, "end_of_data", cash)
trades.append(pos._trade) # type: ignore[attr-defined]
pos = None
final = cash
if curve:
curve[-1] = final
else:
final = cash
return self._summarize(final, curve, trades)
def _realize(self, pos: Position, exit_price: float, time: str, reason: str, cash: float) -> float:
"""Schließt eine Position; legt das Ergebnis in pos._trade und gibt neues Cash zurück."""
exit_fee = pos.size * exit_price * self.t.fee_pct
proceeds = pos.size * exit_price - exit_fee
gross = pos.size * (exit_price - pos.entry_price)
total_fees = exit_fee + pos.entry_cost
pnl = gross - total_fees
pnl_pct = (pnl / max(pos.size * pos.entry_price, 1e-9)) * 100 if pos.size else 0.0
pos._trade = Trade( # type: ignore[attr-defined]
side=pos.side,
entry_price=pos.entry_price,
exit_price=exit_price,
size=pos.size,
entry_time=pos.entry_time,
exit_time=time,
pnl=pnl,
pnl_pct=pnl_pct,
fees=total_fees,
reason=reason,
)
return cash + proceeds
def _summarize(
self, final: float, curve: list, trades: List[Trade]
) -> Result:
curve = curve or [self.t.initial_balance]
arr = np.array(curve, dtype=float)
peak = np.maximum.accumulate(arr)
dd = (peak - arr) / np.where(peak > 0, peak, 1)
max_dd = float(dd.max()) if len(dd) else 0.0
rets = arr[1:] / arr[:-1] - 1 if len(arr) > 1 else np.array([0.0])
std = float(np.std(rets))
sharpe = float(np.mean(rets) / std * np.sqrt(len(rets))) if std > 0 else 0.0
wins = [t.pnl for t in trades if t.pnl > 0]
losses = [t.pnl for t in trades if t.pnl <= 0]
win_rate = (len(wins) / len(trades)) if trades else 0.0
return Result(
final_equity=final,
total_return_pct=(final / self.t.initial_balance - 1) * 100,
num_trades=len(trades),
win_rate=win_rate,
max_drawdown_pct=max_dd * 100,
avg_win=float(np.mean(wins)) if wins else 0.0,
avg_loss=float(np.mean(losses)) if losses else 0.0,
sharpe=sharpe,
equity_curve=[round(x, 2) for x in arr],
trades=trades,
)
def save_state(path: str, result: Result, params: Dict) -> None:
import os
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
with open(path, "w", encoding="utf-8") as fh:
json.dump(
{
"summary": result.summary(),
"params": params,
"trades": [asdict(t) for t in result.trades],
},
fh,
indent=2,
)
+187
View File
@@ -0,0 +1,187 @@
"""Austauschschicht: abstrakter Broker + ccxt-Implementierung + Mock.
Der Broker versorgt die Engine mit Kursen (für Simulation) und platziert echte
Orders (nur Live-Modus). Für Backtesting/Simulation ohne Netzwerk steht ein
OfflineMock bereit.
"""
from __future__ import annotations
import abc
import logging
from dataclasses import dataclass
from typing import Any, Dict, List, Optional
import pandas as pd
from .config import ExchangeConfig
log = logging.getLogger("trademind.exchange")
@dataclass
class Quote:
bid: float
ask: float
ts: str = ""
class Broker(abc.ABC):
name: str = "abstract"
@abc.abstractmethod
def fetch_ohlcv(
self, symbol: str, timeframe: str, limit: int, since: Optional[int] = None
) -> pd.DataFrame:
"""Liefert OHLCV-Candles als DataFrame mit open/high/low/close/volume."""
@abc.abstractmethod
def fetch_ticker(self, symbol: str) -> Quote:
"""Letztes Bid/Ask."""
@abc.abstractmethod
def create_market_order(
self, symbol: str, side: str, amount: float
) -> Dict[str, Any]:
"""Platziert eine Markerorder. side = 'buy' | 'sell'."""
@abc.abstractmethod
def fetch_balance(self) -> Dict[str, float]:
"""Verfügbare Balancen (free)."""
def close(self) -> None: # pragma: no cover - optional
pass
def build_exchange(cfg: ExchangeConfig) -> Broker:
"""Erzeugt aus der Konfiguration einen konkreten Broker (via ccxt)."""
import ccxt # lazy import für schnellere Tests ohne ccxt
if cfg.name not in ccxt.exchanges:
raise ValueError(f"ccxt kennt Exchange '{cfg.name}' nicht")
klass = getattr(ccxt, cfg.name)
params: Dict[str, Any] = {
"apiKey": cfg.api_key,
"secret": cfg.api_secret,
"password": cfg.password,
"enableRateLimit": True,
}
broker = klass(params)
if cfg.sandbox:
broker.set_sandbox_mode(True)
broker.name = cfg.name
return broker
def build_data_broker(name: str) -> Broker:
"""Erzeugt einen Broker nur für öffentliche Kursdaten (ohne Keys, ohne Sandbox).
`fetch_ohlcv`/`fetch_ticker` sind öffentliche Endpunkte ideal für den
Paper-/Simulationsmodus, der echte Marktkurse nutzt, aber keine Orders platziert.
"""
import ccxt
if name not in ccxt.exchanges:
raise ValueError(f"ccxt kennt Exchange '{name}' nicht")
exchange = getattr(ccxt, name)({"enableRateLimit": True})
return CcxtBroker(exchange, name)
class CcxtBroker(Broker):
"""Wrapper rund um eine ccxt-Exchange-Instanz."""
def __init__(self, exchange: Any, name: str = "ccxt"):
self._ex = exchange
self.name = name
def _symbol(self, symbol: str) -> str:
return symbol if "/" in symbol else symbol
def fetch_ohlcv(
self, symbol: str, timeframe: str, limit: int, since: Optional[int] = None
) -> pd.DataFrame:
raw = self._ex.fetch_ohlcv(self._symbol(symbol), timeframe, since=since, limit=limit)
df = pd.DataFrame(raw, columns=["ts", "open", "high", "low", "close", "volume"])
df["time"] = pd.to_datetime(df["ts"], unit="ms")
return df[["time", "open", "high", "low", "close", "volume"]]
def fetch_ticker(self, symbol: str) -> Quote:
t = self._ex.fetch_ticker(self._symbol(symbol))
return Quote(bid=float(t.get("bid") or t.get("last")),
ask=float(t.get("ask") or t.get("last")), ts=str(t.get("timestamp", "")))
def create_market_order(self, symbol: str, side: str, amount: float) -> Dict[str, Any]:
log.info("LIVE order: %s %s %.8f", side, symbol, amount)
order = self._ex.create_order(self._symbol(symbol), "market", side, amount)
return {"id": order.get("id"), "side": side, "amount": amount, "price": order.get("average")}
def fetch_balance(self) -> Dict[str, float]:
bal = self._ex.fetch_balance()
return {k: float(v.get("free") or 0.0) for k, v in bal.items() if isinstance(v, dict)}
def close(self) -> None:
try:
self._ex.close()
except Exception: # pragma: no cover
pass
class MockBroker(Broker):
"""Erzeugt deterministische OHLCV-Daten, damit Simulation & Backtest offline laufen."""
def __init__(
self,
name: str = "mock",
seed: int = 7,
start_price: float = 50_000.0,
drift: float = 0.0002,
vol: float = 0.02,
quote: Optional[Quote] = None,
):
import numpy as np
self._seed = seed
self._start = start_price
self._drift = drift
self._vol = vol
self._quote = quote
self.name = name
def fetch_ohlcv(
self, symbol: str, timeframe: str, limit: int, since: Optional[int] = None
) -> pd.DataFrame:
import numpy as np
rng = np.random.default_rng(self._seed * 1000 + limit)
n = limit
drift = self._drift
vol = self._vol
steps = drift + vol * rng.standard_normal(n)
close = self._start * np.exp(np.cumsum(steps))
open_ = np.roll(close, 1)
open_[0] = self._start
spread = np.abs(rng.standard_normal(n)) * self._vol * close * 0.5
high = np.maximum(open_, close) + spread
low = np.minimum(open_, close) - spread
volume = np.abs(rng.standard_normal(n)).sum() * 10 + rng.uniform(1, 100, n)
idx = pd.date_range(end=pd.Timestamp.utcnow().floor("h"), periods=n, freq="h")
return pd.DataFrame(
{"time": idx, "open": open_, "high": high, "low": low, "close": close, "volume": volume}
)
def fetch_ticker(self, symbol: str) -> Quote:
if self._quote:
return self._quote
df = self.fetch_ohlcv(symbol, "1h", 1)
last = float(df["close"].iloc[-1])
return Quote(bid=last * 0.99999, ask=last * 1.00001)
def create_market_order(self, symbol: str, side: str, amount: float) -> Dict[str, Any]:
q = self.fetch_ticker(symbol)
price = q.ask if side == "buy" else q.bid
log.info("MOCK order: %s %s %.8f @ %.4f", side, symbol, amount, price)
return {"id": "mock", "side": side, "amount": amount, "price": price}
def fetch_balance(self) -> Dict[str, float]:
return {}
+38
View File
@@ -0,0 +1,38 @@
"""Technische Indikatoren (reine pandas/numpy, ohne TA-Lib)."""
from __future__ import annotations
import numpy as np
import pandas as pd
def ema(series: pd.Series, period: int) -> pd.Series:
return series.ewm(span=period, adjust=False).mean()
def rsi(close: pd.Series, period: int = 14) -> pd.Series:
delta = close.diff()
gain = delta.clip(lower=0.0)
loss = -delta.clip(upper=0.0)
avg_gain = gain.ewm(alpha=1 / period, adjust=False).mean()
avg_loss = loss.ewm(alpha=1 / period, adjust=False).mean()
rs = avg_gain / avg_loss.replace(0.0, np.nan)
out = 100 - (100 / (1 + rs))
return out.fillna(50.0)
def atr(df: pd.DataFrame, period: int = 14) -> pd.Series:
high, low, close = df["high"], df["low"], df["close"]
prev_close = close.shift(1)
tr = pd.concat(
[(high - low), (high - prev_close).abs(), (low - prev_close).abs()], axis=1
).max(axis=1)
return tr.ewm(alpha=1 / period, adjust=False).mean()
def crossover(a: pd.Series, b: pd.Series) -> pd.Series:
return (a > b) & (a.shift(1) <= b.shift(1))
def crossunder(a: pd.Series, b: pd.Series) -> pd.Series:
return (a < b) & (a.shift(1) >= b.shift(1))
+149
View File
@@ -0,0 +1,149 @@
"""Kernstrategie: Signal-Score aus EMA-Cross + RSI, gewichtet durch antrainierbare Gewichte.
Die Gewichte werden beim Training (Parameter-Optimierung) so angepasst, dass die
Fitness (Return/Sharpe/Drawdown) gestiegen wird. Dadurch 'lernt' der Bot aus den
Simulations-Ergebnissen.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Dict, Optional
import numpy as np
import pandas as pd
from .config import StrategyConfig
from .indicators import atr, crossover, crossunder, ema, rsi
@dataclass
class Signal:
action: int # +1 long-ein, -1 long-aus, +2 short-ein (fakultativ), 0 halten
score: float = 0.0
price: float = 0.0
stop: float = 0.0
reason: str = ""
def default_weights() -> Dict[str, float]:
return {"ema_cross": 1.0, "rsi_long": 0.8, "rsi_exit": 0.6}
class Strategy:
def __init__(self, cfg: StrategyConfig, weights: Optional[Dict[str, float]] = None):
self.cfg = cfg
self.weights = {**default_weights(), **(weights or {})}
def parameters(self) -> Dict[str, float]:
"""Alle anpassbaren Parameter (für den Optimierer)."""
return {
"fast_period": self.cfg.fast_period,
"slow_period": self.cfg.slow_period,
"signal_period": self.cfg.signal_period,
"rsi_period": self.cfg.rsi_period,
"rsi_overbought": self.cfg.rsi_overbought,
"rsi_oversold": self.cfg.rsi_oversold,
"atr_stop_mult": self.cfg.atr_stop_mult,
**{f"w_{k}": v for k, v in self.weights.items()},
}
def set_parameter(self, key: str, value: float) -> None:
if key.startswith("w_"):
self.weights[key[2:]] = max(0.0, value)
return
if hasattr(self.cfg, key):
v = int(round(float(value))) if isinstance(getattr(self.cfg, key), int) and key != "rsi_overbought" and key != "rsi_oversold" and key != "atr_stop_mult" else float(value)
if key.endswith("_period"):
v = max(2, int(round(float(value))))
setattr(self.cfg, key, v)
def prepare(self, df: pd.DataFrame) -> pd.DataFrame:
"""Berechnet alle Indikatoren auf dem Candles-Frame. Liefert neuen Frame."""
out = df.copy()
c = self.cfg
out["ema_fast"] = ema(out["close"], c.fast_period)
out["ema_slow"] = ema(out["close"], c.slow_period)
out["macd"] = out["ema_fast"] - out["ema_slow"]
out["signal"] = ema(out["macd"], c.signal_period)
out["rsi"] = rsi(out["close"], c.rsi_period)
out["atr"] = atr(out, c.atr_period)
out["cross_up"] = crossover(out["macd"], out["signal"])
out["cross_dn"] = crossunder(out["macd"], out["signal"])
return out
def score_row(self, row: pd.Series) -> float:
"""Gewichteter Score: > 0 Kauf, < 0 Verkauf/Ausstieg."""
w = self.weights
s = 0.0
if row["cross_up"]:
s += w["ema_cross"]
if row["cross_dn"]:
s -= w["ema_cross"]
rsi = row["rsi"]
if rsi <= self.cfg.rsi_oversold:
s += w["rsi_long"]
if rsi >= self.cfg.rsi_overbought:
s -= w["rsi_exit"]
return s
def decide(self, prep: pd.DataFrame) -> list[Signal]:
"""Erzeugt pro Candle ein Signal (für Backtest) bzw. das letzte (Live)."""
signals: list[Signal] = []
for i in range(len(prep)):
row = prep.iloc[i]
if i < max(self.cfg.slow_period, self.cfg.signal_period):
signals.append(Signal(0, 0.0, float(row["close"]), 0.0, "warmup"))
continue
score = self.score_row(row)
price = float(row["close"])
stop = price - self.cfg.atr_stop_mult * row["atr"] if self.cfg.allow_long else price
if score > 0 and self.cfg.allow_long:
action = 1
elif score < 0:
action = -1
else:
action = 0
reasons = []
if row["cross_up"]:
reasons.append("macd_cross_up")
if row["rsi"] <= self.cfg.rsi_oversold:
reasons.append("rsi_oversold")
if score < 0:
if row["cross_dn"]:
reasons.append("macd_cross_down")
if row["rsi"] >= self.cfg.rsi_overbought:
reasons.append("rsi_overbought")
signals.append(
Signal(
action,
score,
price,
float(stop) if action == 1 else 0.0,
",".join(reasons) or "neutral",
)
)
return signals
def last_signal(self, prep: pd.DataFrame) -> Signal:
return self.decide(prep)[-1]
def fitness(
returns: np.ndarray,
final_equity: float,
initial: float,
max_drawdown: float,
w_return: float = 0.6,
w_sharpe: float = 0.3,
w_dd: float = 0.1,
) -> float:
"""Fitness-Bewertung für den Optimierer (maximieren)."""
if len(returns) == 0:
return -1.0
total_return = (final_equity / initial) - 1.0
std = float(np.std(returns))
sharpe = (float(np.mean(returns)) / std * np.sqrt(len(returns))) if std > 0 else 0.0
dd_penalty = max_drawdown # 0.3 -> 0.3 Abzug
score = w_return * total_return + w_sharpe * (sharpe / 10.0) - w_dd * dd_penalty
return float(score)
+54
View File
@@ -0,0 +1,54 @@
"""Orchestrierung von Paper-/Simulations- und Live-Trading über den Broker.
- Paper-Modus: Simulationslauf über die Engine (Käufe/Verkäufe werden lokal
gegen den Kurs-Feed gebucht, kein echtes Geld).
- Live-Modus: platziert echte Markerorders über die Exchange-API.
"""
from __future__ import annotations
import logging
from .config import Config
from .engine import Engine, Result
from .exchange import Broker
from .strategy import Strategy
log = logging.getLogger("trademind.trader")
class Trader:
def __init__(self, cfg: Config, broker: Broker, strategy: Strategy):
self.cfg = cfg
self.t = cfg.trading
self.broker = broker
self.strategy = strategy
self.engine = Engine(self.t, strategy)
def symbol(self) -> str:
base = self.t.base_currency.upper()
quote = self.t.quote_currency.upper()
if self.broker.name == "coinbase":
return f"{base}-{quote}"
return f"{base}/{quote}"
def fetch_candles(self):
return self.broker.fetch_ohlcv(self.symbol(), self.t.timeframe, self.t.candles)
def simulate(self) -> Result:
"""Simulationslauf (Paper-Trading) über die verfügbaren Candles."""
candles = self.fetch_candles()
return self.engine.run(candles)
def live_cycle(self):
"""Ein Live-Zyklus: Signal bewerten und ggf. echte Order platzieren."""
candles = self.fetch_candles()
prep = self.strategy.prepare(candles)
sig = self.strategy.last_signal(prep)
if sig.action == 1:
size = (self.t.initial_balance * self.t.position_size_pct) / max(sig.price, 1e-9)
return self.broker.create_market_order(self.symbol(), "buy", size)
if sig.action in (-1, 2):
size = (self.t.initial_balance * self.t.position_size_pct) / max(sig.price, 1e-9)
return self.broker.create_market_order(self.symbol(), "sell", size)
return None
+163
View File
@@ -0,0 +1,163 @@
"""Antrainieren: evolutionäres Optimieren der Strategie-Parameter & Signalgewichte.
Der Bot wird über viele Simulationen/Backtests (auf Simulations-Daten) darauf
trainiert, seine Strategie-Parameter so anzupassen, dass die Fitness gestiegen
ist. Die 'trainings'-Fähigkeit kommt dadurch zustande, dass die Ergebnisse der
Simulationsläufe als Fitness-Signal (Return, Sharpe, Max Drawdown) genutzt werden.
"""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass
from typing import Callable, Dict, List, Sequence
import numpy as np
import pandas as pd
from .config import StrategyConfig, TradingConfig, TrainingConfig
from .engine import Engine, Result
from .strategy import Strategy, fitness, default_weights
log = logging.getLogger("trademind.trainer")
# --- Parameter-Räume -----------------------------------------------------
def _param_bounds() -> Dict[str, tuple]:
return {
"fast_period": (5, 30),
"slow_period": (20, 60),
"signal_period": (5, 15),
"rsi_period": (7, 21),
"rsi_overbought": (60, 80),
"rsi_oversold": (20, 40),
"atr_stop_mult": (1.5, 4.0),
"w_ema_cross": (0.2, 2.0),
"w_rsi_long": (0.0, 1.5),
"w_rsi_exit": (0.0, 1.5),
}
def random_params(rng: random.Random) -> Dict[str, float]:
b = _param_bounds()
p = {k: rng.uniform(lo, hi) for k, (lo, hi) in b.items()}
# slow muss immer > fast sein
p["slow_period"] = max(int(p["slow_period"]), int(p["fast_period"]) + 5)
return p
def build_strategy(base: StrategyConfig, p: Dict[str, float]) -> Strategy:
sc = StrategyConfig(
fast_period=int(round(p["fast_period"])),
slow_period=int(round(p["slow_period"])),
signal_period=int(round(p["signal_period"])),
rsi_period=int(round(p["rsi_period"])),
rsi_overbought=float(p["rsi_overbought"]),
rsi_oversold=float(p["rsi_oversold"]),
atr_period=base.atr_period,
atr_stop_mult=float(p["atr_stop_mult"]),
allow_long=True,
allow_short=base.allow_short,
)
w = {
"ema_cross": float(p["w_ema_cross"]),
"rsi_long": float(p["w_rsi_long"]),
"rsi_exit": float(p["w_rsi_exit"]),
}
return Strategy(sc, w)
def eval_params(
p: Dict[str, float],
candles: pd.DataFrame,
trading: TradingConfig,
base_strategy: Strategy,
tcfg: TrainingConfig,
) -> float:
st = build_strategy(base_strategy.cfg, p)
eng = Engine(trading, st)
try:
res: Result = eng.run(candles)
except Exception: # pragma: no cover - defensive
return -10.0
return fitness(
np.array(res.equity_curve[1:] or [1.0]),
res.final_equity,
trading.initial_balance,
res.max_drawdown_pct / 100.0,
tcfg.fitness_weight_return,
tcfg.fitness_weight_sharpe,
tcfg.fitness_weight_drawdown,
)
def mutate(p: Dict[str, float], rate: float, rng: random.Random) -> Dict[str, float]:
b = _param_bounds()
out = dict(p)
for k, (lo, hi) in b.items():
if rng.random() < rate:
width = (hi - lo) * 0.2
out[k] = min(hi, max(lo, p[k] + rng.uniform(-width, width)))
out["slow_period"] = max(int(out["slow_period"]), int(out["fast_period"]) + 5)
return out
def crossover(a: Dict[str, float], b: Dict[str, float], rng: random.Random) -> Dict[str, float]:
return {k: (a[k] if rng.random() < 0.5 else b[k]) for k in a}
@dataclass
class Individual:
params: Dict[str, float]
fitness: float = -1e9
class Trainer:
def __init__(self, tcfg: TrainingConfig, training: Strategy):
self.tcfg = tcfg
self._weights = default_weights()
def train(
self,
candles: pd.DataFrame,
trading: TradingConfig,
base_strategy: Strategy,
progress: Callable[[int, float], None] | None = None,
) -> Dict[str, float]:
"""Liefert optimierte Parameter (inkl. Gewichte)."""
rng = random.Random(self.tcfg.seed)
pop = [Individual(random_params(rng)) for _ in range(self.tcfg.population)]
best_params: Dict[str, float] = pop[0].params
best_fit = -1e18
for gen in range(self.tcfg.generations):
for ind in pop:
ind.fitness = eval_params(
ind.params, candles, trading, base_strategy, self.tcfg
)
ranked = sorted(pop, key=lambda x: x.fitness, reverse=True)
if ranked[0].fitness > best_fit:
best_fit = ranked[0].fitness
best_params = ranked[0].params
if progress:
progress(gen + 1, ranked[0].fitness)
# Elternteile (Elitismus) + Kinder
elite = ranked[: max(2, self.tcfg.population // 5)]
new_pop: List[Individual] = [Individual(dict(ind.params), ind.fitness) for ind in elite]
while len(new_pop) < self.tcfg.population:
pa, pb = rng.sample(elite, 2)
child = crossover(pa.params, pb.params, rng)
child = mutate(child, self.tcfg.mutation_rate, rng)
new_pop.append(Individual(child))
pop = new_pop
log.info("Training abgeschlossen. Beste Fitness: %.4f", best_fit)
return best_params
def apply_params(base: StrategyConfig, params: Dict[str, float]) -> Strategy:
return build_strategy(base, params)