Initial commit: TradeMind – Krypto-Trading-Bot mit Lernmodus

Per Podman deploybarer Bot, der Käufe und Verkäufe simuliert ausführt und sich
aus den Ergebnissen weiter antrainiert.

Aufbau
- Einheitliche Bar-Verarbeitung für paper, backtest und live; ausgetauscht werden
  nur Datenquelle und Broker.
- Börsenanbindung über ccxt: rund 100 Börsen allein über exchange.id erreichbar.
  Zugangsdaten kommen über ENV-Platzhalter, der Live-Modus ist doppelt abgesichert.
- Paper-Broker mit Gebühren, Slippage, Börsenpräzision und Volumengrenzen.
- Online trainierte logistische Regression bewertet jedes Einstiegssignal. Sie lernt
  aus realen Trade-Ergebnissen, aus Shadow-Labels aller Kandidaten – auch der
  abgelehnten – und aus Hintergrund-Stichproben; beim Kaltstart wird sie aus der
  Kurshistorie vorgelernt.
- Risikomanagement: Positions- und Exposure-Grenzen, ATR-Stops, Cooldown sowie
  Tagesverlust- und Drawdown-Notbremsen.
- SQLite-Persistenz, HTTP-Status mit Prometheus-Metriken und Dashboard, Webhooks.

Deployment
- Containerfile (zweistufig, non-root UID 10001), podman-compose, systemd-Quadlet.
- Modell und Datenbank liegen im Volume /data und überleben Neustarts.

128 Tests, ruff sauber. Verifiziert gegen echte Marktdaten sowie im gebauten
Container inklusive Healthcheck und Zustandswiederherstellung.
This commit is contained in:
Tobias Zimmermann
2026-08-22 08:53:04 +02:00
commit 65ed73977e
40 changed files with 7033 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
"""TradeMind selbstlernender Krypto-Trading-Bot mit Paper-, Backtest- und Live-Modus."""
__version__ = "0.1.0"
__all__ = ["__version__"]
+6
View File
@@ -0,0 +1,6 @@
"""Erlaubt ``python -m trademind ...``."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
+206
View File
@@ -0,0 +1,206 @@
"""Zusammenbau aller Komponenten aus der Konfiguration."""
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass
from typing import Any
from .broker import Broker, LiveBroker, PaperBroker
from .config import Config, Mode
from .data import CcxtDataFeed, DataFeed
from .engine import TradingEngine
from .exchange import build_exchange, load_market_info
from .features import N_FEATURES
from .notify import Notifier
from .portfolio import Portfolio
from .risk import RiskManager
from .server import StatusServer
from .storage import NullStorage, Storage
from .strategy import Strategy, build_strategy
log = logging.getLogger(__name__)
def setup_logging(level: str = "INFO") -> None:
root = logging.getLogger()
if root.handlers:
root.setLevel(level)
return
# Umlaute und Symbole sollen auch auf Konsolen mit anderer Codepage lesbar bleiben.
for stream in (sys.stdout, sys.stderr):
reconfigure = getattr(stream, "reconfigure", None)
if reconfigure is not None:
try:
reconfigure(encoding="utf-8", errors="replace")
except (ValueError, OSError): # pragma: no cover - je nach Konsole
pass
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(
logging.Formatter("%(asctime)s %(levelname)-7s %(name)-22s %(message)s", "%Y-%m-%d %H:%M:%S")
)
root.addHandler(handler)
root.setLevel(level)
# ccxt und aiohttp sind im INFO-Level sehr gesprächig.
logging.getLogger("ccxt").setLevel(logging.WARNING)
logging.getLogger("aiohttp").setLevel(logging.WARNING)
@dataclass
class Runtime:
"""Alle Laufzeitobjekte eines Laufs, inklusive geordnetem Herunterfahren."""
config: Config
engine: TradingEngine
broker: Broker
feed: DataFeed
strategy: Strategy
portfolio: Portfolio
risk: RiskManager
storage: Storage | NullStorage
notifier: Notifier
server: StatusServer | None
exchange: Any | None
async def start_services(self) -> None:
await self.notifier.start()
if self.server is not None:
await self.server.start()
async def close(self) -> None:
if self.server is not None:
await self.server.close()
await self.notifier.close()
if self.exchange is not None:
await self.exchange.close()
else:
await self.broker.close()
await self.feed.close()
self.storage.close()
def _quote_currency(market_info: dict[str, dict[str, Any]], symbols: list[str], fallback: str) -> str:
quotes = {market_info.get(s, {}).get("quote") for s in symbols}
quotes.discard(None)
if len(quotes) > 1:
raise ValueError(
"Alle Symbole müssen dieselbe Quote-Währung haben (gefunden: "
+ ", ".join(sorted(str(q) for q in quotes))
+ "). Bitte market.symbols anpassen."
)
return str(next(iter(quotes))) if quotes else fallback
async def build_runtime(
config: Config,
*,
with_server: bool = True,
with_storage: bool = True,
load_model: bool = True,
seed: int | None = None,
) -> Runtime:
"""Erzeugt Börsenanbindung, Broker, Strategie, Engine und Nebendienste."""
storage: Storage | NullStorage = (
Storage(config.storage.database_path) if with_storage else NullStorage()
)
read_only = config.mode is not Mode.LIVE
exchange = build_exchange(config.exchange, read_only=read_only)
market_info = await load_market_info(exchange, config.market.symbols)
feed = CcxtDataFeed(exchange)
broker: Broker
if config.mode is Mode.LIVE:
quote = _quote_currency(market_info, config.market.symbols, "USDT")
broker = LiveBroker(exchange, quote_currency=quote, market_info=market_info)
starting_equity = 0.0 # wird beim Start aus dem echten Guthaben gesetzt
else:
quote = _quote_currency(market_info, config.market.symbols, config.paper.quote_currency)
if quote != config.paper.quote_currency:
log.info(
"Quote-Währung der Symbole ist %s paper.quote_currency (%s) wird überschrieben",
quote, config.paper.quote_currency,
)
paper_config = config.paper.model_copy(update={"quote_currency": quote})
broker = PaperBroker(paper_config, market_info=market_info)
starting_equity = paper_config.starting_balance
strategy = build_strategy(config.strategy, N_FEATURES, seed=seed, load_model=load_model)
learner = getattr(strategy, "learner", None)
if learner is not None and config.mode is Mode.LIVE and config.strategy.learner.freeze_in_live:
learner.frozen = True
log.info("Live-Modus: Online-Lernen eingefroren (freeze_in_live=true)")
portfolio = Portfolio(starting_equity=starting_equity, quote_currency=broker.quote_currency)
risk = RiskManager(config.risk)
notifier = Notifier(config.notifications)
engine = TradingEngine(
config=config,
broker=broker,
feed=feed,
strategy=strategy,
portfolio=portfolio,
risk=risk,
storage=storage,
notifier=notifier,
)
server = StatusServer(config.server, engine.status) if (with_server and config.server.enabled) else None
storage.start_run(
mode=config.mode.value,
exchange=config.exchange.id,
symbols=config.market.symbols,
timeframe=config.market.timeframe,
strategy=config.strategy.name,
)
return Runtime(
config=config,
engine=engine,
broker=broker,
feed=feed,
strategy=strategy,
portfolio=portfolio,
risk=risk,
storage=storage,
notifier=notifier,
server=server,
exchange=exchange,
)
def describe_config(config: Config) -> str:
"""Kompakte Übersicht der wirksamen Konfiguration (ohne Geheimnisse)."""
ex = config.exchange
lines = [
f"Modus {config.mode.value}"
+ (" ⚠ ECHTES GELD" if config.mode is Mode.LIVE else ""),
f"Börse {ex.id}" + (" (Sandbox/Testnet)" if ex.sandbox else " (Produktiv)"),
f"Zugangsdaten {'gesetzt' if ex.has_credentials() else 'nicht gesetzt'}",
f"Symbole {', '.join(config.market.symbols)}",
f"Timeframe {config.market.timeframe} "
f"(Abfrage alle {config.market.poll_interval_seconds:g}s)",
f"Strategie {config.strategy.name}"
+ (f" (Lernen aktiv, Schwelle {config.strategy.learner.entry_threshold})"
if config.strategy.name == "adaptive" and config.strategy.learner.enabled
else " (kein Lernen)"),
f"Risiko max. {config.risk.max_open_positions} Positionen, "
f"{config.risk.max_position_pct:.0%} je Position, "
f"Stop {config.risk.stop_loss_atr_mult}×ATR, Ziel {config.risk.take_profit_atr_mult}×ATR",
f"Notbremsen Tagesverlust {config.risk.max_daily_loss_pct:.0%}, "
f"Drawdown {config.risk.max_drawdown_pct:.0%}",
f"Datenbank {config.storage.database_path}",
f"Modelldatei {config.strategy.learner.model_path}",
]
if config.mode is not Mode.LIVE:
lines.insert(
3,
f"Startkapital {config.paper.starting_balance:g} {config.paper.quote_currency} "
f"(Gebühr {config.paper.fee_rate:.3%}, Slippage {config.paper.slippage_bps:g} bps)",
)
if config.server.enabled:
lines.append(f"Status-Server http://{config.server.host}:{config.server.port}/")
return "\n".join(" " + line for line in lines)
+251
View File
@@ -0,0 +1,251 @@
"""Backtest: historische Kerzen mit derselben Bar-Logik wie im Live-Betrieb durchspielen.
Die Indikatoren werden einmal vorberechnet; der Lauf ist damit linear in der Anzahl Kerzen.
Das Lernmodell trainiert währenddessen ganz normal weiter (Walk-Forward), es gibt also
keinen getrennten Trainings- und Testlauf der Bot lernt, während er handelt.
"""
from __future__ import annotations
import logging
import time
from dataclasses import dataclass, field
from typing import Any
import numpy as np
from .data import format_ts
from .engine import Bar, TradingEngine
from .features import FeatureMatrix, build_feature_matrix
from .models import Candles, ExitReason
log = logging.getLogger(__name__)
@dataclass
class BacktestReport:
symbols: list[str]
timeframe: str
bars: int
start: str
end: str
duration_seconds: float
portfolio: dict[str, Any]
strategy: dict[str, Any]
buy_and_hold_pct: dict[str, float] = field(default_factory=dict)
exit_reasons: dict[str, int] = field(default_factory=dict)
def as_dict(self) -> dict[str, Any]:
return {
"symbols": self.symbols,
"timeframe": self.timeframe,
"bars": self.bars,
"start": self.start,
"end": self.end,
"duration_seconds": round(self.duration_seconds, 2),
"portfolio": self.portfolio,
"strategy": self.strategy,
"buy_and_hold_pct": self.buy_and_hold_pct,
"exit_reasons": self.exit_reasons,
}
def render(self, quote: str = "USDT") -> str:
p = self.portfolio
lines = [
"",
"" * 66,
" BACKTEST-ERGEBNIS",
"" * 66,
f" Symbole {', '.join(self.symbols)} ({self.timeframe})",
f" Zeitraum {self.start} bis {self.end}",
f" Kerzen {self.bars:,}".replace(",", "."),
f" Laufzeit {self.duration_seconds:.1f} s",
"" * 66,
f" Endkapital {p['equity']:.2f} {quote}",
f" Gesamtrendite {p['total_return_pct']:+.2f} %",
f" Max. Drawdown {p['max_drawdown_pct']:.2f} %",
f" Sharpe (annual.) {p['sharpe']:.2f}",
"" * 66,
f" Trades {p['trades']} ({p['wins']} Gewinne / {p['losses']} Verluste)",
f" Trefferquote {p['win_rate'] * 100:.1f} %",
f" Profit-Faktor {_fmt(p['profit_factor'])}",
f" Erwartungswert {p['expectancy']:+.4f} {quote} pro Trade",
f" Gebühren gesamt {p['fees']:.2f} {quote}",
f" Bester / schlecht. {p['best_trade']:+.2f} / {p['worst_trade']:+.2f} {quote}",
]
if self.exit_reasons:
reasons = ", ".join(f"{k}: {v}" for k, v in sorted(self.exit_reasons.items()))
lines.append(f" Ausstiegsgründe {reasons}")
if self.buy_and_hold_pct:
lines.append("" * 66)
for symbol, pct in self.buy_and_hold_pct.items():
lines.append(f" Buy & Hold {symbol:<12} {pct:+.2f} %")
learner = self.strategy.get("learner") if isinstance(self.strategy, dict) else None
if isinstance(learner, dict) and learner.get("samples_seen"):
lines += [
"" * 66,
" LERNMODELL",
f" Beobachtungen {learner['samples_seen']} "
f"({learner.get('trade_samples', 0)} aus echten Trades)",
f" Trainingsschritte {learner.get('updates', 0)}",
f" Online-Accuracy {learner.get('online_accuracy', 0) * 100:.1f} %",
f" Online-LogLoss {learner.get('online_logloss', 0):.4f}",
f" Gewinneranteil {learner.get('positive_rate', 0) * 100:.1f} %",
f" Signale akzeptiert {self.strategy.get('candidates_accepted', 0)} von "
f"{self.strategy.get('candidates_seen', 0)} "
f"({self.strategy.get('acceptance_rate', 0) * 100:.1f} %)",
]
lines.append("" * 66)
return "\n".join(lines)
def _fmt(value: Any) -> str:
if value is None:
return ""
return f"{float(value):.2f}"
class BacktestRunner:
"""Spielt vorgeladene Serien Kerze für Kerze durch die Engine."""
def __init__(self, engine: TradingEngine, series: dict[str, Candles], progress_every: int = 500) -> None:
self.engine = engine
self.series = series
self.progress_every = progress_every
self._matrices: dict[str, FeatureMatrix] = {}
def _prepare(self) -> int:
rules = self.engine.config.strategy.rules
first_valid = 0
usable: dict[str, Candles] = {}
for symbol, candles in self.series.items():
matrix = build_feature_matrix(candles, rules)
if matrix is None:
log.warning(
"%s: nur %d Kerzen zu wenig für die Indikatoren, Symbol wird übersprungen",
symbol, len(candles),
)
continue
self._matrices[symbol] = matrix
usable[symbol] = candles
first_valid = max(first_valid, matrix.first_valid)
self.series = usable
if not self.series:
raise ValueError("Keine Serie hat genug Kerzen für einen Backtest")
return first_valid
async def run(self) -> BacktestReport:
start_time = time.perf_counter()
first_valid = self._prepare()
length = min(len(c) for c in self.series.values())
symbols = list(self.series)
log.info(
"Backtest über %d Kerzen (%d verwertbar) auf %s",
length, length - first_valid, ", ".join(symbols),
)
self.engine.running = True
for index in range(first_valid, length):
self.engine._cash = await self.engine.broker.cash()
for symbol in symbols:
matrix = self._matrices[symbol]
snapshot = matrix.snapshot(index)
if snapshot is None:
continue
candles = self.series[symbol]
bar = Bar(
timestamp=int(candles.timestamp[index]),
open=float(candles.open[index]),
high=float(candles.high[index]),
low=float(candles.low[index]),
close=float(candles.close[index]),
volume=float(candles.volume[index]),
)
self.engine.bar_counter[symbol] = index
self.engine.last_bar_ts[symbol] = bar.timestamp
await self.engine.process_bar(symbol, snapshot, bar)
self.engine._record_equity()
if self.progress_every and (index - first_valid) % self.progress_every == 0:
done = index - first_valid
total = length - first_valid
log.info(
"%d/%d Kerzen (%.0f %%) Equity %.2f, Trades %d",
done, total, 100.0 * done / max(total, 1),
self.engine.portfolio.equity(self.engine._cash),
self.engine.portfolio.stats.trades,
)
# Am Ende offene Positionen glattstellen, damit das Ergebnis vollständig ist.
for symbol in list(self.engine.portfolio.positions):
last_close = float(self.series[symbol].close[length - 1])
await self.engine._close_position(symbol, last_close, ExitReason.SHUTDOWN, None)
self.engine.running = False
cash = await self.engine.broker.cash()
duration = time.perf_counter() - start_time
exit_reasons: dict[str, int] = {}
for trade in self.engine.portfolio.trades:
exit_reasons[trade.exit_reason.value] = exit_reasons.get(trade.exit_reason.value, 0) + 1
buy_hold = {}
for symbol, candles in self.series.items():
first_price = float(candles.close[first_valid])
last_price = float(candles.close[length - 1])
if first_price > 0:
buy_hold[symbol] = (last_price - first_price) / first_price * 100.0
first_ts = int(next(iter(self.series.values())).timestamp[first_valid])
last_ts = int(next(iter(self.series.values())).timestamp[length - 1])
return BacktestReport(
symbols=symbols,
timeframe=self.engine.config.market.timeframe,
bars=length - first_valid,
start=format_ts(first_ts),
end=format_ts(last_ts),
duration_seconds=duration,
portfolio=self.engine.portfolio.summary(cash),
strategy=self.engine.strategy.snapshot(),
buy_and_hold_pct={k: round(v, 2) for k, v in buy_hold.items()},
exit_reasons=exit_reasons,
)
def equity_curve_csv(engine: TradingEngine) -> str:
"""Equity-Kurve als CSV-Text (für eigene Auswertungen)."""
rows = ["timestamp,equity"]
rows += [f"{ts},{eq:.8f}" for ts, eq in engine.portfolio.equity_curve]
return "\n".join(rows) + "\n"
def trades_csv(engine: TradingEngine) -> str:
trades = engine.portfolio.trades
header = (
"symbol,entry_timestamp,exit_timestamp,amount,entry_price,exit_price,"
"fees_quote,pnl_quote,pnl_pct,exit_reason,bars_held,entry_confidence,exploratory"
)
rows = [header]
for t in trades:
rows.append(
f"{t.symbol},{t.entry_timestamp},{t.exit_timestamp},{t.amount:.10f},{t.entry_price:.10f},"
f"{t.exit_price:.10f},{t.fees_quote:.10f},{t.pnl_quote:.10f},{t.pnl_pct:.10f},"
f"{t.exit_reason.value},{t.bars_held},{t.entry_confidence:.6f},{int(t.exploratory)}"
)
return "\n".join(rows) + "\n"
def summarize_returns(engine: TradingEngine) -> dict[str, float]:
"""Ein paar Verteilungskennzahlen der Trade-Renditen."""
pnls = np.array([t.pnl_pct for t in engine.portfolio.trades], dtype=np.float64)
if pnls.size == 0:
return {}
return {
"mean_pct": float(np.mean(pnls) * 100),
"median_pct": float(np.median(pnls) * 100),
"std_pct": float(np.std(pnls, ddof=1) * 100) if pnls.size > 1 else 0.0,
"p05_pct": float(np.percentile(pnls, 5) * 100),
"p95_pct": float(np.percentile(pnls, 95) * 100),
}
+345
View File
@@ -0,0 +1,345 @@
"""Order-Ausführung: simuliert (Paper/Backtest) oder echt über ccxt (Live)."""
from __future__ import annotations
import asyncio
import logging
import math
import time
import uuid
from abc import ABC, abstractmethod
from typing import Any
from .config import PaperConfig
from .models import Fill, Side
log = logging.getLogger(__name__)
class InsufficientFunds(RuntimeError):
"""Nicht genug Guthaben für die gewünschte Order."""
class OrderRejected(RuntimeError):
"""Die Börse (oder die Simulation) hat die Order abgelehnt."""
def _now_ms() -> int:
return int(time.time() * 1000)
class Broker(ABC):
"""Gemeinsame Schnittstelle für simulierte und echte Ausführung."""
quote_currency: str = "USDT"
is_simulated: bool = True
def __init__(self, market_info: dict[str, dict[str, Any]] | None = None) -> None:
self.market_info: dict[str, dict[str, Any]] = market_info or {}
async def start(self) -> None:
return None
async def close(self) -> None:
return None
@abstractmethod
async def cash(self) -> float:
"""Verfügbares Guthaben in der Quote-Währung."""
@abstractmethod
async def holdings(self, symbol: str) -> float:
"""Bestand in der Basiswährung des Symbols."""
@abstractmethod
async def execute(
self,
symbol: str,
side: Side,
amount: float,
ref_price: float,
*,
bar_volume: float | None = None,
timestamp: int | None = None,
) -> Fill:
"""Führt eine Market-Order aus und liefert die tatsächliche Ausführung."""
# ------------------------------------------------------------ Marktregeln
def min_amount(self, symbol: str) -> float:
return float(self.market_info.get(symbol, {}).get("min_amount") or 0.0)
def min_cost(self, symbol: str) -> float:
return float(self.market_info.get(symbol, {}).get("min_cost") or 0.0)
def round_amount(self, symbol: str, amount: float) -> float:
"""Auf die Mengen-Präzision der Börse abrunden (nie aufrunden)."""
precision = self.market_info.get(symbol, {}).get("amount_precision")
if precision is None:
return float(amount)
if isinstance(precision, int):
if precision <= 0:
return float(math.floor(amount))
factor = 10**precision
return math.floor(amount * factor) / factor
step = float(precision) # manche Börsen liefern die Schrittweite selbst
if step <= 0:
return float(amount)
return math.floor(amount / step) * step
class PaperBroker(Broker):
"""Simulierte Ausführung mit Gebühren, Slippage und begrenzter Marktliquidität."""
is_simulated = True
def __init__(self, config: PaperConfig, market_info: dict[str, dict[str, Any]] | None = None) -> None:
super().__init__(market_info)
self.config = config
self.quote_currency = config.quote_currency
self.starting_balance = config.starting_balance
self._cash = config.starting_balance
self._holdings: dict[str, float] = {}
self.total_fees = 0.0
self.order_count = 0
self.rejected_count = 0
async def cash(self) -> float:
return self._cash
def cash_sync(self) -> float:
return self._cash
async def holdings(self, symbol: str) -> float:
return self._holdings.get(symbol, 0.0)
def holdings_sync(self, symbol: str) -> float:
return self._holdings.get(symbol, 0.0)
def all_holdings(self) -> dict[str, float]:
return {s: a for s, a in self._holdings.items() if a > 0}
async def execute(
self,
symbol: str,
side: Side,
amount: float,
ref_price: float,
*,
bar_volume: float | None = None,
timestamp: int | None = None,
) -> Fill:
if amount <= 0:
raise OrderRejected(f"{symbol}: Ordermenge muss positiv sein (war {amount})")
if ref_price <= 0:
raise OrderRejected(f"{symbol}: ungültiger Referenzpreis {ref_price}")
requested = amount
# Liquiditätsgrenze: nie mehr als ein Bruchteil des Bar-Volumens ausführen.
if bar_volume and bar_volume > 0:
cap = bar_volume * self.config.max_volume_participation
if amount > cap:
log.debug("%s: Order von %.8f auf %.8f begrenzt (Bar-Volumen)", symbol, amount, cap)
amount = cap
amount = self.round_amount(symbol, amount)
if amount <= 0:
self.rejected_count += 1
raise OrderRejected(f"{symbol}: Menge nach Rundung auf Börsenpräzision = 0")
slip = self.config.slippage_bps / 10_000.0
fee_rate = self.config.fee_rate
ts = timestamp or _now_ms()
if side is Side.BUY:
price = ref_price * (1.0 + slip)
cost = amount * price
fee = cost * fee_rate
if cost + fee > self._cash + 1e-9:
# So weit herunterskalieren, dass es exakt passt.
affordable = self._cash / (price * (1.0 + fee_rate))
amount = self.round_amount(symbol, affordable)
if amount <= 0:
self.rejected_count += 1
raise InsufficientFunds(
f"{symbol}: Guthaben {self._cash:.2f} {self.quote_currency} reicht nicht"
)
cost = amount * price
fee = cost * fee_rate
self._cash -= cost + fee
self._holdings[symbol] = self._holdings.get(symbol, 0.0) + amount
else:
held = self._holdings.get(symbol, 0.0)
if amount > held + 1e-12:
amount = self.round_amount(symbol, held)
if amount <= 0:
self.rejected_count += 1
raise OrderRejected(f"{symbol}: kein Bestand zum Verkaufen")
price = ref_price * (1.0 - slip)
proceeds = amount * price
fee = proceeds * fee_rate
self._cash += proceeds - fee
remaining = held - amount
if remaining <= 1e-12:
self._holdings.pop(symbol, None)
else:
self._holdings[symbol] = remaining
self.total_fees += fee
self.order_count += 1
return Fill(
symbol=symbol,
side=side,
amount=amount,
price=price,
fee_quote=fee,
timestamp=ts,
order_id=f"paper-{uuid.uuid4().hex[:10]}",
requested_amount=requested,
)
def state(self) -> dict[str, Any]:
return {
"cash": round(self._cash, 8),
"holdings": dict(self._holdings),
"total_fees": round(self.total_fees, 8),
"orders": self.order_count,
"rejected": self.rejected_count,
}
def restore(self, cash: float, holdings: dict[str, float], total_fees: float = 0.0) -> None:
self._cash = float(cash)
self._holdings = {k: float(v) for k, v in holdings.items() if v > 0}
self.total_fees = float(total_fees)
class LiveBroker(Broker):
"""Echte Market-Orders über ccxt."""
is_simulated = False
def __init__(
self,
exchange,
quote_currency: str = "USDT",
market_info: dict[str, dict[str, Any]] | None = None,
fill_poll_attempts: int = 5,
fill_poll_delay: float = 1.0,
) -> None:
super().__init__(market_info)
self._exchange = exchange
self.quote_currency = quote_currency
self._fill_poll_attempts = fill_poll_attempts
self._fill_poll_delay = fill_poll_delay
self.order_count = 0
self.total_fees = 0.0
async def start(self) -> None:
balance = await self._exchange.fetch_balance()
free = (balance.get("free") or {}).get(self.quote_currency, 0.0)
log.info("Live-Broker verbunden: %.2f %s verfügbar", float(free or 0.0), self.quote_currency)
async def close(self) -> None:
await self._exchange.close()
async def cash(self) -> float:
balance = await self._exchange.fetch_balance()
return float((balance.get("free") or {}).get(self.quote_currency, 0.0) or 0.0)
async def holdings(self, symbol: str) -> float:
base = self.market_info.get(symbol, {}).get("base") or symbol.split("/")[0]
balance = await self._exchange.fetch_balance()
return float((balance.get("free") or {}).get(base, 0.0) or 0.0)
async def execute(
self,
symbol: str,
side: Side,
amount: float,
ref_price: float,
*,
bar_volume: float | None = None,
timestamp: int | None = None,
) -> Fill:
requested = amount
amount = self.round_amount(symbol, amount)
if amount <= 0:
raise OrderRejected(f"{symbol}: Menge nach Rundung = 0")
try:
order = await self._exchange.create_order(symbol, "market", side.value, amount)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - ccxt-Fehlerhierarchie ist breit
raise OrderRejected(f"{symbol}: Order abgelehnt ({type(exc).__name__}: {exc})") from exc
order = await self._await_fill(symbol, order)
filled = float(order.get("filled") or order.get("amount") or amount)
avg = order.get("average") or order.get("price") or ref_price
price = float(avg) if avg else ref_price
fee = self._extract_fee(order, filled, price)
self.order_count += 1
self.total_fees += fee
log.info(
"Live-Order ausgeführt: %s %s %.8f @ %.6f (Gebühr %.6f %s)",
side.value.upper(), symbol, filled, price, fee, self.quote_currency,
)
return Fill(
symbol=symbol,
side=side,
amount=filled,
price=price,
fee_quote=fee,
timestamp=int(order.get("timestamp") or timestamp or _now_ms()),
order_id=str(order.get("id") or ""),
requested_amount=requested,
)
async def _await_fill(self, symbol: str, order: dict[str, Any]) -> dict[str, Any]:
"""Market-Orders sind meist sofort gefüllt manche Börsen melden das verzögert."""
order_id = order.get("id")
if not order_id or order.get("status") == "closed":
return order
for _ in range(self._fill_poll_attempts):
if order.get("status") in ("closed", "canceled", "rejected"):
break
await asyncio.sleep(self._fill_poll_delay)
try:
order = await self._exchange.fetch_order(order_id, symbol)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
log.debug("fetch_order für %s nicht möglich: %s", order_id, exc)
break
if order.get("status") not in ("closed", None):
log.warning("Order %s hat Status '%s' Buchhaltung nutzt die gemeldete Füllmenge",
order_id, order.get("status"))
return order
def _extract_fee(self, order: dict[str, Any], filled: float, price: float) -> float:
fee_info = order.get("fee") or {}
cost = fee_info.get("cost")
currency = fee_info.get("currency")
if cost is not None and (currency is None or currency == self.quote_currency):
return float(cost)
if cost is not None and currency and price > 0:
# Gebühr in Basiswährung → in Quote umrechnen.
base = self.market_info.get(order.get("symbol", ""), {}).get("base")
if currency == base:
return float(cost) * price
fees = order.get("fees") or []
total = 0.0
for entry in fees:
entry_cost = entry.get("cost")
if entry_cost is None:
continue
if entry.get("currency") == self.quote_currency:
total += float(entry_cost)
else:
total += float(entry_cost) * price
if total:
return total
# Fallback: Taker-Gebühr aus den Marktdaten schätzen.
taker = self.market_info.get(order.get("symbol", ""), {}).get("taker") or 0.001
return filled * price * float(taker)
+368
View File
@@ -0,0 +1,368 @@
"""Kommandozeile: ``trademind run|backtest|validate|report|exchanges``."""
from __future__ import annotations
import argparse
import asyncio
import json
import logging
import os
import sys
from pathlib import Path
from . import __version__
from .app import build_runtime, describe_config, setup_logging
from .backtest import BacktestRunner, equity_curve_csv, summarize_returns, trades_csv
from .config import Config, Mode, load_config
from .data import align_series, load_csv, parse_iso8601
from .engine import install_signal_handlers
from .exchange import available_exchanges
from .models import Candles
from .storage import Storage
log = logging.getLogger("trademind.cli")
DEFAULT_CONFIG = os.environ.get("TRADEMIND_CONFIG", "/config/config.yaml")
# --------------------------------------------------------------------- Parser
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="trademind",
description="Selbstlernender Krypto-Trading-Bot (Paper, Backtest, Live).",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Beispiele:\n"
" trademind run --config config/config.yaml\n"
" trademind backtest --config config/config.yaml --bars 20000 --fresh-model --save-model\n"
" trademind validate --config config/config.yaml\n"
" trademind exchanges --search kraken\n"
),
)
parser.add_argument("--version", action="version", version=f"trademind {__version__}")
sub = parser.add_subparsers(dest="command", required=True)
def add_common(p: argparse.ArgumentParser) -> None:
p.add_argument(
"-c", "--config", default=DEFAULT_CONFIG,
help=f"Konfigurationsdatei (Standard: {DEFAULT_CONFIG})",
)
p.add_argument(
"--log-level", choices=["DEBUG", "INFO", "WARNING", "ERROR"],
help="Log-Level überschreiben",
)
run_p = sub.add_parser("run", help="Bot dauerhaft laufen lassen (paper oder live)")
add_common(run_p)
run_p.add_argument("--mode", choices=[m.value for m in Mode], help="Modus überschreiben")
run_p.add_argument(
"--liquidate-on-exit", action="store_true", help="Beim Beenden alle Positionen schließen"
)
run_p.add_argument("--no-server", action="store_true", help="Status-Server nicht starten")
bt_p = sub.add_parser("backtest", help="Strategie auf historischen Daten durchspielen")
add_common(bt_p)
bt_p.add_argument("--bars", type=int, help="Anzahl Kerzen (Standard aus backtest.bars)")
bt_p.add_argument("--start", help="Startzeit ISO-8601, z. B. 2024-01-01T00:00:00Z")
bt_p.add_argument("--end", help="Endzeit ISO-8601")
bt_p.add_argument("--csv-dir", help="OHLCV aus CSV-Dateien statt von der Börse laden")
bt_p.add_argument("--fresh-model", action="store_true", help="Mit untrainiertem Modell starten")
bt_p.add_argument("--save-model", action="store_true", help="Trainiertes Modell nach dem Lauf speichern")
bt_p.add_argument("--out-dir", help="Trades und Equity-Kurve als CSV hier ablegen")
bt_p.add_argument("--json", action="store_true", help="Ergebnis als JSON ausgeben")
bt_p.add_argument("--seed", type=int, default=42, help="Zufallszahlen-Seed für reproduzierbare Läufe")
val_p = sub.add_parser("validate", help="Konfiguration prüfen und Börsenverbindung testen")
add_common(val_p)
val_p.add_argument(
"--offline", action="store_true", help="Nur die Datei prüfen, keine Verbindung aufbauen"
)
rep_p = sub.add_parser("report", help="Ergebnisse aus der Datenbank zusammenfassen")
add_common(rep_p)
rep_p.add_argument("--limit", type=int, default=20, help="Anzahl der zuletzt gezeigten Trades")
rep_p.add_argument("--json", action="store_true", help="Ausgabe als JSON")
ex_p = sub.add_parser("exchanges", help="Von ccxt unterstützte Börsen auflisten")
ex_p.add_argument("--search", help="Nach Namensbestandteil filtern")
return parser
# ---------------------------------------------------------------- Hilfsroutinen
def _load(args: argparse.Namespace) -> Config:
try:
config = load_config(args.config)
except FileNotFoundError as exc:
print(f"Fehler: {exc}", file=sys.stderr)
print(
"Tipp: Beispielkonfiguration kopieren cp config/config.example.yaml config/config.yaml",
file=sys.stderr,
)
raise SystemExit(2) from None
except Exception as exc: # noqa: BLE001 - Validierungsfehler leserlich ausgeben
print(f"Konfiguration ungültig ({args.config}):\n{exc}", file=sys.stderr)
raise SystemExit(2) from None
if getattr(args, "log_level", None):
config = config.model_copy(update={"log_level": args.log_level})
return config
def _symbol_to_filename(symbol: str) -> str:
return symbol.replace("/", "_").replace(":", "_")
async def _load_series(config: Config, args: argparse.Namespace, runtime) -> dict[str, Candles]:
"""Historische Kerzen laden aus CSV oder von der Börse."""
timeframe = config.market.timeframe
bars = args.bars or config.backtest.bars
csv_dir = args.csv_dir or config.backtest.csv_dir
series: dict[str, Candles] = {}
if csv_dir:
directory = Path(csv_dir)
for symbol in config.market.symbols:
candidates = [
directory / f"{_symbol_to_filename(symbol)}.csv",
directory / f"{_symbol_to_filename(symbol)}_{timeframe}.csv",
directory / f"{symbol.split('/')[0]}.csv",
]
path = next((p for p in candidates if p.is_file()), None)
if path is None:
raise SystemExit(
f"Keine CSV für {symbol} in {directory} gefunden "
f"(erwartet z. B. {_symbol_to_filename(symbol)}.csv)"
)
series[symbol] = load_csv(path, symbol, timeframe)
else:
since = parse_iso8601(args.start or config.backtest.start)
until = parse_iso8601(args.end or config.backtest.end)
for symbol in config.market.symbols:
series[symbol] = await runtime.feed.fetch_history(symbol, timeframe, bars, since, until)
empty = [s for s, c in series.items() if len(c) == 0]
if empty:
raise SystemExit(f"Keine Daten für: {', '.join(empty)}")
return align_series(series)
# ------------------------------------------------------------------- Kommandos
async def cmd_run(args: argparse.Namespace) -> int:
config = _load(args)
if args.mode and args.mode != config.mode.value:
# Über model_validate, damit die Live-Schutzprüfungen erneut greifen.
try:
config = Config.model_validate({**config.model_dump(), "mode": args.mode})
except Exception as exc: # noqa: BLE001
print(f"Modus '{args.mode}' nicht möglich:\n{exc}", file=sys.stderr)
return 2
setup_logging(config.log_level)
print("\nTradeMind startet:\n" + describe_config(config) + "\n")
if config.mode is Mode.LIVE:
log.warning("LIVE-MODUS: Es werden echte Orders mit echtem Guthaben ausgeführt.")
runtime = await build_runtime(config, with_server=not args.no_server)
try:
await runtime.start_services()
await runtime.engine.prepare()
await runtime.engine.bootstrap_learner()
runtime.notifier.startup(
config.mode.value, config.exchange.id, config.market.symbols, config.market.timeframe
)
install_signal_handlers(runtime.engine)
await runtime.engine.run()
except asyncio.CancelledError:
log.info("Abbruch empfangen")
finally:
await runtime.engine.shutdown(liquidate=args.liquidate_on_exit)
await runtime.close()
summary = runtime.portfolio.summary(runtime.engine._cash)
print(
f"\nBeendet. Equity {summary['equity']:.2f} {runtime.broker.quote_currency}, "
f"{summary['trades']} Trades, Rendite {summary['total_return_pct']:+.2f} %"
)
return 0
async def cmd_backtest(args: argparse.Namespace) -> int:
config = _load(args)
if config.mode is Mode.LIVE:
config = config.model_copy(update={"mode": Mode.BACKTEST})
setup_logging(config.log_level)
runtime = await build_runtime(
config,
with_server=False,
with_storage=False,
load_model=not args.fresh_model,
seed=args.seed,
)
learner = getattr(runtime.strategy, "learner", None)
if learner is not None:
learner.autosave = False
try:
series = await _load_series(config, args, runtime)
await runtime.engine.prepare()
report = await BacktestRunner(runtime.engine, series).run()
if args.json:
payload = report.as_dict()
payload["return_distribution"] = summarize_returns(runtime.engine)
print(json.dumps(payload, indent=2, ensure_ascii=False, default=str))
else:
print(report.render(runtime.broker.quote_currency))
distribution = summarize_returns(runtime.engine)
if distribution:
print(
f" Trade-Renditen Median {distribution['median_pct']:+.2f} %, "
f"5%-Quantil {distribution['p05_pct']:+.2f} %, "
f"95%-Quantil {distribution['p95_pct']:+.2f} %\n"
)
if args.out_dir:
out = Path(args.out_dir)
out.mkdir(parents=True, exist_ok=True)
(out / "trades.csv").write_text(trades_csv(runtime.engine), encoding="utf-8")
(out / "equity.csv").write_text(equity_curve_csv(runtime.engine), encoding="utf-8")
(out / "report.json").write_text(
json.dumps(report.as_dict(), indent=2, ensure_ascii=False, default=str), encoding="utf-8"
)
print(f" Ergebnisdateien in {out.resolve()}")
if args.save_model and learner is not None:
path = learner.save()
print(f" Modell gespeichert: {path}")
finally:
await runtime.close()
return 0
async def cmd_validate(args: argparse.Namespace) -> int:
config = _load(args)
setup_logging(config.log_level)
print("\nKonfiguration gültig:\n" + describe_config(config) + "\n")
if args.offline:
return 0
runtime = await build_runtime(config, with_server=False, with_storage=False, load_model=False)
try:
candles = await runtime.feed.fetch(
config.market.symbols[0], config.market.timeframe, min(config.market.history_bars, 100)
)
print(
f" Verbindung zu {config.exchange.id} steht: {len(candles)} Kerzen für "
f"{config.market.symbols[0]}, letzter Kurs {candles.last_price():.6f}"
)
if config.mode is Mode.LIVE:
cash = await runtime.broker.cash()
print(f" Live-Guthaben: {cash:.2f} {runtime.broker.quote_currency}")
print()
finally:
await runtime.close()
return 0
def cmd_report(args: argparse.Namespace) -> int:
config = _load(args)
setup_logging(config.log_level)
path = Path(config.storage.database_path)
if not path.is_file():
print(f"Keine Datenbank unter {path} noch kein Lauf aufgezeichnet.", file=sys.stderr)
return 1
storage = Storage(path)
try:
total = storage.trade_count()
per_symbol = storage.performance_by_symbol()
recent = storage.recent_trades(args.limit)
if args.json:
print(json.dumps(
{"trades": total, "per_symbol": per_symbol, "recent": recent},
indent=2, ensure_ascii=False, default=str,
))
return 0
print(f"\n Datenbank: {path}")
print(f" Trades gesamt: {total}\n")
if per_symbol:
print(f" {'Symbol':<14}{'Trades':>8}{'Gewinne':>9}{'Netto-P/L':>14}{'Ø %':>9}")
print(" " + "" * 54)
for row in per_symbol:
print(
f" {row['symbol']:<14}{row['trades']:>8}{row['wins']:>9}"
f"{row['net_pnl']:>14.2f}{(row['avg_pnl_pct'] or 0) * 100:>9.2f}"
)
if recent:
print(f"\n Letzte {len(recent)} Trades")
print(f" {'Symbol':<12}{'Grund':<15}{'P/L':>12}{'%':>9}{'Konfidenz':>11}")
print(" " + "" * 59)
for row in recent:
print(
f" {row['symbol']:<12}{row['exit_reason']:<15}{row['pnl_quote']:>12.2f}"
f"{row['pnl_pct'] * 100:>9.2f}{row['entry_confidence']:>11.2f}"
)
print()
finally:
storage.close()
return 0
def cmd_exchanges(args: argparse.Namespace) -> int:
names = available_exchanges()
if args.search:
needle = args.search.lower()
names = [n for n in names if needle in n]
if not names:
print("Keine passende Börse gefunden.")
return 1
print(f"\n {len(names)} Börsen über ccxt ansprechbar (exchange.id in der Konfiguration):\n")
for i in range(0, len(names), 5):
print(" " + "".join(f"{n:<20}" for n in names[i : i + 5]))
print()
return 0
# ------------------------------------------------------------------ Einstieg
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
try:
if args.command == "exchanges":
return cmd_exchanges(args)
if args.command == "report":
return cmd_report(args)
if args.command == "run":
return asyncio.run(cmd_run(args))
if args.command == "backtest":
return asyncio.run(cmd_backtest(args))
if args.command == "validate":
return asyncio.run(cmd_validate(args))
except KeyboardInterrupt:
print("\nAbgebrochen.")
return 130
except SystemExit:
raise
except Exception as exc: # noqa: BLE001 - oberste Fehlerbarriere der CLI
logging.getLogger("trademind").exception("Unbehandelter Fehler")
print(f"\nFehler: {exc}", file=sys.stderr)
return 1
parser.error(f"Unbekanntes Kommando: {args.command}")
return 2
if __name__ == "__main__":
raise SystemExit(main())
+285
View File
@@ -0,0 +1,285 @@
"""Konfiguration: YAML laden, ``${ENV}``-Platzhalter auflösen, per pydantic validieren."""
from __future__ import annotations
import os
import re
from enum import Enum
from pathlib import Path
from typing import Any
import yaml
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
_ENV_PATTERN = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}")
LIVE_CONFIRMATION_PHRASE = "I_UNDERSTAND_THE_RISK"
class Mode(str, Enum):
PAPER = "paper"
LIVE = "live"
BACKTEST = "backtest"
class _Base(BaseModel):
model_config = ConfigDict(extra="forbid")
class ExchangeConfig(_Base):
"""Anbindung an eine Börse. ``id`` ist eine beliebige ccxt-Exchange-ID."""
id: str = "binance"
api_key: str | None = None
api_secret: str | None = None
password: str | None = None # OKX, KuCoin, Coinbase Advanced ...
uid: str | None = None
sandbox: bool = True
enable_rate_limit: bool = True
timeout_ms: int = Field(default=20_000, ge=1_000)
options: dict[str, Any] = Field(default_factory=lambda: {"defaultType": "spot"})
@field_validator("id")
@classmethod
def _lower(cls, v: str) -> str:
return v.strip().lower()
def has_credentials(self) -> bool:
return bool(self.api_key and self.api_secret)
class MarketConfig(_Base):
symbols: list[str] = Field(default_factory=lambda: ["BTC/USDT"])
timeframe: str = "5m"
history_bars: int = Field(default=500, ge=60, le=5_000)
poll_interval_seconds: float = Field(default=20.0, gt=0)
@field_validator("symbols")
@classmethod
def _non_empty(cls, v: list[str]) -> list[str]:
if not v:
raise ValueError("market.symbols darf nicht leer sein")
return [s.strip().upper() for s in v]
class PaperConfig(_Base):
"""Parameter des simulierten Brokers."""
starting_balance: float = Field(default=10_000.0, gt=0)
quote_currency: str = "USDT"
fee_rate: float = Field(default=0.001, ge=0, le=0.05)
slippage_bps: float = Field(default=5.0, ge=0, le=500)
# Teilausführungen bei zu großem Ordervolumen relativ zum Bar-Volumen
max_volume_participation: float = Field(default=0.1, gt=0, le=1.0)
class RiskConfig(_Base):
max_position_pct: float = Field(default=0.2, gt=0, le=1.0)
max_total_exposure_pct: float = Field(default=0.6, gt=0, le=1.0)
max_open_positions: int = Field(default=3, ge=1)
stop_loss_atr_mult: float = Field(default=2.0, ge=0)
take_profit_atr_mult: float = Field(default=3.0, ge=0)
trailing_stop_atr_mult: float = Field(default=0.0, ge=0)
max_holding_bars: int = Field(default=0, ge=0) # 0 = unbegrenzt
max_daily_loss_pct: float = Field(default=0.05, ge=0, le=1.0)
max_drawdown_pct: float = Field(default=0.25, ge=0, le=1.0)
min_notional: float = Field(default=10.0, ge=0)
cooldown_bars_after_exit: int = Field(default=3, ge=0)
class RuleConfig(_Base):
fast_ema: int = Field(default=12, ge=2)
slow_ema: int = Field(default=26, ge=3)
rsi_period: int = Field(default=14, ge=2)
rsi_oversold: float = Field(default=35.0, ge=1, le=99)
rsi_overbought: float = Field(default=70.0, ge=1, le=99)
atr_period: int = Field(default=14, ge=2)
trend_filter_period: int = Field(default=100, ge=0) # 0 = aus
# Mindesthaltedauer für signalbasierte Ausstiege. Verhindert, dass ein frischer
# Einstieg sofort wieder ausgestoppt wird. Stop-Loss und Take-Profit gelten immer.
min_holding_bars: int = Field(default=3, ge=0)
@model_validator(mode="after")
def _ema_order(self) -> RuleConfig:
if self.fast_ema >= self.slow_ema:
raise ValueError("strategy.rules.fast_ema muss kleiner als slow_ema sein")
return self
class LearnerConfig(_Base):
"""Online-Lernen: Bewertung von Einstiegssignalen anhand realisierter Ergebnisse."""
enabled: bool = True
model_path: str = "/data/models/adaptive.npz"
entry_threshold: float = Field(default=0.55, ge=0.0, le=1.0)
exploration_rate: float = Field(default=0.05, ge=0.0, le=1.0)
learning_rate: float = Field(default=0.02, gt=0)
l2: float = Field(default=1e-4, ge=0)
replay_size: int = Field(default=5_000, ge=100)
batch_size: int = Field(default=64, ge=1)
train_every_n_samples: int = Field(default=5, ge=1)
warmup_samples: int = Field(default=200, ge=1)
label_horizon_bars: int = Field(default=12, ge=1)
label_target_bps: float = Field(default=30.0, ge=0)
trade_sample_weight: float = Field(default=3.0, gt=0)
# Einstiegssignale sind selten. Zusätzliche Stichproben des Marktzustands beschleunigen
# die Aufwärmphase erheblich (0 = aus).
background_sample_every_n_bars: int = Field(default=10, ge=0)
background_sample_weight: float = Field(default=0.5, gt=0)
# Beim Start ein noch untrainiertes Modell aus der Kurshistorie vorlernen, statt
# tagelang auf genügend Live-Beobachtungen zu warten (0 = aus).
bootstrap_bars: int = Field(default=3_000, ge=0)
freeze_in_live: bool = False
save_every_n_updates: int = Field(default=50, ge=1)
class StrategyConfig(_Base):
name: str = "adaptive" # adaptive | rules
rules: RuleConfig = Field(default_factory=RuleConfig)
learner: LearnerConfig = Field(default_factory=LearnerConfig)
@field_validator("name")
@classmethod
def _known(cls, v: str) -> str:
v = v.strip().lower()
if v not in {"adaptive", "rules"}:
raise ValueError("strategy.name muss 'adaptive' oder 'rules' sein")
return v
class StorageConfig(_Base):
database_path: str = "/data/trademind.sqlite3"
class ServerConfig(_Base):
enabled: bool = True
host: str = "0.0.0.0"
port: int = Field(default=8080, ge=1, le=65535)
enable_metrics: bool = True
class NotificationConfig(_Base):
webhook_url: str | None = None
notify_on_trade: bool = True
notify_on_risk_halt: bool = True
class BacktestConfig(_Base):
start: str | None = None # ISO-8601, z.B. 2024-01-01T00:00:00Z
end: str | None = None
bars: int = Field(default=5_000, ge=100)
csv_dir: str | None = None # optional: OHLCV aus CSV statt von der Börse
class Config(_Base):
mode: Mode = Mode.PAPER
log_level: str = "INFO"
live_confirmation: str | None = None
exchange: ExchangeConfig = Field(default_factory=ExchangeConfig)
market: MarketConfig = Field(default_factory=MarketConfig)
paper: PaperConfig = Field(default_factory=PaperConfig)
risk: RiskConfig = Field(default_factory=RiskConfig)
strategy: StrategyConfig = Field(default_factory=StrategyConfig)
storage: StorageConfig = Field(default_factory=StorageConfig)
server: ServerConfig = Field(default_factory=ServerConfig)
notifications: NotificationConfig = Field(default_factory=NotificationConfig)
backtest: BacktestConfig = Field(default_factory=BacktestConfig)
@field_validator("log_level")
@classmethod
def _level(cls, v: str) -> str:
v = v.strip().upper()
if v not in {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}:
raise ValueError(f"Unbekannter log_level: {v}")
return v
@model_validator(mode="after")
def _live_guard(self) -> Config:
if self.mode is Mode.LIVE:
if self.live_confirmation != LIVE_CONFIRMATION_PHRASE:
raise ValueError(
"Live-Modus erfordert 'live_confirmation: "
f"{LIVE_CONFIRMATION_PHRASE}' in der Konfiguration."
)
if not self.exchange.has_credentials():
raise ValueError("Live-Modus erfordert exchange.api_key und exchange.api_secret.")
return self
@property
def is_simulated(self) -> bool:
return self.mode in (Mode.PAPER, Mode.BACKTEST)
def _substitute_env(node: Any) -> Any:
"""Ersetzt ``${VAR}`` / ``${VAR:-default}`` rekursiv durch Umgebungsvariablen."""
if isinstance(node, dict):
return {k: _substitute_env(v) for k, v in node.items()}
if isinstance(node, list):
return [_substitute_env(v) for v in node]
if not isinstance(node, str):
return node
def repl(match: re.Match[str]) -> str:
name, default = match.group(1), match.group(2)
return os.environ.get(name, default if default is not None else "")
result = _ENV_PATTERN.sub(repl, node)
# Ein Platzhalter, der zu einem leeren String auflöst, gilt als "nicht gesetzt".
if result == "" and _ENV_PATTERN.search(node):
return None
return result
_TRUE = {"1", "true", "yes", "on"}
_FALSE = {"0", "false", "no", "off"}
def _coerce(raw: str) -> Any:
low = raw.strip().lower()
if low in _TRUE:
return True
if low in _FALSE:
return False
try:
return int(raw)
except ValueError:
pass
try:
return float(raw)
except ValueError:
pass
if "," in raw:
return [part.strip() for part in raw.split(",") if part.strip()]
return raw
def _apply_env_overrides(data: dict[str, Any], prefix: str = "TRADEMIND__") -> dict[str, Any]:
"""``TRADEMIND__RISK__MAX_OPEN_POSITIONS=5`` überschreibt ``risk.max_open_positions``."""
for key, value in os.environ.items():
if not key.startswith(prefix) or not value:
continue
path = [part.lower() for part in key[len(prefix) :].split("__") if part]
if not path:
continue
cursor: dict[str, Any] = data
for part in path[:-1]:
nxt = cursor.get(part)
if not isinstance(nxt, dict):
nxt = {}
cursor[part] = nxt
cursor = nxt
cursor[path[-1]] = _coerce(value)
return data
def load_config(path: str | Path) -> Config:
"""Lädt und validiert die Konfigurationsdatei."""
p = Path(path)
if not p.is_file():
raise FileNotFoundError(f"Konfigurationsdatei nicht gefunden: {p}")
raw = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
if not isinstance(raw, dict):
raise ValueError(f"{p}: erwartet wurde ein YAML-Mapping auf oberster Ebene")
data = _substitute_env(raw)
data = _apply_env_overrides(data)
return Config.model_validate(data)
+251
View File
@@ -0,0 +1,251 @@
"""Marktdaten-Quellen: Live-Abruf über ccxt, CSV-Import und Replay für Backtests."""
from __future__ import annotations
import asyncio
import csv
import logging
from abc import ABC, abstractmethod
from datetime import UTC, datetime
from pathlib import Path
import numpy as np
from .exchange import ExchangeError, timeframe_to_ms
from .models import Candles
log = logging.getLogger(__name__)
MAX_FETCH_LIMIT = 1_000
def parse_iso8601(value: str | None) -> int | None:
"""ISO-8601 → Millisekunden seit Epoch (UTC)."""
if not value:
return None
text = value.strip().replace("Z", "+00:00")
dt = datetime.fromisoformat(text)
if dt.tzinfo is None:
dt = dt.replace(tzinfo=UTC)
return int(dt.timestamp() * 1000)
def format_ts(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%Y-%m-%d %H:%M:%S UTC")
class DataFeed(ABC):
"""Liefert OHLCV-Kerzen für ein Symbol."""
@abstractmethod
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
"""Die letzten ``limit`` abgeschlossenen Kerzen."""
async def close(self) -> None:
return None
class CcxtDataFeed(DataFeed):
"""Öffentliche Marktdaten über ccxt (keine Zugangsdaten nötig)."""
def __init__(self, exchange, max_retries: int = 3, retry_delay: float = 2.0) -> None:
self._exchange = exchange
self._max_retries = max_retries
self._retry_delay = retry_delay
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
rows = await self._fetch_with_retry(symbol, timeframe, min(limit, MAX_FETCH_LIMIT))
# Die letzte Kerze der Börse ist meist noch offen sie wird verworfen,
# damit Indikatoren nicht auf unvollständigen Daten rechnen.
if len(rows) > 1 and _is_incomplete(rows[-1][0], timeframe):
rows = rows[:-1]
return Candles.from_rows(symbol, timeframe, rows)
async def _fetch_with_retry(self, symbol: str, timeframe: str, limit: int) -> list[list[float]]:
last_error: Exception | None = None
for attempt in range(1, self._max_retries + 1):
try:
return await self._exchange.fetch_ohlcv(symbol, timeframe=timeframe, limit=limit)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - ccxt-Fehlerhierarchie ist breit
last_error = exc
if attempt == self._max_retries:
break
delay = self._retry_delay * attempt
log.warning(
"OHLCV-Abruf für %s fehlgeschlagen (Versuch %d/%d): %s erneuter Versuch in %.1fs",
symbol, attempt, self._max_retries, exc, delay,
)
await asyncio.sleep(delay)
raise ExchangeError(f"OHLCV-Abruf für {symbol} endgültig fehlgeschlagen: {last_error}")
async def fetch_history(
self, symbol: str, timeframe: str, bars: int, since_ms: int | None = None, until_ms: int | None = None
) -> Candles:
"""Längere Historie seitenweise laden (für Backtests)."""
step = timeframe_to_ms(timeframe)
if since_ms is None:
end = until_ms if until_ms is not None else int(datetime.now(tz=UTC).timestamp() * 1000)
since_ms = end - bars * step
collected: list[list[float]] = []
cursor = since_ms
while len(collected) < bars:
batch = await self._fetch_with_retry_since(symbol, timeframe, cursor)
if not batch:
break
if collected and batch[0][0] <= collected[-1][0]:
batch = [row for row in batch if row[0] > collected[-1][0]]
if not batch:
break
collected.extend(batch)
cursor = int(batch[-1][0]) + step
if until_ms is not None and cursor >= until_ms:
break
await asyncio.sleep(self._exchange.rateLimit / 1000 if self._exchange.rateLimit else 0.2)
if until_ms is not None:
collected = [row for row in collected if row[0] <= until_ms]
collected = collected[:bars] if since_ms is not None and until_ms is None else collected
log.info("%s: %d historische Kerzen geladen", symbol, len(collected))
return Candles.from_rows(symbol, timeframe, collected)
async def _fetch_with_retry_since(self, symbol: str, timeframe: str, since: int) -> list[list[float]]:
last_error: Exception | None = None
for attempt in range(1, self._max_retries + 1):
try:
return await self._exchange.fetch_ohlcv(
symbol, timeframe=timeframe, since=since, limit=MAX_FETCH_LIMIT
)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001
last_error = exc
if attempt == self._max_retries:
break
await asyncio.sleep(self._retry_delay * attempt)
raise ExchangeError(f"Historien-Abruf für {symbol} fehlgeschlagen: {last_error}")
async def close(self) -> None:
await self._exchange.close()
class ReplayDataFeed(DataFeed):
"""Spielt eine vorgeladene Serie Kerze für Kerze ab (Backtest)."""
def __init__(self, series: dict[str, Candles], warmup: int) -> None:
self._series = series
self._cursor = warmup
self.warmup = warmup
@property
def cursor(self) -> int:
return self._cursor
@property
def length(self) -> int:
return min(len(c) for c in self._series.values()) if self._series else 0
@property
def exhausted(self) -> bool:
return self._cursor >= self.length
def advance(self) -> bool:
self._cursor += 1
return not self.exhausted
async def fetch(self, symbol: str, timeframe: str, limit: int) -> Candles:
full = self._series[symbol]
stop = min(self._cursor + 1, len(full))
start = max(0, stop - limit)
return full.slice(start, stop)
def current_bar(self, symbol: str) -> tuple[float, float, float, float, float]:
"""(open, high, low, close, volume) der aktuellen Kerze."""
c = self._series[symbol]
i = min(self._cursor, len(c) - 1)
return float(c.open[i]), float(c.high[i]), float(c.low[i]), float(c.close[i]), float(c.volume[i])
def timestamp(self, symbol: str) -> int:
c = self._series[symbol]
return int(c.timestamp[min(self._cursor, len(c) - 1)])
def load_csv(path: str | Path, symbol: str, timeframe: str) -> Candles:
"""Lädt OHLCV aus CSV.
Erwartete Spalten (Header, Reihenfolge egal): ``timestamp,open,high,low,close,volume``.
``timestamp`` als Millisekunden, Sekunden oder ISO-8601.
"""
p = Path(path)
rows: list[list[float]] = []
with p.open("r", encoding="utf-8", newline="") as fh:
reader = csv.DictReader(fh)
if reader.fieldnames is None:
raise ValueError(f"{p}: CSV ohne Kopfzeile")
cols = {name.strip().lower(): name for name in reader.fieldnames}
required = ("timestamp", "open", "high", "low", "close", "volume")
missing = [c for c in required if c not in cols]
if missing:
raise ValueError(f"{p}: fehlende Spalten {missing}")
for record in reader:
raw_ts = record[cols["timestamp"]].strip()
rows.append(
[
_parse_timestamp(raw_ts),
float(record[cols["open"]]),
float(record[cols["high"]]),
float(record[cols["low"]]),
float(record[cols["close"]]),
float(record[cols["volume"]] or 0.0),
]
)
rows.sort(key=lambda r: r[0])
log.info("%s: %d Kerzen aus %s geladen", symbol, len(rows), p.name)
return Candles.from_rows(symbol, timeframe, rows)
def _parse_timestamp(raw: str) -> float:
try:
value = float(raw)
except ValueError:
ms = parse_iso8601(raw)
if ms is None:
raise ValueError(f"Zeitstempel nicht interpretierbar: {raw!r}") from None
return float(ms)
# Sekunden vs. Millisekunden unterscheiden (Schwelle ~2001 in ms).
return value * 1000.0 if value < 1e11 else value
def _is_incomplete(candle_open_ms: int, timeframe: str) -> bool:
step = timeframe_to_ms(timeframe)
now_ms = int(datetime.now(tz=UTC).timestamp() * 1000)
return candle_open_ms + step > now_ms
def align_series(series: dict[str, Candles]) -> dict[str, Candles]:
"""Kürzt mehrere Serien auf gemeinsame Zeitstempel, damit der Backtest synchron läuft."""
if len(series) <= 1:
return series
common: set[int] | None = None
for candles in series.values():
stamps = set(int(t) for t in candles.timestamp)
common = stamps if common is None else (common & stamps)
if not common:
raise ValueError("Die geladenen Serien haben keine gemeinsamen Zeitstempel")
keep = np.array(sorted(common), dtype=np.int64)
aligned: dict[str, Candles] = {}
for symbol, candles in series.items():
mask = np.isin(candles.timestamp, keep)
aligned[symbol] = Candles(
symbol=symbol,
timeframe=candles.timeframe,
timestamp=candles.timestamp[mask],
open=candles.open[mask],
high=candles.high[mask],
low=candles.low[mask],
close=candles.close[mask],
volume=candles.volume[mask],
)
return aligned
+519
View File
@@ -0,0 +1,519 @@
"""Handels-Engine: verbindet Marktdaten, Strategie, Risiko und Ausführung.
Die Bar-Verarbeitung (:meth:`TradingEngine.process_bar`) ist identisch für Paper-, Live- und
Backtest-Betrieb; nur die Datenquelle und die Ausführung werden ausgetauscht.
"""
from __future__ import annotations
import asyncio
import logging
import signal
import time
from dataclasses import dataclass
from typing import Any
import numpy as np
from .broker import Broker, InsufficientFunds, OrderRejected, PaperBroker
from .config import Config
from .data import DataFeed
from .features import FEATURE_NAMES, FeatureSnapshot, build_feature_matrix, required_bars
from .models import Action, Candles, ExitReason, Position, Side, Signal
from .notify import Notifier
from .portfolio import Portfolio
from .risk import RiskManager
from .storage import NullStorage, Storage
from .strategy import AdaptiveStrategy, Strategy
log = logging.getLogger(__name__)
STATE_KEY = "engine_state"
@dataclass(slots=True)
class Bar:
timestamp: int
open: float
high: float
low: float
close: float
volume: float
@classmethod
def from_candles(cls, candles: Candles, index: int = -1) -> Bar:
i = index if index >= 0 else len(candles) + index
return cls(
timestamp=int(candles.timestamp[i]),
open=float(candles.open[i]),
high=float(candles.high[i]),
low=float(candles.low[i]),
close=float(candles.close[i]),
volume=float(candles.volume[i]),
)
class TradingEngine:
"""Orchestriert einen Handelslauf."""
def __init__(
self,
config: Config,
broker: Broker,
feed: DataFeed,
strategy: Strategy,
portfolio: Portfolio,
risk: RiskManager,
storage: Storage | NullStorage,
notifier: Notifier | None = None,
) -> None:
self.config = config
self.broker = broker
self.feed = feed
self.strategy = strategy
self.portfolio = portfolio
self.risk = risk
self.storage = storage
self.notifier = notifier
self.running = False
self.startup_error: str | None = None
self.iterations = 0
self.errors = 0
self.started_at = time.time()
self.last_bar_ts: dict[str, int] = {}
self.bar_counter: dict[str, int] = {sym: 0 for sym in config.market.symbols}
self._cash = 0.0
self._stop_event: asyncio.Event | None = None
self._persist_every = 10
self._since_persist = 0
# ------------------------------------------------------------ Lebenszyklus
async def prepare(self) -> None:
await self.broker.start()
self._cash = await self.broker.cash()
self.portfolio.starting_equity = self.portfolio.starting_equity or self._cash
self._restore_state()
log.info(
"Engine bereit Modus %s, %d Symbol(e), Startguthaben %.2f %s",
self.config.mode.value,
len(self.config.market.symbols),
self._cash,
self.broker.quote_currency,
)
async def shutdown(self, liquidate: bool = False) -> None:
if liquidate and self.portfolio.positions:
log.info("Schließe %d offene Position(en) …", len(self.portfolio.positions))
for symbol in list(self.portfolio.positions):
price = self.portfolio.mark_prices.get(symbol)
if price:
await self._close_position(symbol, price, ExitReason.SHUTDOWN, None)
self._persist_state()
learner = getattr(self.strategy, "learner", None)
if learner is not None:
try:
learner.save()
except OSError as exc: # pragma: no cover
log.error("Modell konnte beim Herunterfahren nicht gespeichert werden: %s", exc)
self.running = False
async def bootstrap_learner(self) -> None:
"""Ein noch untrainiertes Modell aus der Kurshistorie vorlernen.
Ohne diesen Schritt bräuchte ein frisch gestarteter Bot bei 5-Minuten-Kerzen
mehrere Tage, bis das Modell genug Beobachtungen für die Aufwärmphase gesammelt hat.
"""
learner = getattr(self.strategy, "learner", None)
warmup = getattr(self.strategy, "warmup_from_history", None)
bars = self.config.strategy.learner.bootstrap_bars
if learner is None or warmup is None or not bars or learner.ready:
return
log.info("Modell ist untrainiert lerne aus bis zu %d historischen Kerzen vor …", bars)
before = learner.stats.samples_seen
for symbol in self.config.market.symbols:
try:
candles = await self._fetch_history(symbol, bars)
except Exception as exc: # noqa: BLE001 - Vorlernen darf den Start nie verhindern
log.warning("%s: Historie für das Vorlernen nicht abrufbar (%s)", symbol, exc)
continue
matrix = build_feature_matrix(candles, self.config.strategy.rules)
if matrix is None:
log.warning("%s: zu wenig Historie zum Vorlernen (%d Kerzen)", symbol, len(candles))
continue
last_index = warmup(symbol, matrix, candles)
# Zähler und Zeitstempel fortschreiben, damit der Live-Loop nahtlos anschließt
# und die zuletzt genutzte Kerze nicht doppelt verarbeitet wird.
self.bar_counter[symbol] = last_index
self.last_bar_ts[symbol] = int(candles.timestamp[last_index])
gained = learner.stats.samples_seen - before
log.info(
"Vorlernen abgeschlossen: %d neue Beobachtungen (gesamt %d), Modell %s",
gained, learner.stats.samples_seen, "einsatzbereit" if learner.ready else "noch im Aufwärmen",
)
if gained:
try:
learner.save()
except OSError as exc: # pragma: no cover
log.error("Vorgelerntes Modell konnte nicht gespeichert werden: %s", exc)
async def _fetch_history(self, symbol: str, bars: int) -> Candles:
"""Längere Historie holen, wenn der Feed das kann sonst das normale Fenster."""
fetch_history = getattr(self.feed, "fetch_history", None)
if fetch_history is not None:
return await fetch_history(symbol, self.config.market.timeframe, bars)
return await self.feed.fetch(symbol, self.config.market.timeframe, bars)
def request_stop(self) -> None:
log.info("Stopp angefordert beende nach dem aktuellen Durchlauf")
if self._stop_event is not None:
self._stop_event.set()
self.running = False
# ------------------------------------------------------------- Hauptloop
async def run(self) -> None:
"""Endlosschleife für Paper- und Live-Betrieb."""
self._stop_event = asyncio.Event()
self.running = True
interval = self.config.market.poll_interval_seconds
history = max(self.config.market.history_bars, required_bars(self.config.strategy.rules) + 10)
while self.running:
cycle_start = time.monotonic()
try:
await self._tick(history)
self.iterations += 1
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - der Loop darf nie sterben
self.errors += 1
log.exception("Fehler im Handelsdurchlauf: %s", exc)
if self.errors > 50 and self.iterations == 0:
self.startup_error = str(exc)
log.error("Zu viele Fehler ohne erfolgreichen Durchlauf Abbruch")
break
elapsed = time.monotonic() - cycle_start
wait = max(0.5, interval - elapsed)
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=wait)
break # Stopp-Event wurde gesetzt
except TimeoutError:
continue
log.info("Handels-Loop beendet nach %d Durchläufen (%d Fehler)", self.iterations, self.errors)
async def _tick(self, history: int) -> None:
self._cash = await self.broker.cash()
for symbol in self.config.market.symbols:
candles = await self.feed.fetch(symbol, self.config.market.timeframe, history)
if len(candles) == 0:
log.warning("%s: keine Kerzen erhalten", symbol)
continue
bar = Bar.from_candles(candles)
self.portfolio.update_mark(symbol, bar.close)
if self.last_bar_ts.get(symbol) == bar.timestamp:
continue # noch dieselbe Kerze nichts Neues zu entscheiden
self.last_bar_ts[symbol] = bar.timestamp
matrix = build_feature_matrix(candles, self.config.strategy.rules)
snapshot = matrix.snapshot(-1) if matrix is not None else None
if snapshot is None:
log.info(
"%s: Historie noch zu kurz (%d/%d Kerzen) warte",
symbol, len(candles), required_bars(self.config.strategy.rules),
)
continue
self.bar_counter[symbol] = self.bar_counter.get(symbol, 0) + 1
await self.process_bar(symbol, snapshot, bar)
self._record_equity()
self._maybe_persist()
# -------------------------------------------------------- Bar-Verarbeitung
async def process_bar(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None:
"""Verarbeitet genau eine abgeschlossene Kerze für ein Symbol."""
bar_index = self.bar_counter.get(symbol, 0)
self.portfolio.on_new_bar(symbol, bar.high, bar.low, bar.close)
self.strategy.on_bar(symbol, snapshot, bar_index, bar.high, bar.low, bar.close)
halt_reason = self.risk.evaluate_halt(self.portfolio, self._cash, bar.timestamp)
if halt_reason and self.notifier is not None:
self.notifier.risk_halt(halt_reason)
position = self.portfolio.positions.get(symbol)
if position is not None:
if await self._manage_open_position(symbol, position, snapshot, bar):
return
elif not self.risk.trading_halted:
await self._maybe_enter(symbol, snapshot, bar)
async def _manage_open_position(
self, symbol: str, position: Position, snapshot: FeatureSnapshot, bar: Bar
) -> bool:
"""Stop/Ziel/Signal prüfen. Gibt ``True`` zurück, wenn die Position geschlossen wurde."""
self.risk.update_trailing(position, snapshot.atr)
if self.risk.force_liquidation():
await self._close_position(symbol, bar.close, ExitReason.RISK_HALT, bar)
return True
reason, exit_price = self.risk.check_exit(position, bar.high, bar.low, bar.close)
if reason is not None:
await self._close_position(symbol, exit_price, reason, bar)
return True
signal = self.strategy.evaluate(symbol, snapshot, position)
if signal.action is Action.EXIT_LONG:
log.info("%s: Ausstiegssignal (%s)", symbol, signal.reason)
await self._close_position(symbol, bar.close, ExitReason.SIGNAL, bar)
return True
return False
async def _maybe_enter(self, symbol: str, snapshot: FeatureSnapshot, bar: Bar) -> None:
signal = self.strategy.evaluate(symbol, snapshot, None)
if signal.action is not Action.ENTER_LONG:
if signal.confidence and log.isEnabledFor(logging.DEBUG):
log.debug("%s: kein Einstieg %s", symbol, signal.reason)
return
decision = self.risk.can_open(symbol, self.portfolio, self._cash, bar.close)
if not decision:
log.debug("%s: Einstieg durch Risikoprüfung verhindert %s", symbol, decision.reason)
return
amount, why_not = self.risk.position_size(
self.portfolio,
self._cash,
bar.close,
min_amount=self.broker.min_amount(symbol),
min_cost=self.broker.min_cost(symbol),
)
if amount <= 0:
log.info("%s: Einstieg übersprungen %s", symbol, why_not)
return
try:
fill = await self.broker.execute(
symbol, Side.BUY, amount, bar.close, bar_volume=bar.volume, timestamp=bar.timestamp
)
except (InsufficientFunds, OrderRejected) as exc:
log.warning("%s: Kauf nicht ausgeführt %s", symbol, exc)
return
self._cash = await self.broker.cash()
stop, target = self.risk.stop_levels(fill.price, snapshot.atr)
self.portfolio.open_position(
fill,
stop_loss=stop,
take_profit=target,
features=signal.features,
confidence=signal.confidence,
exploratory=signal.exploratory,
)
log.info(
"%s: EINSTIEG %.8f @ %.6f (%.2f %s) | %s | SL %s TP %s",
symbol, fill.amount, fill.price, fill.notional, self.broker.quote_currency,
signal.reason,
f"{stop:.6f}" if stop else "",
f"{target:.6f}" if target else "",
)
if self.notifier is not None:
self.notifier.position_opened(
symbol, fill.amount, fill.price, signal.confidence, self.broker.quote_currency
)
async def _close_position(
self, symbol: str, price: float, reason: ExitReason, bar: Bar | None
) -> None:
position = self.portfolio.positions.get(symbol)
if position is None:
return
try:
# Ausstiege werden bewusst nicht durch die Volumengrenze gedrosselt
# Risikomanagement muss jederzeit vollständig aussteigen können.
fill = await self.broker.execute(
symbol,
Side.SELL,
position.amount,
price,
bar_volume=None,
timestamp=bar.timestamp if bar else None,
)
except (InsufficientFunds, OrderRejected) as exc:
log.error("%s: Ausstieg fehlgeschlagen (%s) Position bleibt offen!", symbol, exc)
return
trade = self.portfolio.close_position(fill, reason, mode=self.config.mode.value)
self._cash = await self.broker.cash()
self.portfolio.start_cooldown(symbol, self.config.risk.cooldown_bars_after_exit)
self.strategy.on_trade_closed(position, trade.pnl_quote)
self.storage.record_trade(trade)
log.info(
"%s: AUSSTIEG %.8f @ %.6f (%s) | P/L %+.2f %s (%+.2f%%) | Equity %.2f",
symbol, fill.amount, fill.price, reason.value, trade.pnl_quote,
self.broker.quote_currency, trade.pnl_pct * 100.0,
self.portfolio.equity(self._cash),
)
if self.notifier is not None:
self.notifier.trade_closed(
trade, self.portfolio.equity(self._cash), self.broker.quote_currency
)
# -------------------------------------------------------------- Zustand
def _record_equity(self) -> None:
timestamp = max(self.last_bar_ts.values()) if self.last_bar_ts else int(time.time() * 1000)
equity = self.portfolio.record_equity(timestamp, self._cash)
self.storage.record_equity(timestamp, equity, self._cash, self.portfolio.exposure())
def _maybe_persist(self) -> None:
self._since_persist += 1
if self._since_persist >= self._persist_every:
self._since_persist = 0
self._persist_state()
def _persist_state(self) -> None:
state: dict[str, Any] = {
"mode": self.config.mode.value,
"cash": self._cash,
"peak_equity": self.portfolio.peak_equity,
"max_drawdown": self.portfolio.max_drawdown,
"starting_equity": self.portfolio.starting_equity,
"bar_counter": self.bar_counter,
"cooldowns": self.portfolio.cooldowns,
"positions": [_position_to_dict(p) for p in self.portfolio.positions.values()],
}
if isinstance(self.broker, PaperBroker):
state["paper"] = self.broker.state()
self.storage.save_state(STATE_KEY, state)
def _restore_state(self) -> None:
state = self.storage.load_state(STATE_KEY)
if not state:
return
if state.get("mode") != self.config.mode.value:
log.info(
"Gespeicherter Zustand stammt aus Modus '%s' wird für '%s' ignoriert",
state.get("mode"), self.config.mode.value,
)
return
if isinstance(self.broker, PaperBroker) and "paper" in state:
paper = state["paper"]
self.broker.restore(
paper.get("cash", 0.0), paper.get("holdings", {}), paper.get("total_fees", 0.0)
)
self._cash = self.broker.cash_sync()
self.portfolio.starting_equity = float(state.get("starting_equity") or self.portfolio.starting_equity)
self.portfolio.peak_equity = float(state.get("peak_equity") or self.portfolio.starting_equity)
self.portfolio.max_drawdown = float(state.get("max_drawdown") or 0.0)
self.bar_counter.update({k: int(v) for k, v in (state.get("bar_counter") or {}).items()})
self.portfolio.cooldowns = {k: int(v) for k, v in (state.get("cooldowns") or {}).items()}
restored = 0
for raw in state.get("positions", []):
position = _position_from_dict(raw)
if position.symbol not in self.config.market.symbols:
log.warning(
"Wiederhergestellte Position %s ist nicht mehr konfiguriert bitte manuell prüfen",
position.symbol,
)
self.portfolio.positions[position.symbol] = position
self.portfolio.update_mark(position.symbol, position.entry_price)
restored += 1
if restored:
log.info("%d offene Position(en) aus dem gespeicherten Zustand übernommen", restored)
# ------------------------------------------------------------- Statusdaten
def status(self) -> dict[str, Any]:
learner = getattr(self.strategy, "learner", None)
return {
"running": self.running,
"mode": self.config.mode.value,
"exchange": self.config.exchange.id,
"sandbox": self.config.exchange.sandbox,
"symbols": self.config.market.symbols,
"timeframe": self.config.market.timeframe,
"quote_currency": self.broker.quote_currency,
"uptime_seconds": round(time.time() - self.started_at, 1),
"iterations": self.iterations,
"errors": self.errors,
"startup_error": self.startup_error,
"bars_processed": dict(self.bar_counter),
"portfolio": self.portfolio.summary(self._cash),
"positions": self.portfolio.open_positions_view(),
"recent_trades": self.portfolio.recent_trades(25),
"strategy": self.strategy.snapshot(),
"risk": self.risk.snapshot(),
"feature_weights": (
learner.feature_importance(FEATURE_NAMES) if learner is not None else {}
),
}
def _position_to_dict(position: Position) -> dict[str, Any]:
return {
"id": position.id,
"symbol": position.symbol,
"amount": position.amount,
"entry_price": position.entry_price,
"entry_timestamp": position.entry_timestamp,
"stop_loss": position.stop_loss,
"take_profit": position.take_profit,
"trailing_stop": position.trailing_stop,
"highest_price": position.highest_price,
"bars_held": position.bars_held,
"entry_fee_quote": position.entry_fee_quote,
"entry_confidence": position.entry_confidence,
"exploratory": position.exploratory,
"entry_features": (
None if position.entry_features is None else [float(v) for v in position.entry_features]
),
}
def _position_from_dict(raw: dict[str, Any]) -> Position:
features = raw.get("entry_features")
return Position(
symbol=raw["symbol"],
amount=float(raw["amount"]),
entry_price=float(raw["entry_price"]),
entry_timestamp=int(raw["entry_timestamp"]),
stop_loss=raw.get("stop_loss"),
take_profit=raw.get("take_profit"),
trailing_stop=raw.get("trailing_stop"),
highest_price=float(raw.get("highest_price") or raw["entry_price"]),
bars_held=int(raw.get("bars_held") or 0),
entry_fee_quote=float(raw.get("entry_fee_quote") or 0.0),
entry_features=None if features is None else np.asarray(features, dtype=np.float64),
entry_confidence=float(raw.get("entry_confidence") or 0.0),
exploratory=bool(raw.get("exploratory")),
id=raw.get("id") or "",
)
def install_signal_handlers(engine: TradingEngine) -> None:
"""SIGTERM/SIGINT abfangen, damit ``podman stop`` sauber herunterfährt."""
loop = asyncio.get_running_loop()
for sig_name in ("SIGTERM", "SIGINT"):
sig = getattr(signal, sig_name, None)
if sig is None:
continue
try:
loop.add_signal_handler(sig, engine.request_stop)
except NotImplementedError: # Windows kennt add_signal_handler für SIGTERM nicht
signal.signal(sig, lambda *_: engine.request_stop())
__all__ = ["Bar", "TradingEngine", "install_signal_handlers", "AdaptiveStrategy", "Signal"]
+123
View File
@@ -0,0 +1,123 @@
"""Aufbau des ccxt-Clients und Hilfsfunktionen rund um Börsen-Metadaten.
Über ccxt sind u. a. Binance, Kraken, Coinbase, Bybit, OKX, KuCoin, Bitget, Gate.io,
Bitstamp und MEXC ansprechbar die Auswahl erfolgt allein über ``exchange.id``.
"""
from __future__ import annotations
import logging
from typing import Any
import ccxt.async_support as ccxt
from .config import ExchangeConfig
log = logging.getLogger(__name__)
# Börsen, die einen zusätzlichen Passphrase/Password benötigen.
PASSWORD_EXCHANGES = {"okx", "kucoin", "kucoinfutures", "coinbase", "coinbaseadvanced", "bitget"}
TIMEFRAME_MS = {
"1m": 60_000,
"3m": 180_000,
"5m": 300_000,
"15m": 900_000,
"30m": 1_800_000,
"1h": 3_600_000,
"2h": 7_200_000,
"4h": 14_400_000,
"6h": 21_600_000,
"8h": 28_800_000,
"12h": 43_200_000,
"1d": 86_400_000,
"3d": 259_200_000,
"1w": 604_800_000,
}
class ExchangeError(RuntimeError):
"""Fehler beim Aufbau oder Betrieb der Börsenanbindung."""
def timeframe_to_ms(timeframe: str) -> int:
try:
return TIMEFRAME_MS[timeframe]
except KeyError:
raise ExchangeError(
f"Unbekannter timeframe '{timeframe}'. Erlaubt: {', '.join(TIMEFRAME_MS)}"
) from None
def available_exchanges() -> list[str]:
return sorted(ccxt.exchanges)
def build_exchange(config: ExchangeConfig, *, read_only: bool = False) -> ccxt.Exchange:
"""Erzeugt einen asynchronen ccxt-Client.
``read_only=True`` lässt die Zugangsdaten weg ausreichend für öffentliche
Marktdaten und damit der Standard in Paper- und Backtest-Modus.
"""
if config.id not in ccxt.exchanges:
raise ExchangeError(
f"Börse '{config.id}' ist in ccxt unbekannt. Verfügbar u. a.: "
+ ", ".join(list(ccxt.exchanges)[:15])
+ " ..."
)
params: dict[str, Any] = {
"enableRateLimit": config.enable_rate_limit,
"timeout": config.timeout_ms,
"options": dict(config.options),
}
if not read_only:
if not config.has_credentials():
raise ExchangeError("API-Key und Secret werden für authentifizierte Aufrufe benötigt.")
params["apiKey"] = config.api_key
params["secret"] = config.api_secret
if config.password:
params["password"] = config.password
elif config.id in PASSWORD_EXCHANGES:
log.warning("Börse '%s' verlangt üblicherweise ein Passphrase (exchange.password).", config.id)
if config.uid:
params["uid"] = config.uid
exchange = getattr(ccxt, config.id)(params)
if config.sandbox:
try:
exchange.set_sandbox_mode(True)
log.info("Sandbox/Testnet für '%s' aktiviert", config.id)
except Exception as exc: # noqa: BLE001 - ccxt wirft heterogene Typen
log.warning("Sandbox-Modus für '%s' nicht verfügbar: %s", config.id, exc)
return exchange
async def load_market_info(exchange: ccxt.Exchange, symbols: list[str]) -> dict[str, dict[str, Any]]:
"""Lädt Handelsregeln (Präzision, Mindestgrößen) für die konfigurierten Symbole."""
markets = await exchange.load_markets()
info: dict[str, dict[str, Any]] = {}
missing = []
for symbol in symbols:
market = markets.get(symbol)
if market is None:
missing.append(symbol)
continue
limits = market.get("limits") or {}
info[symbol] = {
"amount_precision": (market.get("precision") or {}).get("amount"),
"price_precision": (market.get("precision") or {}).get("price"),
"min_amount": ((limits.get("amount") or {}).get("min")) or 0.0,
"min_cost": ((limits.get("cost") or {}).get("min")) or 0.0,
"maker": market.get("maker"),
"taker": market.get("taker"),
"base": market.get("base"),
"quote": market.get("quote"),
}
if missing:
raise ExchangeError(
f"Symbole an '{exchange.id}' nicht handelbar: {', '.join(missing)}. "
"Bitte market.symbols prüfen (Schreibweise z. B. 'BTC/USDT')."
)
return info
+225
View File
@@ -0,0 +1,225 @@
"""Feature-Engineering: aus einer Kerzenserie normierte Merkmalsvektoren bauen.
Alle Features sind bewusst skalenfrei (Verhältnisse, Prozentwerte, z-Scores), damit ein
Modell über verschiedene Symbole und Preisniveaus hinweg lernen kann.
Die Indikatoren werden einmal über die gesamte Serie berechnet (``build_feature_matrix``);
Backtests laufen dadurch linear statt quadratisch.
"""
from __future__ import annotations
from dataclasses import dataclass
import numpy as np
from .config import RuleConfig
from .indicators import atr, donchian_position, ema, macd, roc, rolling_std, rsi, sma
from .models import Candles
FEATURE_NAMES: tuple[str, ...] = (
"ema_spread", # (EMA_fast - EMA_slow) / Preis [%]
"ema_fast_dist", # (Preis - EMA_fast) / Preis [%]
"trend_dist", # (Preis - EMA_trend) / Preis [%]
"rsi_norm", # (RSI - 50) / 50
"rsi_slope", # RSI-Änderung über 3 Bars / 50
"macd_hist", # MACD-Histogramm / Preis [%]
"macd_hist_slope",
"atr_pct", # ATR / Preis [%]
"vol_ratio", # kurzfristige vs. langfristige Kursvolatilität
"roc_3",
"roc_12",
"donchian_pos", # Lage in der 20-Bar-Range, zentriert auf 0
"volume_z", # z-Score des Volumens
"body_ratio", # Kerzenkörper / Range
"upper_wick",
"lower_wick",
"time_sin", # zyklische Tageszeit
"time_cos",
)
N_FEATURES = len(FEATURE_NAMES)
MIN_BARS = 140
_CLIP_LIMIT = 8.0
@dataclass(slots=True)
class FeatureSnapshot:
"""Merkmalsvektor plus Roh-Kennzahlen, die Risiko und Regelwerk zusätzlich brauchen."""
values: np.ndarray
names: tuple[str, ...]
index: int
price: float
atr: float
rsi: float
rsi_prev: float
ema_fast: float
ema_slow: float
ema_fast_prev: float
ema_slow_prev: float
trend_ema: float
timestamp: int
def as_dict(self) -> dict[str, float]:
return {name: float(v) for name, v in zip(self.names, self.values, strict=True)}
def required_bars(rules: RuleConfig) -> int:
"""Minimale Anzahl Kerzen, damit alle Indikatoren belastbare Werte liefern."""
return max(MIN_BARS, rules.trend_filter_period + 10, rules.slow_ema * 3, rules.rsi_period * 4)
def _clean(arr: np.ndarray, fill: float | np.ndarray = 0.0) -> np.ndarray:
out = np.asarray(arr, dtype=np.float64).copy()
bad = ~np.isfinite(out)
if np.any(bad):
out[bad] = fill[bad] if isinstance(fill, np.ndarray) else fill
return out
def _pct(numerator: np.ndarray, price: np.ndarray) -> np.ndarray:
with np.errstate(divide="ignore", invalid="ignore"):
return np.where(price > 0, numerator / price * 100.0, 0.0)
@dataclass(slots=True)
class FeatureMatrix:
"""Alle Merkmalsvektoren einer Serie plus die Roh-Indikatoren."""
values: np.ndarray # (n, N_FEATURES)
timestamp: np.ndarray
price: np.ndarray
atr: np.ndarray
rsi: np.ndarray
ema_fast: np.ndarray
ema_slow: np.ndarray
trend_ema: np.ndarray
first_valid: int # ab hier sind die Zeilen belastbar
def __len__(self) -> int:
return int(self.values.shape[0])
def is_valid(self, index: int) -> bool:
idx = index if index >= 0 else len(self) + index
return self.first_valid <= idx < len(self)
def snapshot(self, index: int = -1) -> FeatureSnapshot | None:
n = len(self)
idx = index if index >= 0 else n + index
if not self.is_valid(idx):
return None
prev = max(idx - 1, 0)
return FeatureSnapshot(
values=self.values[idx].copy(),
names=FEATURE_NAMES,
index=idx,
price=float(self.price[idx]),
atr=float(self.atr[idx]),
rsi=float(self.rsi[idx]),
rsi_prev=float(self.rsi[prev]),
ema_fast=float(self.ema_fast[idx]),
ema_slow=float(self.ema_slow[idx]),
ema_fast_prev=float(self.ema_fast[prev]),
ema_slow_prev=float(self.ema_slow[prev]),
trend_ema=float(self.trend_ema[idx]),
timestamp=int(self.timestamp[idx]),
)
def build_feature_matrix(candles: Candles, rules: RuleConfig) -> FeatureMatrix | None:
"""Berechnet Indikatoren und Merkmalsvektoren für die gesamte Serie.
Gibt ``None`` zurück, wenn die Historie kürzer als ``required_bars`` ist.
"""
n = len(candles)
need = required_bars(rules)
if n < need:
return None
close = np.asarray(candles.close, dtype=np.float64)
high = np.asarray(candles.high, dtype=np.float64)
low = np.asarray(candles.low, dtype=np.float64)
open_ = np.asarray(candles.open, dtype=np.float64)
volume = np.asarray(candles.volume, dtype=np.float64)
price = np.where(close > 0, close, np.nan)
ema_fast = ema(close, rules.fast_ema)
ema_slow = ema(close, rules.slow_ema)
trend_period = rules.trend_filter_period or rules.slow_ema * 4
trend_ema = ema(close, trend_period)
rsi_arr = _clean(rsi(close, rules.rsi_period), 50.0)
atr_raw = atr(high, low, close, rules.atr_period)
atr_arr = _clean(atr_raw, close * 0.005)
atr_arr = np.where(atr_arr > 0, atr_arr, np.maximum(close * 0.005, 1e-9))
_, _, macd_hist = macd(close, rules.fast_ema, rules.slow_ema, 9)
macd_hist = _clean(macd_hist)
roc3 = _clean(roc(close, 3))
roc12 = _clean(roc(close, 12))
dpos = _clean(donchian_position(high, low, close, 20), 0.5)
vol_short = _clean(rolling_std(close, 10))
vol_long = _clean(rolling_std(close, 50))
with np.errstate(divide="ignore", invalid="ignore"):
vol_ratio = np.where(vol_long > 1e-12, vol_short / vol_long, 1.0)
volume_mean = _clean(sma(volume, 50), float(np.mean(volume)) if volume.size else 0.0)
volume_std = _clean(rolling_std(volume, 50))
with np.errstate(divide="ignore", invalid="ignore"):
volume_z = np.where(volume_std > 1e-12, (volume - volume_mean) / volume_std, 0.0)
bar_range = np.maximum(high - low, 1e-12)
body = np.abs(close - open_) / bar_range
upper_wick = (high - np.maximum(open_, close)) / bar_range
lower_wick = (np.minimum(open_, close) - low) / bar_range
seconds_of_day = (np.asarray(candles.timestamp, dtype=np.int64) // 1000) % 86_400
angle = 2.0 * np.pi * seconds_of_day.astype(np.float64) / 86_400.0
rsi_prev = np.concatenate([np.full(min(3, n), rsi_arr[0]), rsi_arr[:-3]])[:n] if n > 3 else rsi_arr
hist_prev = np.concatenate([macd_hist[:1], macd_hist[:-1]])
columns = [
_pct(ema_fast - ema_slow, price),
_pct(close - ema_fast, price),
_pct(close - trend_ema, price),
(rsi_arr - 50.0) / 50.0,
(rsi_arr - rsi_prev) / 50.0,
_pct(macd_hist, price),
_pct(macd_hist - hist_prev, price),
_pct(atr_arr, price),
vol_ratio - 1.0,
roc3 * 100.0,
roc12 * 100.0,
dpos - 0.5,
volume_z,
body,
upper_wick,
lower_wick,
np.sin(angle),
np.cos(angle),
]
values = np.column_stack([_clean(col) for col in columns])
np.clip(values, -_CLIP_LIMIT, _CLIP_LIMIT, out=values)
return FeatureMatrix(
values=values,
timestamp=np.asarray(candles.timestamp, dtype=np.int64),
price=_clean(close),
atr=atr_arr,
rsi=rsi_arr,
ema_fast=_clean(ema_fast, close),
ema_slow=_clean(ema_slow, close),
trend_ema=_clean(trend_ema, close),
first_valid=need - 1,
)
def compute_features(candles: Candles, rules: RuleConfig, index: int = -1) -> FeatureSnapshot | None:
"""Bequemlichkeits-Wrapper für einen einzelnen Zeitpunkt (Live-Loop, Tests)."""
matrix = build_feature_matrix(candles, rules)
if matrix is None:
return None
return matrix.snapshot(index)
+156
View File
@@ -0,0 +1,156 @@
"""Technische Indikatoren auf reinen numpy-Arrays (keine pandas-Abhängigkeit)."""
from __future__ import annotations
import numpy as np
__all__ = [
"ema",
"sma",
"rsi",
"true_range",
"atr",
"macd",
"bollinger",
"rolling_std",
"roc",
"donchian_position",
]
def _as_float(values: np.ndarray) -> np.ndarray:
arr = np.asarray(values, dtype=np.float64)
if arr.ndim != 1:
raise ValueError("Es wird ein eindimensionales Array erwartet")
return arr
def ema(values: np.ndarray, period: int) -> np.ndarray:
"""Exponentiell gewichteter Durchschnitt, Seed = erster Wert."""
arr = _as_float(values)
if period < 1:
raise ValueError("period muss >= 1 sein")
if arr.size == 0:
return arr
alpha = 2.0 / (period + 1.0)
out = np.empty_like(arr)
out[0] = arr[0]
for i in range(1, arr.size):
out[i] = alpha * arr[i] + (1.0 - alpha) * out[i - 1]
return out
def sma(values: np.ndarray, period: int) -> np.ndarray:
"""Gleitender Durchschnitt; die ersten ``period-1`` Werte sind NaN."""
arr = _as_float(values)
if period < 1:
raise ValueError("period muss >= 1 sein")
out = np.full(arr.size, np.nan)
if arr.size < period:
return out
cumsum = np.cumsum(np.insert(arr, 0, 0.0))
out[period - 1 :] = (cumsum[period:] - cumsum[:-period]) / period
return out
def rolling_std(values: np.ndarray, period: int) -> np.ndarray:
arr = _as_float(values)
out = np.full(arr.size, np.nan)
if arr.size < period or period < 2:
return out
for i in range(period - 1, arr.size):
out[i] = float(np.std(arr[i - period + 1 : i + 1], ddof=0))
return out
def rsi(values: np.ndarray, period: int = 14) -> np.ndarray:
"""Relative Strength Index nach Wilder. Werte vor dem Seed sind NaN."""
arr = _as_float(values)
out = np.full(arr.size, np.nan)
if arr.size <= period:
return out
delta = np.diff(arr)
gains = np.clip(delta, 0.0, None)
losses = np.clip(-delta, 0.0, None)
avg_gain = float(np.mean(gains[:period]))
avg_loss = float(np.mean(losses[:period]))
out[period] = _rsi_value(avg_gain, avg_loss)
for i in range(period + 1, arr.size):
avg_gain = (avg_gain * (period - 1) + gains[i - 1]) / period
avg_loss = (avg_loss * (period - 1) + losses[i - 1]) / period
out[i] = _rsi_value(avg_gain, avg_loss)
return out
def _rsi_value(avg_gain: float, avg_loss: float) -> float:
if avg_loss <= 1e-12:
return 100.0 if avg_gain > 0 else 50.0
rs = avg_gain / avg_loss
return 100.0 - (100.0 / (1.0 + rs))
def true_range(high: np.ndarray, low: np.ndarray, close: np.ndarray) -> np.ndarray:
h, low_a, c = _as_float(high), _as_float(low), _as_float(close)
tr = np.empty_like(c)
tr[0] = h[0] - low_a[0]
prev_close = c[:-1]
tr[1:] = np.maximum.reduce(
[h[1:] - low_a[1:], np.abs(h[1:] - prev_close), np.abs(low_a[1:] - prev_close)]
)
return tr
def atr(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 14) -> np.ndarray:
"""Average True Range (Wilder-Glättung)."""
tr = true_range(high, low, close)
out = np.full(tr.size, np.nan)
if tr.size < period:
return out
seed = float(np.mean(tr[:period]))
out[period - 1] = seed
for i in range(period, tr.size):
seed = (seed * (period - 1) + tr[i]) / period
out[i] = seed
return out
def macd(
values: np.ndarray, fast: int = 12, slow: int = 26, signal: int = 9
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Gibt (macd_line, signal_line, histogram) zurück."""
macd_line = ema(values, fast) - ema(values, slow)
signal_line = ema(macd_line, signal)
return macd_line, signal_line, macd_line - signal_line
def bollinger(
values: np.ndarray, period: int = 20, num_std: float = 2.0
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Gibt (unteres Band, Mittelband, oberes Band) zurück."""
mid = sma(values, period)
sd = rolling_std(values, period)
return mid - num_std * sd, mid, mid + num_std * sd
def roc(values: np.ndarray, period: int = 10) -> np.ndarray:
"""Rate of Change als Anteil (0.01 == +1 %)."""
arr = _as_float(values)
out = np.full(arr.size, np.nan)
if arr.size <= period:
return out
base = arr[:-period]
with np.errstate(divide="ignore", invalid="ignore"):
out[period:] = np.where(base != 0, (arr[period:] - base) / base, 0.0)
return out
def donchian_position(high: np.ndarray, low: np.ndarray, close: np.ndarray, period: int = 20) -> np.ndarray:
"""Position des Schlusskurses in der Donchian-Range: 0 = Tief, 1 = Hoch."""
h, low_a, c = _as_float(high), _as_float(low), _as_float(close)
out = np.full(c.size, np.nan)
for i in range(period - 1, c.size):
window_high = float(np.max(h[i - period + 1 : i + 1]))
window_low = float(np.min(low_a[i - period + 1 : i + 1]))
span = window_high - window_low
out[i] = 0.5 if span <= 1e-12 else (c[i] - window_low) / span
return out
+563
View File
@@ -0,0 +1,563 @@
"""Online-Lernkomponente.
Der Bot bewertet jedes Einstiegssignal mit einer Gewinnwahrscheinlichkeit. Das Modell ist
eine logistische Regression, die inkrementell (Adam + Replay-Buffer) aus zwei Quellen lernt:
1. **Shadow-Labels** für jedes Kandidatensignal wird nach ``label_horizon_bars`` geprüft,
ob der Kurs das Ziel (``label_target_bps``) erreicht hätte. Liefert schnell viele Daten.
2. **Reale Trade-Ergebnisse** abgeschlossene Round-Trips, mit höherem Gewicht.
Der Zustand (Gewichte + Normalisierung + Statistik) wird als ``.npz`` persistiert und
überlebt damit Container-Neustarts.
"""
from __future__ import annotations
import logging
import threading
from dataclasses import dataclass, field
from pathlib import Path
import numpy as np
from .config import LearnerConfig
log = logging.getLogger(__name__)
MODEL_FORMAT_VERSION = 2
def sigmoid(z: np.ndarray | float) -> np.ndarray:
z = np.clip(np.asarray(z, dtype=np.float64), -35.0, 35.0)
return 1.0 / (1.0 + np.exp(-z))
class RunningScaler:
"""Welford-Normalisierung: laufender Mittelwert und Varianz je Feature."""
def __init__(self, n_features: int) -> None:
self.n_features = n_features
self.count = 0.0
self.mean = np.zeros(n_features, dtype=np.float64)
self.m2 = np.zeros(n_features, dtype=np.float64)
def update(self, x: np.ndarray) -> None:
x = np.asarray(x, dtype=np.float64).reshape(-1)
self.count += 1.0
delta = x - self.mean
self.mean += delta / self.count
self.m2 += delta * (x - self.mean)
@property
def std(self) -> np.ndarray:
if self.count < 2:
return np.ones(self.n_features, dtype=np.float64)
var = self.m2 / (self.count - 1.0)
return np.sqrt(np.maximum(var, 1e-8))
def transform(self, x: np.ndarray) -> np.ndarray:
arr = np.asarray(x, dtype=np.float64)
scaled = (arr - self.mean) / self.std
return np.clip(scaled, -6.0, 6.0)
def state(self) -> dict[str, np.ndarray]:
return {
"scaler_count": np.array([self.count]),
"scaler_mean": self.mean,
"scaler_m2": self.m2,
}
def load_state(self, data: dict[str, np.ndarray]) -> None:
self.count = float(data["scaler_count"][0])
self.mean = np.asarray(data["scaler_mean"], dtype=np.float64)
self.m2 = np.asarray(data["scaler_m2"], dtype=np.float64)
class OnlineLogisticRegression:
"""Logistische Regression mit Adam-Optimierer und L2-Regularisierung."""
def __init__(self, n_features: int, learning_rate: float = 0.02, l2: float = 1e-4) -> None:
self.n_features = n_features
self.lr = learning_rate
self.l2 = l2
self.w = np.zeros(n_features, dtype=np.float64)
self.b = 0.0
self._mw = np.zeros(n_features, dtype=np.float64)
self._vw = np.zeros(n_features, dtype=np.float64)
self._mb = 0.0
self._vb = 0.0
self._t = 0
self._beta1, self._beta2, self._eps = 0.9, 0.999, 1e-8
def decision(self, x: np.ndarray) -> np.ndarray:
return np.asarray(x, dtype=np.float64) @ self.w + self.b
def predict_proba(self, x: np.ndarray) -> np.ndarray:
return sigmoid(self.decision(x))
def partial_fit(self, x: np.ndarray, y: np.ndarray, sample_weight: np.ndarray | None = None) -> float:
"""Ein Adam-Schritt auf einem Mini-Batch. Gibt den gewichteten Log-Loss zurück."""
x = np.atleast_2d(np.asarray(x, dtype=np.float64))
y = np.asarray(y, dtype=np.float64).reshape(-1)
if x.shape[0] != y.shape[0]:
raise ValueError("x und y haben unterschiedliche Batch-Größen")
w_s = np.ones_like(y) if sample_weight is None else np.asarray(sample_weight, dtype=np.float64)
w_sum = float(np.sum(w_s))
if w_sum <= 0:
return 0.0
p = self.predict_proba(x)
error = (p - y) * w_s
grad_w = (x.T @ error) / w_sum + self.l2 * self.w
grad_b = float(np.sum(error)) / w_sum
self._t += 1
self._mw = self._beta1 * self._mw + (1 - self._beta1) * grad_w
self._vw = self._beta2 * self._vw + (1 - self._beta2) * grad_w**2
self._mb = self._beta1 * self._mb + (1 - self._beta1) * grad_b
self._vb = self._beta2 * self._vb + (1 - self._beta2) * grad_b**2
bias1 = 1 - self._beta1**self._t
bias2 = 1 - self._beta2**self._t
self.w -= self.lr * (self._mw / bias1) / (np.sqrt(self._vw / bias2) + self._eps)
self.b -= self.lr * (self._mb / bias1) / (np.sqrt(self._vb / bias2) + self._eps)
eps = 1e-12
loss = -np.sum(w_s * (y * np.log(p + eps) + (1 - y) * np.log(1 - p + eps))) / w_sum
return float(loss)
def state(self) -> dict[str, np.ndarray]:
return {
"w": self.w,
"b": np.array([self.b]),
"mw": self._mw,
"vw": self._vw,
"mb": np.array([self._mb]),
"vb": np.array([self._vb]),
"t": np.array([self._t]),
}
def load_state(self, data: dict[str, np.ndarray]) -> None:
self.w = np.asarray(data["w"], dtype=np.float64)
self.b = float(data["b"][0])
self._mw = np.asarray(data["mw"], dtype=np.float64)
self._vw = np.asarray(data["vw"], dtype=np.float64)
self._mb = float(data["mb"][0])
self._vb = float(data["vb"][0])
self._t = int(data["t"][0])
class ReplayBuffer:
"""Ringpuffer über vergangene Beobachtungen für Mini-Batch-Wiederholung."""
def __init__(self, capacity: int, n_features: int, rng: np.random.Generator) -> None:
self.capacity = capacity
self.x = np.zeros((capacity, n_features), dtype=np.float64)
self.y = np.zeros(capacity, dtype=np.float64)
self.w = np.zeros(capacity, dtype=np.float64)
self._pos = 0
self._size = 0
self._rng = rng
def __len__(self) -> int:
return self._size
def add(self, x: np.ndarray, y: float, weight: float = 1.0) -> None:
self.x[self._pos] = np.asarray(x, dtype=np.float64).reshape(-1)
self.y[self._pos] = float(y)
self.w[self._pos] = float(weight)
self._pos = (self._pos + 1) % self.capacity
self._size = min(self._size + 1, self.capacity)
def sample(self, batch_size: int) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
n = min(batch_size, self._size)
idx = self._rng.choice(self._size, size=n, replace=False)
return self.x[idx], self.y[idx], self.w[idx]
def positive_rate(self) -> float:
if self._size == 0:
return 0.0
return float(np.mean(self.y[: self._size]))
def state(self) -> dict[str, np.ndarray]:
return {
"buf_x": self.x[: self._size],
"buf_y": self.y[: self._size],
"buf_w": self.w[: self._size],
}
def load_state(self, data: dict[str, np.ndarray]) -> None:
xs = np.asarray(data["buf_x"], dtype=np.float64)
ys = np.asarray(data["buf_y"], dtype=np.float64)
ws = np.asarray(data["buf_w"], dtype=np.float64)
keep = min(len(ys), self.capacity)
if keep:
self.x[:keep] = xs[-keep:]
self.y[:keep] = ys[-keep:]
self.w[:keep] = ws[-keep:]
self._size = keep
self._pos = keep % self.capacity
@dataclass
class LearnerStats:
samples_seen: int = 0
trade_samples: int = 0
shadow_samples: int = 0
updates: int = 0
last_loss: float = 0.0
# Prequential (online) Bewertung: erst vorhersagen, dann lernen
prequential_correct: float = 0.0
prequential_total: float = 0.0
prequential_logloss_sum: float = 0.0
positive_rate: float = 0.0
@property
def accuracy(self) -> float:
return self.prequential_correct / self.prequential_total if self.prequential_total else 0.0
@property
def logloss(self) -> float:
return self.prequential_logloss_sum / self.prequential_total if self.prequential_total else 0.0
def as_dict(self) -> dict[str, float]:
return {
"samples_seen": self.samples_seen,
"trade_samples": self.trade_samples,
"shadow_samples": self.shadow_samples,
"updates": self.updates,
"last_loss": round(self.last_loss, 5),
"online_accuracy": round(self.accuracy, 4),
"online_logloss": round(self.logloss, 5),
"positive_rate": round(self.positive_rate, 4),
}
@dataclass(slots=True)
class PendingLabel:
"""Ein Kandidatensignal, dessen Ausgang erst in der Zukunft feststeht."""
symbol: str
features: np.ndarray
entry_price: float
created_bar: int
horizon_bars: int
target_bps: float
weight: float = 1.0
def matured(self, current_bar: int) -> bool:
return current_bar - self.created_bar >= self.horizon_bars
class AdaptiveLearner:
"""Kapselt Modell, Normalisierung, Replay-Buffer und Persistenz."""
def __init__(self, config: LearnerConfig, n_features: int, seed: int | None = None) -> None:
self.config = config
self.n_features = n_features
self._rng = np.random.default_rng(seed)
self.scaler = RunningScaler(n_features)
self.model = OnlineLogisticRegression(n_features, config.learning_rate, config.l2)
self.buffer = ReplayBuffer(config.replay_size, n_features, self._rng)
self.stats = LearnerStats()
self._pending: list[PendingLabel] = []
self._since_train = 0
self._since_save = 0
self._lock = threading.Lock()
self.frozen = False
self.autosave = True
# ------------------------------------------------------------------ Scoring
@property
def ready(self) -> bool:
"""Erst ab genügend Beobachtungen darf das Modell Signale filtern."""
return self.stats.samples_seen >= self.config.warmup_samples
def score(self, features: np.ndarray) -> float:
"""Gewinnwahrscheinlichkeit für ein Einstiegssignal (0..1)."""
x = self.scaler.transform(np.asarray(features, dtype=np.float64).reshape(1, -1))
return float(self.model.predict_proba(x)[0])
def explore(self) -> bool:
"""Epsilon-greedy: gelegentlich ein abgelehntes Signal trotzdem handeln."""
rate = self.config.exploration_rate
return rate > 0.0 and bool(self._rng.random() < rate)
# ------------------------------------------------------------------ Lernen
def observe(
self, features: np.ndarray, label: float, weight: float = 1.0, *, is_trade: bool = False
) -> None:
"""Eine gelabelte Beobachtung aufnehmen und ggf. einen Trainingsschritt machen."""
with self._lock:
x_raw = np.asarray(features, dtype=np.float64).reshape(-1)
if x_raw.size != self.n_features:
log.warning(
"Feature-Länge %d != erwartet %d Beobachtung verworfen",
x_raw.size, self.n_features,
)
return
y = 1.0 if label > 0.5 else 0.0
# Prequential-Bewertung vor dem Lernen (nur wenn das Modell schon warm ist).
if self.ready:
p = float(self.model.predict_proba(self.scaler.transform(x_raw.reshape(1, -1)))[0])
self.stats.prequential_total += 1.0
self.stats.prequential_correct += 1.0 if (p >= 0.5) == (y >= 0.5) else 0.0
self.stats.prequential_logloss_sum += -(
y * np.log(p + 1e-12) + (1 - y) * np.log(1 - p + 1e-12)
)
self.scaler.update(x_raw)
self.buffer.add(self.scaler.transform(x_raw), y, weight)
self.stats.samples_seen += 1
if is_trade:
self.stats.trade_samples += 1
else:
self.stats.shadow_samples += 1
self.stats.positive_rate = self.buffer.positive_rate()
self._since_train += 1
if self.frozen or self._since_train < self.config.train_every_n_samples:
return
self._since_train = 0
self._train_step()
def _train_step(self) -> None:
if len(self.buffer) < min(self.config.batch_size, 16):
return
x, y, w = self.buffer.sample(self.config.batch_size)
w = self._balance_weights(y, w)
self.stats.last_loss = self.model.partial_fit(x, y, w)
self.stats.updates += 1
self._since_save += 1
def _balance_weights(self, y: np.ndarray, w: np.ndarray) -> np.ndarray:
"""Klassenungleichgewicht ausgleichen, damit seltene Gewinner nicht untergehen."""
pos = float(np.sum(y))
neg = float(y.size - pos)
if pos == 0 or neg == 0:
return w
scale = np.where(y > 0.5, neg / pos, 1.0)
return w * np.clip(scale, 0.2, 5.0)
# -------------------------------------------------- Verzögerte Shadow-Labels
def register_candidate(
self, symbol: str, features: np.ndarray, price: float, bar_index: int, weight: float = 1.0
) -> None:
"""Kandidatensignal vormerken; das Label folgt nach ``label_horizon_bars``."""
self._pending.append(
PendingLabel(
symbol=symbol,
features=np.asarray(features, dtype=np.float64).reshape(-1).copy(),
entry_price=float(price),
created_bar=bar_index,
horizon_bars=self.config.label_horizon_bars,
target_bps=self.config.label_target_bps,
weight=weight,
)
)
def resolve_pending(self, symbol: str, bar_index: int, high: float, low: float, close: float) -> int:
"""Fällige Shadow-Labels auswerten. Gibt die Anzahl neuer Beobachtungen zurück.
Label = 1, wenn der Kurs innerhalb des Horizonts das Ziel erreicht hat, ohne vorher
um denselben Betrag zu fallen (vereinfachte Triple-Barrier-Methode).
"""
if not self._pending:
return 0
resolved = 0
still_open: list[PendingLabel] = []
for item in self._pending:
if item.symbol != symbol:
still_open.append(item)
continue
target = item.entry_price * (1.0 + item.target_bps / 10_000.0)
stop = item.entry_price * (1.0 - item.target_bps / 10_000.0)
if high >= target:
self.observe(item.features, 1.0, item.weight)
resolved += 1
continue
if low <= stop:
self.observe(item.features, 0.0, item.weight)
resolved += 1
continue
if item.matured(bar_index):
label = 1.0 if close > item.entry_price else 0.0
self.observe(item.features, label, item.weight)
resolved += 1
continue
still_open.append(item)
self._pending = still_open
return resolved
def drop_pending(self, symbol: str | None = None) -> None:
self._pending = [p for p in self._pending if symbol is not None and p.symbol != symbol]
@property
def pending_count(self) -> int:
return len(self._pending)
def learn_from_trade(self, features: np.ndarray | None, pnl_quote: float) -> None:
"""Realisiertes Trade-Ergebnis mit erhöhtem Gewicht einspeisen."""
if features is None:
return
self.observe(features, 1.0 if pnl_quote > 0 else 0.0, self.config.trade_sample_weight, is_trade=True)
# ------------------------------------------------------------- Persistenz
def save(self, path: str | Path | None = None) -> Path:
target = Path(path or self.config.model_path)
target.parent.mkdir(parents=True, exist_ok=True)
payload: dict[str, np.ndarray] = {
"version": np.array([MODEL_FORMAT_VERSION]),
"n_features": np.array([self.n_features]),
"stats": np.array(
[
self.stats.samples_seen,
self.stats.trade_samples,
self.stats.shadow_samples,
self.stats.updates,
self.stats.last_loss,
self.stats.prequential_correct,
self.stats.prequential_total,
self.stats.prequential_logloss_sum,
],
dtype=np.float64,
),
}
payload.update(self.model.state())
payload.update(self.scaler.state())
payload.update(self.buffer.state())
# Atomar schreiben: erst in eine Temp-Datei, dann umbenennen. np.savez_compressed
# bekommt bewusst ein offenes Handle bei einem Pfad würde es ".npz" anhängen.
tmp = target.with_name(target.name + ".tmp")
with tmp.open("wb") as handle:
np.savez_compressed(handle, **payload)
tmp.replace(target)
self._since_save = 0
log.info("Modell gespeichert: %s (%d Beobachtungen)", target, self.stats.samples_seen)
return target
def maybe_save(self) -> None:
if self.autosave and self._since_save >= self.config.save_every_n_updates:
try:
self.save()
except OSError as exc: # pragma: no cover - Dateisystemfehler
log.error("Modell konnte nicht gespeichert werden: %s", exc)
def load(self, path: str | Path | None = None) -> bool:
"""Modellzustand laden. Gibt ``False`` zurück, wenn nichts (Passendes) vorhanden ist."""
target = Path(path or self.config.model_path)
if not target.is_file():
log.info("Kein gespeichertes Modell unter %s starte mit frischen Gewichten", target)
return False
try:
with np.load(target, allow_pickle=False) as data:
stored = {k: data[k] for k in data.files}
except (OSError, ValueError) as exc:
log.error("Modell %s nicht lesbar (%s) starte mit frischen Gewichten", target, exc)
return False
if int(stored.get("n_features", np.array([-1]))[0]) != self.n_features:
log.warning("Modell %s passt nicht zur Feature-Anzahl wird ignoriert", target)
return False
if int(stored.get("version", np.array([0]))[0]) != MODEL_FORMAT_VERSION:
log.warning("Modell %s hat ein altes Format wird ignoriert", target)
return False
try:
self.model.load_state(stored)
self.scaler.load_state(stored)
self.buffer.load_state(stored)
s = stored["stats"]
self.stats = LearnerStats(
samples_seen=int(s[0]),
trade_samples=int(s[1]),
shadow_samples=int(s[2]),
updates=int(s[3]),
last_loss=float(s[4]),
prequential_correct=float(s[5]),
prequential_total=float(s[6]),
prequential_logloss_sum=float(s[7]),
positive_rate=self.buffer.positive_rate(),
)
except KeyError as exc:
log.error("Modelldatei unvollständig (%s) starte mit frischen Gewichten", exc)
return False
log.info(
"Modell geladen: %s (%d Beobachtungen, Online-Accuracy %.1f%%)",
target,
self.stats.samples_seen,
self.stats.accuracy * 100.0,
)
return True
# ------------------------------------------------------------- Diagnostik
def feature_importance(self, names: tuple[str, ...]) -> dict[str, float]:
"""Gewichte des linearen Modells bei normierten Features direkt vergleichbar."""
return {n: round(float(w), 4) for n, w in zip(names, self.model.w, strict=False)}
def snapshot(self) -> dict[str, object]:
return {
"ready": self.ready,
"frozen": self.frozen,
"pending_labels": self.pending_count,
"buffer_size": len(self.buffer),
**self.stats.as_dict(),
}
@dataclass
class NullLearner:
"""Platzhalter, wenn das Lernen deaktiviert ist akzeptiert jedes Signal."""
n_features: int = 0
frozen: bool = True
stats: LearnerStats = field(default_factory=LearnerStats)
ready = True
def score(self, features: np.ndarray) -> float:
return 1.0
def explore(self) -> bool:
return False
def observe(self, *args: object, **kwargs: object) -> None:
return None
def register_candidate(self, *args: object, **kwargs: object) -> None:
return None
def resolve_pending(self, *args: object, **kwargs: object) -> int:
return 0
def drop_pending(self, *args: object, **kwargs: object) -> None:
return None
def learn_from_trade(self, *args: object, **kwargs: object) -> None:
return None
def save(self, path: str | Path | None = None) -> None:
return None
def maybe_save(self) -> None:
return None
def load(self, path: str | Path | None = None) -> bool:
return False
def feature_importance(self, names: tuple[str, ...]) -> dict[str, float]:
return {}
def snapshot(self) -> dict[str, object]:
return {"enabled": False}
@property
def pending_count(self) -> int:
return 0
+188
View File
@@ -0,0 +1,188 @@
"""Datenmodelle: Kerzen, Signale, Orders, Positionen, Trades."""
from __future__ import annotations
import time
import uuid
from dataclasses import asdict, dataclass, field
from enum import Enum
from typing import Any
import numpy as np
class Side(str, Enum):
BUY = "buy"
SELL = "sell"
class Action(str, Enum):
HOLD = "hold"
ENTER_LONG = "enter_long"
EXIT_LONG = "exit_long"
class ExitReason(str, Enum):
STOP_LOSS = "stop_loss"
TAKE_PROFIT = "take_profit"
TRAILING_STOP = "trailing_stop"
SIGNAL = "signal"
MAX_HOLDING = "max_holding"
RISK_HALT = "risk_halt"
SHUTDOWN = "shutdown"
@dataclass(slots=True)
class Candles:
"""OHLCV-Zeitreihe in Spaltenform. ``timestamp`` in Millisekunden (UTC)."""
symbol: str
timeframe: str
timestamp: np.ndarray
open: np.ndarray
high: np.ndarray
low: np.ndarray
close: np.ndarray
volume: np.ndarray
def __len__(self) -> int:
return int(self.close.size)
@classmethod
def from_rows(cls, symbol: str, timeframe: str, rows: list[list[float]]) -> Candles:
"""Erzeugt eine Serie aus ccxt-OHLCV-Zeilen ``[ts, o, h, l, c, v]``."""
if not rows:
empty = np.empty(0, dtype=np.float64)
return cls(symbol, timeframe, np.empty(0, dtype=np.int64), empty, empty, empty, empty, empty)
arr = np.asarray(rows, dtype=np.float64)
return cls(
symbol=symbol,
timeframe=timeframe,
timestamp=arr[:, 0].astype(np.int64),
open=arr[:, 1].copy(),
high=arr[:, 2].copy(),
low=arr[:, 3].copy(),
close=arr[:, 4].copy(),
volume=arr[:, 5].copy(),
)
def slice(self, start: int, stop: int) -> Candles:
return Candles(
symbol=self.symbol,
timeframe=self.timeframe,
timestamp=self.timestamp[start:stop],
open=self.open[start:stop],
high=self.high[start:stop],
low=self.low[start:stop],
close=self.close[start:stop],
volume=self.volume[start:stop],
)
def last_price(self) -> float:
return float(self.close[-1])
def last_timestamp(self) -> int:
return int(self.timestamp[-1])
@dataclass(slots=True)
class Signal:
action: Action
confidence: float = 0.0
reason: str = ""
exploratory: bool = False
features: np.ndarray | None = None
feature_names: tuple[str, ...] = ()
@classmethod
def hold(cls, reason: str = "") -> Signal:
return cls(action=Action.HOLD, reason=reason)
@dataclass(slots=True)
class Fill:
"""Ergebnis einer ausgeführten Order."""
symbol: str
side: Side
amount: float # Basiswährung, tatsächlich ausgeführt
price: float # Durchschnittlicher Ausführungspreis inkl. Slippage
fee_quote: float # Gebühr in Quote-Währung
timestamp: int # Millisekunden
order_id: str = ""
requested_amount: float = 0.0
@property
def notional(self) -> float:
return self.amount * self.price
@dataclass(slots=True)
class Position:
symbol: str
amount: float
entry_price: float
entry_timestamp: int
stop_loss: float | None = None
take_profit: float | None = None
trailing_stop: float | None = None
highest_price: float = 0.0
bars_held: int = 0
entry_fee_quote: float = 0.0
entry_features: np.ndarray | None = None
entry_confidence: float = 0.0
exploratory: bool = False
id: str = field(default_factory=lambda: uuid.uuid4().hex[:12])
def unrealized_pnl(self, price: float) -> float:
return (price - self.entry_price) * self.amount
def unrealized_pct(self, price: float) -> float:
if self.entry_price <= 0:
return 0.0
return (price - self.entry_price) / self.entry_price
def notional(self, price: float) -> float:
return self.amount * price
@dataclass(slots=True)
class Trade:
"""Ein abgeschlossener Round-Trip."""
symbol: str
amount: float
entry_price: float
exit_price: float
entry_timestamp: int
exit_timestamp: int
fees_quote: float
pnl_quote: float
pnl_pct: float
exit_reason: ExitReason
bars_held: int
entry_confidence: float = 0.0
exploratory: bool = False
mode: str = "paper"
position_id: str = ""
@property
def is_win(self) -> bool:
return self.pnl_quote > 0
def to_dict(self) -> dict[str, Any]:
data = asdict(self)
data["exit_reason"] = self.exit_reason.value
return data
@dataclass(slots=True)
class EquityPoint:
timestamp: int
equity: float
cash: float
exposure: float
@classmethod
def now(cls, equity: float, cash: float, exposure: float) -> EquityPoint:
return cls(timestamp=int(time.time() * 1000), equity=equity, cash=cash, exposure=exposure)
+100
View File
@@ -0,0 +1,100 @@
"""Optionale Benachrichtigungen über einen generischen Webhook (Slack/Discord-kompatibel)."""
from __future__ import annotations
import asyncio
import logging
import aiohttp
from .config import NotificationConfig
from .models import Trade
log = logging.getLogger(__name__)
class Notifier:
"""Verschickt kurze Statusmeldungen. Fehler werden geloggt, nie weitergereicht."""
def __init__(self, config: NotificationConfig, timeout: float = 8.0) -> None:
self.config = config
self._timeout = aiohttp.ClientTimeout(total=timeout)
self._session: aiohttp.ClientSession | None = None
self._tasks: set[asyncio.Task[None]] = set()
@property
def enabled(self) -> bool:
return bool(self.config.webhook_url)
async def start(self) -> None:
if self.enabled and self._session is None:
self._session = aiohttp.ClientSession(timeout=self._timeout)
async def close(self) -> None:
for task in list(self._tasks):
task.cancel()
if self._tasks:
await asyncio.gather(*self._tasks, return_exceptions=True)
if self._session is not None:
await self._session.close()
self._session = None
def send_soon(self, message: str) -> None:
"""Nachricht im Hintergrund verschicken, ohne den Handels-Loop zu blockieren."""
if not self.enabled:
return
task = asyncio.create_task(self._send(message))
self._tasks.add(task)
task.add_done_callback(self._tasks.discard)
async def _send(self, message: str) -> None:
if self._session is None:
await self.start()
if self._session is None or not self.config.webhook_url:
return
# "text" bedient Slack, "content" bedient Discord ein Payload für beide.
payload = {"text": message, "content": message}
try:
async with self._session.post(self.config.webhook_url, json=payload) as response:
if response.status >= 400:
body = (await response.text())[:200]
log.warning("Webhook antwortete mit HTTP %s: %s", response.status, body)
except asyncio.CancelledError:
raise
except Exception as exc: # noqa: BLE001 - Benachrichtigungen dürfen nie den Bot stoppen
log.warning("Webhook-Zustellung fehlgeschlagen: %s", exc)
# ------------------------------------------------------------- Bausteine
def trade_closed(self, trade: Trade, equity: float, quote: str) -> None:
if not self.config.notify_on_trade:
return
icon = "🟢" if trade.is_win else "🔴"
tag = " [Exploration]" if trade.exploratory else ""
self.send_soon(
f"{icon} {trade.symbol} geschlossen ({trade.exit_reason.value}){tag}\n"
f"P/L: {trade.pnl_quote:+.2f} {quote} ({trade.pnl_pct * 100:+.2f}%) | "
f"Einstieg {trade.entry_price:.6f} → Ausstieg {trade.exit_price:.6f} | "
f"Equity: {equity:.2f} {quote}"
)
def position_opened(
self, symbol: str, amount: float, price: float, confidence: float, quote: str
) -> None:
if not self.config.notify_on_trade:
return
self.send_soon(
f"📈 Position eröffnet: {symbol} {amount:.8f} @ {price:.6f} "
f"(≈{amount * price:.2f} {quote}, Modellkonfidenz {confidence:.2f})"
)
def risk_halt(self, reason: str) -> None:
if not self.config.notify_on_risk_halt:
return
self.send_soon(f"⛔ Handel gestoppt: {reason}")
def startup(self, mode: str, exchange: str, symbols: list[str], timeframe: str) -> None:
self.send_soon(
f"🤖 TradeMind gestartet Modus **{mode}**, Börse {exchange}, "
f"Symbole {', '.join(symbols)} ({timeframe})"
)
+300
View File
@@ -0,0 +1,300 @@
"""Portfolio-Buchhaltung: offene Positionen, Equity, realisierte Ergebnisse, Kennzahlen."""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from datetime import UTC, datetime
import numpy as np
from .models import ExitReason, Fill, Position, Side, Trade
log = logging.getLogger(__name__)
@dataclass
class PerformanceStats:
trades: int = 0
wins: int = 0
losses: int = 0
gross_profit: float = 0.0
gross_loss: float = 0.0
fees: float = 0.0
best_trade: float = 0.0
worst_trade: float = 0.0
@property
def win_rate(self) -> float:
return self.wins / self.trades if self.trades else 0.0
@property
def net_pnl(self) -> float:
return self.gross_profit - self.gross_loss
@property
def profit_factor(self) -> float:
if self.gross_loss <= 0:
return float("inf") if self.gross_profit > 0 else 0.0
return self.gross_profit / self.gross_loss
@property
def expectancy(self) -> float:
return self.net_pnl / self.trades if self.trades else 0.0
def register(self, trade: Trade) -> None:
self.trades += 1
self.fees += trade.fees_quote
if trade.pnl_quote > 0:
self.wins += 1
self.gross_profit += trade.pnl_quote
else:
self.losses += 1
self.gross_loss += abs(trade.pnl_quote)
self.best_trade = max(self.best_trade, trade.pnl_quote)
self.worst_trade = min(self.worst_trade, trade.pnl_quote)
def as_dict(self) -> dict[str, float]:
pf = self.profit_factor
return {
"trades": self.trades,
"wins": self.wins,
"losses": self.losses,
"win_rate": round(self.win_rate, 4),
"net_pnl": round(self.net_pnl, 4),
"gross_profit": round(self.gross_profit, 4),
"gross_loss": round(self.gross_loss, 4),
"profit_factor": round(pf, 4) if math.isfinite(pf) else None,
"expectancy": round(self.expectancy, 4),
"fees": round(self.fees, 4),
"best_trade": round(self.best_trade, 4),
"worst_trade": round(self.worst_trade, 4),
}
def _utc_day(ms: int) -> str:
return datetime.fromtimestamp(ms / 1000, tz=UTC).strftime("%Y-%m-%d")
class Portfolio:
"""Hält offene Positionen, berechnet Equity und protokolliert Trades."""
def __init__(self, starting_equity: float, quote_currency: str = "USDT") -> None:
self.quote_currency = quote_currency
self.starting_equity = starting_equity
self.positions: dict[str, Position] = {}
self.trades: list[Trade] = []
self.stats = PerformanceStats()
self.mark_prices: dict[str, float] = {}
self.peak_equity = starting_equity
self.max_drawdown = 0.0
self.equity_curve: list[tuple[int, float]] = []
self._day_key: str | None = None
self._day_start_equity = starting_equity
self.realized_today = 0.0
self.cooldowns: dict[str, int] = {}
# -------------------------------------------------------------- Bewertung
def update_mark(self, symbol: str, price: float) -> None:
if price > 0:
self.mark_prices[symbol] = float(price)
def exposure(self) -> float:
return sum(
pos.notional(self.mark_prices.get(sym, pos.entry_price)) for sym, pos in self.positions.items()
)
def equity(self, cash: float) -> float:
return cash + self.exposure()
def unrealized_pnl(self) -> float:
return sum(
pos.unrealized_pnl(self.mark_prices.get(sym, pos.entry_price))
for sym, pos in self.positions.items()
)
def record_equity(self, timestamp: int, cash: float) -> float:
equity = self.equity(cash)
self.equity_curve.append((timestamp, equity))
if equity > self.peak_equity:
self.peak_equity = equity
if self.peak_equity > 0:
drawdown = (self.peak_equity - equity) / self.peak_equity
self.max_drawdown = max(self.max_drawdown, drawdown)
self._roll_day(timestamp, equity)
return equity
def _roll_day(self, timestamp: int, equity: float) -> None:
day = _utc_day(timestamp)
if self._day_key is None:
self._day_key = day
self._day_start_equity = equity
elif day != self._day_key:
log.info(
"Neuer Handelstag %s Tagesergebnis %s: %+.2f %s",
day, self._day_key, equity - self._day_start_equity, self.quote_currency,
)
self._day_key = day
self._day_start_equity = equity
self.realized_today = 0.0
def current_drawdown(self, cash: float) -> float:
if self.peak_equity <= 0:
return 0.0
return max(0.0, (self.peak_equity - self.equity(cash)) / self.peak_equity)
def daily_pnl_pct(self, cash: float) -> float:
if self._day_start_equity <= 0:
return 0.0
return (self.equity(cash) - self._day_start_equity) / self._day_start_equity
# -------------------------------------------------------------- Positionen
def has_position(self, symbol: str) -> bool:
return symbol in self.positions
def open_position(
self,
fill: Fill,
*,
stop_loss: float | None,
take_profit: float | None,
features: np.ndarray | None,
confidence: float,
exploratory: bool,
) -> Position:
position = Position(
symbol=fill.symbol,
amount=fill.amount,
entry_price=fill.price,
entry_timestamp=fill.timestamp,
stop_loss=stop_loss,
take_profit=take_profit,
highest_price=fill.price,
entry_fee_quote=fill.fee_quote,
entry_features=None if features is None else np.asarray(features, dtype=np.float64).copy(),
entry_confidence=confidence,
exploratory=exploratory,
)
self.positions[fill.symbol] = position
self.update_mark(fill.symbol, fill.price)
return position
def close_position(self, fill: Fill, reason: ExitReason, mode: str = "paper") -> Trade:
position = self.positions.pop(fill.symbol)
fees = position.entry_fee_quote + fill.fee_quote
gross = (fill.price - position.entry_price) * fill.amount
pnl = gross - fees
cost_basis = position.entry_price * fill.amount
trade = Trade(
symbol=fill.symbol,
amount=fill.amount,
entry_price=position.entry_price,
exit_price=fill.price,
entry_timestamp=position.entry_timestamp,
exit_timestamp=fill.timestamp,
fees_quote=fees,
pnl_quote=pnl,
pnl_pct=pnl / cost_basis if cost_basis > 0 else 0.0,
exit_reason=reason,
bars_held=position.bars_held,
entry_confidence=position.entry_confidence,
exploratory=position.exploratory,
mode=mode,
position_id=position.id,
)
self.trades.append(trade)
self.stats.register(trade)
self.realized_today += pnl
self.update_mark(fill.symbol, fill.price)
return trade
def partial_reduce(self, symbol: str, amount: float) -> None:
"""Bestand nach einer Teilausführung korrigieren."""
position = self.positions.get(symbol)
if position is None:
return
position.amount = max(0.0, position.amount - amount)
def on_new_bar(self, symbol: str, high: float, low: float, close: float) -> None:
"""Haltedauer, Höchststand und Trailing-Stop fortschreiben."""
position = self.positions.get(symbol)
if position is None:
if symbol in self.cooldowns:
self.cooldowns[symbol] -= 1
if self.cooldowns[symbol] <= 0:
self.cooldowns.pop(symbol, None)
return
position.bars_held += 1
position.highest_price = max(position.highest_price, high)
self.update_mark(symbol, close)
def start_cooldown(self, symbol: str, bars: int) -> None:
if bars > 0:
self.cooldowns[symbol] = bars
def in_cooldown(self, symbol: str) -> bool:
return self.cooldowns.get(symbol, 0) > 0
# ------------------------------------------------------------- Kennzahlen
def sharpe_ratio(self, periods_per_year: float = 105_120.0) -> float:
"""Annualisierte Sharpe Ratio aus der Equity-Kurve (risikofreier Zins = 0)."""
if len(self.equity_curve) < 3:
return 0.0
equity = np.array([e for _, e in self.equity_curve], dtype=np.float64)
equity = equity[equity > 0]
if equity.size < 3:
return 0.0
returns = np.diff(np.log(equity))
sd = float(np.std(returns, ddof=1))
if sd <= 1e-12:
return 0.0
return float(np.mean(returns) / sd * math.sqrt(periods_per_year))
def summary(self, cash: float) -> dict[str, object]:
equity = self.equity(cash)
total_return = (equity - self.starting_equity) / self.starting_equity if self.starting_equity else 0.0
return {
"equity": round(equity, 4),
"cash": round(cash, 4),
"exposure": round(self.exposure(), 4),
"unrealized_pnl": round(self.unrealized_pnl(), 4),
"total_return_pct": round(total_return * 100.0, 4),
"peak_equity": round(self.peak_equity, 4),
"max_drawdown_pct": round(self.max_drawdown * 100.0, 4),
"current_drawdown_pct": round(self.current_drawdown(cash) * 100.0, 4),
"sharpe": round(self.sharpe_ratio(), 4),
"open_positions": len(self.positions),
**self.stats.as_dict(),
}
def open_positions_view(self) -> list[dict[str, object]]:
out = []
for symbol, pos in self.positions.items():
price = self.mark_prices.get(symbol, pos.entry_price)
out.append(
{
"symbol": symbol,
"amount": pos.amount,
"entry_price": pos.entry_price,
"mark_price": price,
"unrealized_pnl": round(pos.unrealized_pnl(price), 4),
"unrealized_pct": round(pos.unrealized_pct(price) * 100.0, 4),
"stop_loss": pos.stop_loss,
"take_profit": pos.take_profit,
"bars_held": pos.bars_held,
"confidence": round(pos.entry_confidence, 4),
"exploratory": pos.exploratory,
}
)
return out
def recent_trades(self, limit: int = 20) -> list[dict[str, object]]:
return [t.to_dict() for t in self.trades[-limit:]]
def side_for_exit() -> Side:
return Side.SELL
+184
View File
@@ -0,0 +1,184 @@
"""Risikomanagement: Positionsgröße, Stop-Level, Exit-Prüfung und Notbremsen."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from datetime import UTC, datetime
from .config import RiskConfig
from .models import ExitReason, Position
from .portfolio import Portfolio
log = logging.getLogger(__name__)
@dataclass(slots=True)
class RiskDecision:
allowed: bool
reason: str = ""
def __bool__(self) -> bool:
return self.allowed
ALLOWED = RiskDecision(True)
@dataclass(slots=True)
class HaltState:
"""Aktuelle Sperren. ``day`` sperrt bis zum nächsten UTC-Tag, ``permanent`` bis Neustart."""
day: str | None = None
permanent: bool = False
reason: str = ""
@property
def active(self) -> bool:
return self.permanent or self.day is not None
class RiskManager:
def __init__(self, config: RiskConfig) -> None:
self.config = config
self.halt = HaltState()
# ----------------------------------------------------------- Notbremsen
def evaluate_halt(self, portfolio: Portfolio, cash: float, timestamp: int) -> str | None:
"""Prüft Tagesverlust und Gesamt-Drawdown. Gibt den Grund zurück, wenn neu gesperrt wird."""
today = datetime.fromtimestamp(timestamp / 1000, tz=UTC).strftime("%Y-%m-%d")
if self.halt.day is not None and self.halt.day != today:
log.info("Tagesverlust-Sperre aufgehoben (neuer Handelstag %s)", today)
self.halt.day = None
self.halt.reason = ""
if self.halt.permanent:
return None
drawdown = portfolio.current_drawdown(cash)
if self.config.max_drawdown_pct > 0 and drawdown >= self.config.max_drawdown_pct:
self.halt.permanent = True
self.halt.reason = (
f"Maximaler Drawdown erreicht: {drawdown * 100:.2f}% "
f">= {self.config.max_drawdown_pct * 100:.2f}%"
)
log.error("NOTBREMSE: %s", self.halt.reason)
return self.halt.reason
if self.halt.day is None and self.config.max_daily_loss_pct > 0:
daily = portfolio.daily_pnl_pct(cash)
if daily <= -self.config.max_daily_loss_pct:
self.halt.day = today
self.halt.reason = (
f"Tagesverlustgrenze erreicht: {daily * 100:.2f}% "
f"<= -{self.config.max_daily_loss_pct * 100:.2f}%"
)
log.warning("Handel für %s gestoppt: %s", today, self.halt.reason)
return self.halt.reason
return None
@property
def trading_halted(self) -> bool:
return self.halt.active
def force_liquidation(self) -> bool:
"""Bei permanentem Halt (Drawdown) werden offene Positionen geschlossen."""
return self.halt.permanent
# ------------------------------------------------------------- Einstieg
def can_open(self, symbol: str, portfolio: Portfolio, cash: float, price: float) -> RiskDecision:
if self.halt.active:
return RiskDecision(False, f"Handel gesperrt: {self.halt.reason}")
if portfolio.has_position(symbol):
return RiskDecision(False, "Position bereits offen")
if portfolio.in_cooldown(symbol):
return RiskDecision(False, f"Cooldown aktiv ({portfolio.cooldowns.get(symbol)} Bars)")
if len(portfolio.positions) >= self.config.max_open_positions:
return RiskDecision(False, f"Maximal {self.config.max_open_positions} Positionen offen")
equity = portfolio.equity(cash)
if equity <= 0:
return RiskDecision(False, "Kein Kapital vorhanden")
exposure_after = portfolio.exposure() + self.target_notional(portfolio, cash)
if exposure_after > equity * self.config.max_total_exposure_pct + 1e-9:
return RiskDecision(False, "Gesamt-Exposure-Grenze erreicht")
if cash < self.config.min_notional:
return RiskDecision(False, f"Guthaben unter Mindestordervolumen ({self.config.min_notional})")
if price <= 0:
return RiskDecision(False, "Ungültiger Preis")
return ALLOWED
def target_notional(self, portfolio: Portfolio, cash: float) -> float:
"""Gewünschtes Ordervolumen in Quote-Währung."""
equity = portfolio.equity(cash)
by_position = equity * self.config.max_position_pct
exposure_left = max(0.0, equity * self.config.max_total_exposure_pct - portfolio.exposure())
return max(0.0, min(by_position, exposure_left, cash))
def position_size(
self, portfolio: Portfolio, cash: float, price: float, min_amount: float = 0.0, min_cost: float = 0.0
) -> tuple[float, str]:
"""Ordermenge in Basiswährung. Gibt ``(0.0, Grund)`` zurück, wenn zu klein."""
notional = self.target_notional(portfolio, cash)
floor = max(self.config.min_notional, min_cost)
if notional < floor:
return 0.0, f"Ordervolumen {notional:.2f} unter Minimum {floor:.2f}"
amount = notional / price
if min_amount and amount < min_amount:
return 0.0, f"Menge {amount:.8f} unter Börsen-Minimum {min_amount:.8f}"
return amount, ""
def stop_levels(self, entry_price: float, atr: float) -> tuple[float | None, float | None]:
stop = (
entry_price - self.config.stop_loss_atr_mult * atr
if self.config.stop_loss_atr_mult > 0
else None
)
target = (
entry_price + self.config.take_profit_atr_mult * atr
if self.config.take_profit_atr_mult > 0
else None
)
if stop is not None and stop <= 0:
stop = entry_price * 0.5
return stop, target
# --------------------------------------------------------------- Ausstieg
def update_trailing(self, position: Position, atr: float) -> None:
if self.config.trailing_stop_atr_mult <= 0:
return
candidate = position.highest_price - self.config.trailing_stop_atr_mult * atr
if position.trailing_stop is None or candidate > position.trailing_stop:
position.trailing_stop = candidate
def check_exit(
self, position: Position, high: float, low: float, close: float
) -> tuple[ExitReason | None, float]:
"""Prüft die Stop-/Ziel-Level gegen die Kerze.
Gibt (Grund, Ausführungspreis) zurück. Bei gleichzeitigem Treffer von Stop und Ziel
wird konservativ der Stop angenommen.
"""
if position.stop_loss is not None and low <= position.stop_loss:
return ExitReason.STOP_LOSS, min(position.stop_loss, high)
if position.trailing_stop is not None and low <= position.trailing_stop:
return ExitReason.TRAILING_STOP, min(position.trailing_stop, high)
if position.take_profit is not None and high >= position.take_profit:
return ExitReason.TAKE_PROFIT, max(position.take_profit, low)
if self.config.max_holding_bars and position.bars_held >= self.config.max_holding_bars:
return ExitReason.MAX_HOLDING, close
return None, close
def snapshot(self) -> dict[str, object]:
return {
"halted": self.halt.active,
"halt_permanent": self.halt.permanent,
"halt_day": self.halt.day,
"halt_reason": self.halt.reason,
"max_open_positions": self.config.max_open_positions,
"max_position_pct": self.config.max_position_pct,
"max_total_exposure_pct": self.config.max_total_exposure_pct,
}
+180
View File
@@ -0,0 +1,180 @@
"""HTTP-Schnittstelle: Health-Checks, Status-JSON, Prometheus-Metriken und Mini-Dashboard."""
from __future__ import annotations
import logging
from collections.abc import Callable
from typing import Any
from aiohttp import web
from .config import ServerConfig
log = logging.getLogger(__name__)
StatusProvider = Callable[[], dict[str, Any]]
_DASHBOARD = """<!doctype html>
<meta charset="utf-8">
<title>TradeMind</title>
<style>
:root { color-scheme: light dark; --fg:#111; --bg:#fafafa; --card:#fff; --line:#e3e3e3; --muted:#666; }
@media (prefers-color-scheme: dark) {
:root { --fg:#e8e8e8; --bg:#131417; --card:#1c1e22; --line:#2c2f36; --muted:#9aa0a6; }
}
body { font: 14px/1.5 system-ui, sans-serif; margin: 0; padding: 24px; background: var(--bg); color: var(--fg); }
h1 { font-size: 20px; margin: 0 0 4px; }
.sub { color: var(--muted); margin-bottom: 20px; }
.grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); }
.card { background: var(--card); border: 1px solid var(--line); border-radius: 10px; padding: 12px 14px; }
.k { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .04em; }
.v { font-size: 20px; font-variant-numeric: tabular-nums; margin-top: 2px; }
.pos { color: #1a8f3c; } .neg { color: #c62828; }
table { border-collapse: collapse; width: 100%; margin-top: 10px; font-variant-numeric: tabular-nums; }
th, td { text-align: right; padding: 6px 8px; border-bottom: 1px solid var(--line); }
th:first-child, td:first-child { text-align: left; }
section { margin-top: 28px; }
.overflow { overflow-x: auto; }
</style>
<h1>TradeMind</h1>
<div class="sub" id="sub">lädt …</div>
<div class="grid" id="cards"></div>
<section><h2 style="font-size:15px">Offene Positionen</h2><div class="overflow"><table id="pos"></table></div></section>
<section><h2 style="font-size:15px">Letzte Trades</h2><div class="overflow"><table id="trades"></table></div></section>
<script>
const num = (v, d = 2) => (v === null || v === undefined ? "" : Number(v).toFixed(d));
const cls = v => (v > 0 ? "pos" : v < 0 ? "neg" : "");
function card(k, v, extra = "") { return `<div class="card"><div class="k">${k}</div><div class="v ${extra}">${v}</div></div>`; }
function table(el, cols, rows) {
el.innerHTML = "<tr>" + cols.map(c => `<th>${c[0]}</th>`).join("") + "</tr>" +
(rows.length ? rows.map(r => "<tr>" + cols.map(c => `<td>${c[1](r)}</td>`).join("") + "</tr>").join("")
: `<tr><td colspan="${cols.length}" style="text-align:center;color:var(--muted)">keine</td></tr>`);
}
async function refresh() {
try {
const s = await (await fetch("status")).json();
const p = s.portfolio || {}, l = (s.strategy || {}).learner || {};
document.getElementById("sub").textContent =
`Modus ${s.mode} · ${s.exchange} · ${(s.symbols || []).join(", ")} · ${s.timeframe} · Uptime ${num(s.uptime_seconds / 60, 1)} min`;
document.getElementById("cards").innerHTML = [
card("Equity", num(p.equity) + " " + (s.quote_currency || "")),
card("Rendite", num(p.total_return_pct) + " %", cls(p.total_return_pct)),
card("Offene Positionen", p.open_positions ?? 0),
card("Trades", p.trades ?? 0),
card("Trefferquote", num((p.win_rate || 0) * 100, 1) + " %"),
card("Profit-Faktor", num(p.profit_factor)),
card("Max. Drawdown", num(p.max_drawdown_pct) + " %", "neg"),
card("Modell-Beobachtungen", l.samples_seen ?? ""),
card("Modell-Accuracy", l.online_accuracy != null ? num(l.online_accuracy * 100, 1) + " %" : ""),
card("Risiko", (s.risk || {}).halted ? "GESPERRT" : "aktiv", (s.risk || {}).halted ? "neg" : "pos"),
].join("");
table(document.getElementById("pos"),
[["Symbol", r => r.symbol], ["Menge", r => num(r.amount, 6)], ["Einstieg", r => num(r.entry_price, 6)],
["Kurs", r => num(r.mark_price, 6)], ["P/L", r => `<span class="${cls(r.unrealized_pnl)}">${num(r.unrealized_pnl)}</span>`],
["%", r => `<span class="${cls(r.unrealized_pct)}">${num(r.unrealized_pct)}</span>`], ["Bars", r => r.bars_held]],
s.positions || []);
table(document.getElementById("trades"),
[["Symbol", r => r.symbol], ["Grund", r => r.exit_reason],
["P/L", r => `<span class="${cls(r.pnl_quote)}">${num(r.pnl_quote)}</span>`],
["%", r => `<span class="${cls(r.pnl_pct)}">${num(r.pnl_pct * 100)}</span>`],
["Konfidenz", r => num(r.entry_confidence)], ["Bars", r => r.bars_held]],
(s.recent_trades || []).slice().reverse());
} catch (e) { document.getElementById("sub").textContent = "Status nicht erreichbar: " + e; }
}
refresh(); setInterval(refresh, 5000);
</script>
"""
def _flatten_metrics(prefix: str, node: Any, out: list[tuple[str, float]]) -> None:
if isinstance(node, bool):
out.append((prefix, 1.0 if node else 0.0))
elif isinstance(node, (int, float)) and not isinstance(node, bool):
out.append((prefix, float(node)))
elif isinstance(node, dict):
for key, value in node.items():
safe = "".join(ch if ch.isalnum() else "_" for ch in str(key)).strip("_").lower()
if safe:
_flatten_metrics(f"{prefix}_{safe}", value, out)
class StatusServer:
"""Kleiner aiohttp-Server; hält keinen eigenen Zustand, sondern fragt den Bot ab."""
def __init__(self, config: ServerConfig, status_provider: StatusProvider) -> None:
self.config = config
self._status = status_provider
self._runner: web.AppRunner | None = None
def _build_app(self) -> web.Application:
app = web.Application()
app.add_routes(
[
web.get("/", self._dashboard),
web.get("/health", self._health),
web.get("/ready", self._ready),
web.get("/status", self._status_json),
web.get("/positions", self._positions),
web.get("/trades", self._trades),
]
)
if self.config.enable_metrics:
app.router.add_get("/metrics", self._metrics)
return app
async def start(self) -> None:
app = self._build_app()
self._runner = web.AppRunner(app, access_log=None)
await self._runner.setup()
site = web.TCPSite(self._runner, self.config.host, self.config.port)
await site.start()
log.info("Status-Server läuft auf http://%s:%d", self.config.host, self.config.port)
async def close(self) -> None:
if self._runner is not None:
await self._runner.cleanup()
self._runner = None
# -------------------------------------------------------------- Handler
async def _dashboard(self, _: web.Request) -> web.Response:
return web.Response(text=_DASHBOARD, content_type="text/html")
async def _health(self, _: web.Request) -> web.Response:
return web.json_response({"status": "ok"})
async def _ready(self, _: web.Request) -> web.Response:
state = self._status()
ready = bool(state.get("running")) and not state.get("startup_error")
return web.json_response({"ready": ready}, status=200 if ready else 503)
async def _status_json(self, _: web.Request) -> web.Response:
return web.json_response(self._status(), dumps=_dumps)
async def _positions(self, _: web.Request) -> web.Response:
return web.json_response(self._status().get("positions", []), dumps=_dumps)
async def _trades(self, request: web.Request) -> web.Response:
try:
limit = min(int(request.query.get("limit", "50")), 500)
except ValueError:
limit = 50
trades = self._status().get("recent_trades", [])
return web.json_response(trades[-limit:], dumps=_dumps)
async def _metrics(self, _: web.Request) -> web.Response:
state = self._status()
samples: list[tuple[str, float]] = []
for section in ("portfolio", "strategy", "risk"):
_flatten_metrics(f"trademind_{section}", state.get(section, {}), samples)
_flatten_metrics("trademind", {"uptime_seconds": state.get("uptime_seconds", 0)}, samples)
_flatten_metrics("trademind", {"loop_iterations": state.get("iterations", 0)}, samples)
_flatten_metrics("trademind", {"errors_total": state.get("errors", 0)}, samples)
body = "\n".join(f"{name} {value:.10g}" for name, value in samples) + "\n"
return web.Response(text=body, content_type="text/plain", charset="utf-8")
def _dumps(obj: Any) -> str:
import json
return json.dumps(obj, default=str, ensure_ascii=False)
+224
View File
@@ -0,0 +1,224 @@
"""Persistenz: Trades, Equity-Kurve und Laufzeitzustand in SQLite.
Die Datei liegt im Volume ``/data``, damit ein Container-Neustart nahtlos fortsetzt.
"""
from __future__ import annotations
import json
import logging
import sqlite3
import threading
import time
from pathlib import Path
from typing import Any
from .models import Trade
log = logging.getLogger(__name__)
SCHEMA = """
CREATE TABLE IF NOT EXISTS runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
started_at INTEGER NOT NULL,
mode TEXT NOT NULL,
exchange TEXT NOT NULL,
symbols TEXT NOT NULL,
timeframe TEXT NOT NULL,
strategy TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS trades (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER,
position_id TEXT,
symbol TEXT NOT NULL,
mode TEXT NOT NULL,
amount REAL NOT NULL,
entry_price REAL NOT NULL,
exit_price REAL NOT NULL,
entry_timestamp INTEGER NOT NULL,
exit_timestamp INTEGER NOT NULL,
fees_quote REAL NOT NULL,
pnl_quote REAL NOT NULL,
pnl_pct REAL NOT NULL,
exit_reason TEXT NOT NULL,
bars_held INTEGER NOT NULL,
entry_confidence REAL NOT NULL,
exploratory INTEGER NOT NULL,
FOREIGN KEY (run_id) REFERENCES runs(id)
);
CREATE INDEX IF NOT EXISTS idx_trades_symbol ON trades(symbol);
CREATE INDEX IF NOT EXISTS idx_trades_exit_ts ON trades(exit_timestamp);
CREATE TABLE IF NOT EXISTS equity (
id INTEGER PRIMARY KEY AUTOINCREMENT,
run_id INTEGER,
timestamp INTEGER NOT NULL,
equity REAL NOT NULL,
cash REAL NOT NULL,
exposure REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_equity_ts ON equity(timestamp);
CREATE TABLE IF NOT EXISTS state (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
"""
class Storage:
"""Dünner, thread-sicherer SQLite-Wrapper."""
def __init__(self, path: str | Path) -> None:
self.path = Path(path)
self.path.parent.mkdir(parents=True, exist_ok=True)
self._lock = threading.Lock()
self._conn = sqlite3.connect(str(self.path), check_same_thread=False)
self._conn.row_factory = sqlite3.Row
self._conn.execute("PRAGMA journal_mode=WAL")
self._conn.execute("PRAGMA synchronous=NORMAL")
with self._lock:
self._conn.executescript(SCHEMA)
self._conn.commit()
self.run_id: int | None = None
log.info("Datenbank bereit: %s", self.path)
# ------------------------------------------------------------------ Runs
def start_run(
self, mode: str, exchange: str, symbols: list[str], timeframe: str, strategy: str
) -> int:
with self._lock:
cur = self._conn.execute(
"INSERT INTO runs (started_at, mode, exchange, symbols, timeframe, strategy)"
" VALUES (?, ?, ?, ?, ?, ?)",
(int(time.time() * 1000), mode, exchange, ",".join(symbols), timeframe, strategy),
)
self._conn.commit()
self.run_id = int(cur.lastrowid)
return self.run_id
# ---------------------------------------------------------------- Trades
def record_trade(self, trade: Trade) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO trades (run_id, position_id, symbol, mode, amount, entry_price, exit_price,"
" entry_timestamp, exit_timestamp, fees_quote, pnl_quote, pnl_pct, exit_reason, bars_held,"
" entry_confidence, exploratory)"
" VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
self.run_id,
trade.position_id,
trade.symbol,
trade.mode,
trade.amount,
trade.entry_price,
trade.exit_price,
trade.entry_timestamp,
trade.exit_timestamp,
trade.fees_quote,
trade.pnl_quote,
trade.pnl_pct,
trade.exit_reason.value,
trade.bars_held,
trade.entry_confidence,
int(trade.exploratory),
),
)
self._conn.commit()
def record_equity(self, timestamp: int, equity: float, cash: float, exposure: float) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO equity (run_id, timestamp, equity, cash, exposure) VALUES (?, ?, ?, ?, ?)",
(self.run_id, timestamp, equity, cash, exposure),
)
self._conn.commit()
def trade_count(self) -> int:
with self._lock:
row = self._conn.execute("SELECT COUNT(*) AS n FROM trades").fetchone()
return int(row["n"])
def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT * FROM trades ORDER BY exit_timestamp DESC LIMIT ?", (limit,)
).fetchall()
return [dict(r) for r in rows]
def performance_by_symbol(self) -> list[dict[str, Any]]:
with self._lock:
rows = self._conn.execute(
"SELECT symbol, COUNT(*) AS trades, SUM(pnl_quote) AS net_pnl,"
" SUM(CASE WHEN pnl_quote > 0 THEN 1 ELSE 0 END) AS wins,"
" AVG(pnl_pct) AS avg_pnl_pct"
" FROM trades GROUP BY symbol ORDER BY net_pnl DESC"
).fetchall()
return [dict(r) for r in rows]
# ----------------------------------------------------------------- State
def save_state(self, key: str, value: Any) -> None:
with self._lock:
self._conn.execute(
"INSERT INTO state (key, value, updated_at) VALUES (?, ?, ?)"
" ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at",
(key, json.dumps(value), int(time.time() * 1000)),
)
self._conn.commit()
def load_state(self, key: str, default: Any = None) -> Any:
with self._lock:
row = self._conn.execute("SELECT value FROM state WHERE key = ?", (key,)).fetchone()
if row is None:
return default
try:
return json.loads(row["value"])
except json.JSONDecodeError:
log.warning("Zustand '%s' ist beschädigt und wird ignoriert", key)
return default
def close(self) -> None:
with self._lock:
self._conn.commit()
self._conn.close()
class NullStorage:
"""Kein-Op-Variante (z. B. für Backtests ohne Persistenz)."""
run_id = None
def start_run(self, *args: Any, **kwargs: Any) -> int:
return 0
def record_trade(self, trade: Trade) -> None:
return None
def record_equity(self, *args: Any, **kwargs: Any) -> None:
return None
def trade_count(self) -> int:
return 0
def recent_trades(self, limit: int = 50) -> list[dict[str, Any]]:
return []
def performance_by_symbol(self) -> list[dict[str, Any]]:
return []
def save_state(self, key: str, value: Any) -> None:
return None
def load_state(self, key: str, default: Any = None) -> Any:
return default
def close(self) -> None:
return None
+240
View File
@@ -0,0 +1,240 @@
"""Strategien: regelbasiertes Grundgerüst und die lernende Variante darüber."""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from .config import RuleConfig, StrategyConfig
from .features import FeatureMatrix, FeatureSnapshot
from .learner import AdaptiveLearner, NullLearner
from .models import Action, Candles, Position, Signal
log = logging.getLogger(__name__)
Learner = AdaptiveLearner | NullLearner
class Strategy(ABC):
"""Erzeugt Handelssignale aus einem Merkmals-Snapshot."""
name: str = "base"
@abstractmethod
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
...
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
close: float) -> None:
"""Hook für Lernvorgänge; die reine Regelstrategie nutzt ihn nicht."""
return None
def on_trade_closed(self, position: Position, pnl_quote: float) -> None:
return None
def snapshot(self) -> dict[str, object]:
return {"strategy": self.name}
class RuleStrategy(Strategy):
"""EMA-Crossover mit RSI- und Trendfilter; zusätzlich ein Rücksetzer-Einstieg.
Long-Einstieg bei
* EMA-Kreuzung nach oben, RSI unter der Überkauft-Schwelle, Kurs über dem Trendfilter, oder
* überverkauftem RSI, wenn der Kurs über dem Trendfilter liegt (Pullback im Aufwärtstrend).
Ausstieg bei EMA-Kreuzung nach unten oder überkauftem RSI.
"""
name = "rules"
def __init__(self, config: RuleConfig) -> None:
self.config = config
def _crossed_up(self, s: FeatureSnapshot) -> bool:
return s.ema_fast_prev <= s.ema_slow_prev and s.ema_fast > s.ema_slow
def _crossed_down(self, s: FeatureSnapshot) -> bool:
return s.ema_fast_prev >= s.ema_slow_prev and s.ema_fast < s.ema_slow
def _in_uptrend(self, s: FeatureSnapshot) -> bool:
return self.config.trend_filter_period == 0 or s.price >= s.trend_ema
def entry_candidate(self, s: FeatureSnapshot) -> Signal | None:
"""Ein potenzieller Einstieg ungefiltert durch das Modell."""
if not self._in_uptrend(s):
return None
if self._crossed_up(s) and s.rsi < self.config.rsi_overbought:
return Signal(action=Action.ENTER_LONG, reason="ema_cross_up", confidence=0.5)
if s.rsi <= self.config.rsi_oversold and s.ema_fast > s.ema_slow:
return Signal(action=Action.ENTER_LONG, reason="pullback_oversold", confidence=0.5)
return None
def exit_signal(self, s: FeatureSnapshot) -> Signal | None:
if self._crossed_down(s):
return Signal(action=Action.EXIT_LONG, reason="ema_cross_down", confidence=0.5)
# Nicht "RSI ist hoch" (das ist in einem Aufwärtstrend der Normalfall und würde
# jeden Einstieg sofort wieder schließen), sondern "RSI dreht aus dem überkauften
# Bereich nach unten" also nachlassendes Momentum.
if s.rsi_prev >= self.config.rsi_overbought > s.rsi:
return Signal(action=Action.EXIT_LONG, reason="rsi_momentum_fade", confidence=0.5)
return None
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
if position is not None:
if position.bars_held < self.config.min_holding_bars:
return Signal.hold("min_holding_bars")
return self.exit_signal(snapshot) or Signal.hold("position_held")
candidate = self.entry_candidate(snapshot)
if candidate is None:
return Signal.hold("no_setup")
candidate.features = snapshot.values
candidate.feature_names = snapshot.names
return candidate
class AdaptiveStrategy(Strategy):
"""Regelwerk als Signalgeber, lernendes Modell als Torwächter.
Jeder Kandidat des Regelwerks wird bewertet. Nur Signale mit ausreichender
Gewinnwahrscheinlichkeit werden gehandelt mit einer kleinen Explorationsquote,
damit das Modell auch über abgelehnte Setups etwas lernt.
Unabhängig von der Entscheidung wird jeder Kandidat zum verzögerten Labeln vorgemerkt
(Off-Policy-Lernen): Der Bot lernt also auch aus Trades, die er *nicht* gemacht hat.
"""
name = "adaptive"
def __init__(self, config: StrategyConfig, learner: Learner) -> None:
self.config = config
self.rules = RuleStrategy(config.rules)
self.learner = learner
self.candidates_seen = 0
self.candidates_accepted = 0
self.candidates_explored = 0
self.background_samples = 0
self._bar_index: dict[str, int] = {}
def evaluate(self, symbol: str, snapshot: FeatureSnapshot, position: Position | None) -> Signal:
if position is not None:
return self.rules.evaluate(symbol, snapshot, position)
candidate = self.rules.entry_candidate(snapshot)
if candidate is None:
return Signal.hold("no_setup")
self.candidates_seen += 1
# Jeder Kandidat wird zum verzögerten Labeln vorgemerkt unabhängig davon,
# ob er anschließend gehandelt wird (Off-Policy-Lernen).
self.register_candidate(symbol, snapshot, self._bar_index.get(symbol, 0))
probability = self.learner.score(snapshot.values)
candidate.features = snapshot.values
candidate.feature_names = snapshot.names
candidate.confidence = probability
threshold = self.config.learner.entry_threshold
if not self.learner.ready:
# Aufwärmphase: Regelwerk entscheidet, das Modell sammelt Daten.
self.candidates_accepted += 1
candidate.reason = f"{candidate.reason}+warmup(p={probability:.2f})"
return candidate
if probability >= threshold:
self.candidates_accepted += 1
candidate.reason = f"{candidate.reason}+model(p={probability:.2f}>={threshold:.2f})"
return candidate
if self.learner.explore():
self.candidates_explored += 1
candidate.exploratory = True
candidate.reason = f"{candidate.reason}+explore(p={probability:.2f})"
return candidate
return Signal(
action=Action.HOLD,
confidence=probability,
reason=f"vom Modell abgelehnt (p={probability:.2f} < {threshold:.2f})",
features=snapshot.values,
feature_names=snapshot.names,
)
def register_candidate(
self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, weight: float = 1.0
) -> None:
"""Kandidaten für das verzögerte Labeln vormerken (auch abgelehnte)."""
self.learner.register_candidate(symbol, snapshot.values, snapshot.price, bar_index, weight)
def on_bar(self, symbol: str, snapshot: FeatureSnapshot, bar_index: int, high: float, low: float,
close: float) -> None:
self._bar_index[symbol] = bar_index
self.learner.resolve_pending(symbol, bar_index, high, low, close)
# Einstiegssignale sind selten regelmäßige Stichproben des Marktzustands geben
# dem Modell genug Daten, um die Aufwärmphase in vertretbarer Zeit zu durchlaufen.
every = self.config.learner.background_sample_every_n_bars
if every and bar_index % every == 0:
self.background_samples += 1
self.register_candidate(
symbol, snapshot, bar_index, weight=self.config.learner.background_sample_weight
)
def on_trade_closed(self, position: Position, pnl_quote: float) -> None:
self.learner.learn_from_trade(position.entry_features, pnl_quote)
self.learner.maybe_save()
def warmup_from_history(self, symbol: str, matrix: FeatureMatrix, candles: Candles) -> int:
"""Trainiert das Modell offline auf vorhandener Kurshistorie.
Damit ist ein frisch ausgerollter Bot nach Sekunden einsatzbereit statt nach Tagen.
Es werden ausschließlich vergangene Kerzen verwendet dieselbe Logik wie im Backtest.
Gibt den Index der letzten verarbeiteten Kerze zurück.
"""
last_index = matrix.first_valid
for index in range(matrix.first_valid, len(matrix)):
snapshot = matrix.snapshot(index)
if snapshot is None:
continue
self.on_bar(
symbol,
snapshot,
index,
float(candles.high[index]),
float(candles.low[index]),
float(candles.close[index]),
)
if self.rules.entry_candidate(snapshot) is not None:
self.register_candidate(symbol, snapshot, index)
last_index = index
return last_index
def snapshot(self) -> dict[str, object]:
acceptance = self.candidates_accepted / self.candidates_seen if self.candidates_seen else 0.0
return {
"strategy": self.name,
"candidates_seen": self.candidates_seen,
"candidates_accepted": self.candidates_accepted,
"candidates_explored": self.candidates_explored,
"background_samples": self.background_samples,
"acceptance_rate": round(acceptance, 4),
"learner": self.learner.snapshot(),
}
def build_strategy(
config: StrategyConfig, n_features: int, seed: int | None = None, *, load_model: bool = True
) -> Strategy:
"""Erzeugt die konfigurierte Strategie samt Lernmodell."""
if config.name == "rules":
return RuleStrategy(config.rules)
if not config.learner.enabled:
log.info("Lernmodul deaktiviert Signale werden ungefiltert übernommen")
return AdaptiveStrategy(config, NullLearner())
learner = AdaptiveLearner(config.learner, n_features, seed=seed)
if load_model:
learner.load()
else:
log.info("Starte mit frisch initialisiertem Modell (vorhandene Gewichte werden ignoriert)")
return AdaptiveStrategy(config, learner)