"""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 {}