From 98a332da792646c8c040319bfec9182d2751b8c9 Mon Sep 17 00:00:00 2001 From: Tobias Zimmermann Date: Sat, 22 Aug 2026 12:26:17 +0200 Subject: [PATCH] Add REST API with dashboard and configurable port (server.host/port, TM_PORT, --port) --- Podmanfile | 5 +- README.md | 29 ++++- config.yaml | 6 + podman-compose.yml | 12 ++ requirements.txt | 2 + tests/test_server.py | 102 +++++++++++++++++ trademind/__init__.py | 2 +- trademind/cli.py | 20 ++++ trademind/config.py | 21 ++++ trademind/server.py | 190 ++++++++++++++++++++++++++++++++ trademind/static/dashboard.html | 184 +++++++++++++++++++++++++++++++ 11 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 tests/test_server.py create mode 100644 trademind/server.py create mode 100644 trademind/static/dashboard.html diff --git a/Podmanfile b/Podmanfile index 08f38c2..e6a6f2f 100644 --- a/Podmanfile +++ b/Podmanfile @@ -16,7 +16,10 @@ USER trademind ENV PYTHONUNBUFFERED=1 \ PYTHONDONTWRITEBYTECODE=1 \ - TM_CONFIG=/app/config.yaml + TM_CONFIG=/app/config.yaml \ + TM_PORT=8080 + +EXPOSE 8080 # Default: Paper-Modus. Für Live: TM_MODE=live ENTRYPOINT ["python", "-m", "trademind"] diff --git a/README.md b/README.md index 7e8e794..5581da6 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Per [Podman](https://podman.io) deploybar. Alle API-Keys werden aus der `config. - **Live**: platziert reale Markerorders über [ccxt](https://github.com/ccxt/ccxt) (Binance, Kraken, Coinbase, KuCoin, OKX, Bybit, BitMEX …). - **Antrainieren** (`train`): evolutionäre Optimierung der Strategie-Parameter + Signalgewichte auf Simulationsdaten (Fitness = gewichteter Return + Sharpe − Max-Drawdown). - **Kernstrategie**: MACD-Cross + RSI + ATR-Stop-Loss. +- **REST-API + Dashboard** (`serve`): Web-Dashboard + API für Status, Signals, Equity, Paper-Launch – Port frei anpassbar. - **Konfiguration**: eine `config.yaml`, mehrere Exchanges in `state/weights.json` persistierbar. ## Installation (lokal / Dev) @@ -44,6 +45,7 @@ podman-compose build podman-compose up paper # Simulationslauf podman-compose run --rm train # Antrainieren podman-compose run --rm live # Live-Zyklus +TM_PORT=9000 podman-compose up serve # API/Dashboard auf Host-Port 9000 ``` ## Konfiguration (`config.yaml`) @@ -73,6 +75,10 @@ training: seed: 42 state_file: state/weights.json +server: + host: 0.0.0.0 + port: 8080 # REST-API/Dashboard (trademind serve) + exchanges: binance: api_key: ${BINANCE_API_KEY} @@ -98,9 +104,30 @@ exchanges: trademind paper # Paper-/Simulationslauf (Echte Marktkurse, Orders nur simuliert) trademind live # ein Live-Zyklus (echte Orders) trademind train # optimiert die Strategie-Parameter & -gewichte +trademind serve # STARTET REST-API + HTML-DASHBOARD (Port: server.port / --port) trademind show # zeigt die geladene Konfiguration (Keys maskiert) ``` +### REST-API + Dashboard (`serve`) +```bash +# lokal (Port frei wählbar – CLI > Env > config.yaml) +trademind serve --port 9000 --data mock +TM_PORT=9000 tm serve # Env-Override + +# Podman (Host-Port über TM_PORT anpassbar) +podman run --rm -p 9000:8080 trademind:latest serve --config /app/config.yaml +``` +| Endpunkt | Beschreibung | +|---|---| +| `GET /` | HTML-Dashboard (Status-Kacheln, Equity-Chart, Signal-Tabelle) | +| `GET /api/health` | Liveness | +| `GET /api/status` | Konfiguration, aktives Symbol, letztes Paper-Ergebnis | +| `GET /api/signals?limit=25` | zuletzt berechnete Signals (Score, Preis, Grund) | +| `GET /api/equity` | Equity-Kurve des letzten Paper-Laufs | +| `POST /api/paper` | startet einen Paper-Lauf (simuliert, keine Live-Orders) | + +Der Server ist rein read-only bzw. simuliert – er platziert **nie** Live-Orders. + ### Kursdatenquellen (paper & train) Die Standard-Modus `--data auto` nutzt **echte Marktkurse** über die öffentliche ccxt-API (Binance ist default) – keine API-Keys nötig. Bei Erreichbarkeitsproblemen @@ -131,6 +158,6 @@ automatisch geladen. ## Tests ```bash -pip install pytest +pip install pytest httpx python -m pytest ``` diff --git a/config.yaml b/config.yaml index 99a6d6d..2dbba89 100644 --- a/config.yaml +++ b/config.yaml @@ -45,6 +45,12 @@ training: seed: 42 state_file: state/weights.json +# REST-API / Dashboard (Befehl: trademind serve) +server: + host: 0.0.0.0 + port: 8080 # host-Port (im Container: TM_PORT / mapping in podman-compose) + public_base_url: "" # optional: von außen erreichbare URL (Reverse Proxy) + exchanges: binance: api_key: ${BINANCE_API_KEY} diff --git a/podman-compose.yml b/podman-compose.yml index 1f108d8..bd49fee 100644 --- a/podman-compose.yml +++ b/podman-compose.yml @@ -41,6 +41,18 @@ services: - ./state:/app/state restart: "no" + serve: + image: trademind:latest + container_name: tm-serve + command: ["serve", "--config", "/app/config.yaml"] + ports: + - "${TM_PORT:-8080}:8080" # Host-Port anpassbar, zB. TM_PORT=9000 podman-compose up serve + environment: + - TZ=Europe/Berlin + volumes: + - ./state:/app/state + restart: "no" + live: image: trademind:latest container_name: tm-live diff --git a/requirements.txt b/requirements.txt index a2a46a3..473760e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,3 +3,5 @@ pandas>=2.0 numpy>=1.26 PyYAML>=6.0 click>=8.1 +fastapi>=0.110 +uvicorn>=0.29 diff --git a/tests/test_server.py b/tests/test_server.py new file mode 100644 index 0000000..efd1124 --- /dev/null +++ b/tests/test_server.py @@ -0,0 +1,102 @@ +"""Tests für REST-API / Dashboard (trademind serve).""" +import textwrap + +import pytest + +from trademind.config import ServerConfig, load + + +CFG = textwrap.dedent( + """ + trading: + base_currency: BTC + quote_currency: USDT + candles: 120 + """ +) + + +@pytest.fixture +def cfg_file(tmp_path): + p = tmp_path / "config.yaml" + p.write_text(CFG, encoding="utf-8") + return str(p) + + +@pytest.fixture +def client(cfg_file): + from fastapi.testclient import TestClient + + from trademind.server import make_app + + app = make_app(cfg_file, data="mock", exchange="binance") + with TestClient(app) as c: + yield c + + +def test_server_config_defaults_and_env(): + assert ServerConfig().port == 8080 + assert ServerConfig().host == "0.0.0.0" + cfg = ServerConfig.from_dict({"port": "9443", "host": "127.0.0.1"}) + assert cfg.port == 9443 + assert cfg.host == "127.0.0.1" + + +def test_config_load_includes_server(cfg_file): + cfg = load(cfg_file) + assert cfg.server.port == 8080 + assert cfg.server.host == "0.0.0.0" + + +def test_health(client): + r = client.get("/api/health") + assert r.status_code == 200 + body = r.json() + assert body["status"] == "ok" + assert body["version"] + + +def test_dashboard_html(client): + r = client.get("/") + assert r.status_code == 200 + assert "text/html" in r.headers["content-type"] + assert "TradeMind" in r.text + + +def test_status(client): + r = client.get("/api/status") + assert r.status_code == 200 + body = r.json() + assert body["symbol"] == "BTC/USDT" + assert body["trading"]["candles"] == 120 + assert body["last_run"] is None + + +def test_signals_mock_data(client): + r = client.get("/api/signals?limit=10") + assert r.status_code == 200 + body = r.json() + assert body["broker"] == "mock" + sigs = body["signals"] + assert sigs, "expected at least one signal" + last = sigs[-1] + assert last["action"] in (-1, 0, 1, 2) + assert last["price"] > 0 + assert isinstance(last["score"], (int, float)) + + +def test_paper_run_and_equity(client): + r = client.post("/api/paper") + assert r.status_code == 200 + body = r.json() + assert body["summary"]["final_equity"] > 0 + assert len(body["equity_curve"]) > 1 + + eq = client.get("/api/equity") + assert eq.status_code == 200 + ebody = eq.json() + assert ebody["summary"]["final_equity"] > 0 + assert ebody["equity_curve"] + + st = client.get("/api/status") + assert st.json()["last_run"]["broker"] == "mock" diff --git a/trademind/__init__.py b/trademind/__init__.py index 13ae811..d1be02f 100644 --- a/trademind/__init__.py +++ b/trademind/__init__.py @@ -1,3 +1,3 @@ """TradeMind – Krypto-Tradingbot mit Simulations- und Live-Modus.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/trademind/cli.py b/trademind/cli.py index 692b0f4..c39128a 100644 --- a/trademind/cli.py +++ b/trademind/cli.py @@ -170,6 +170,25 @@ def train(cfg_path, generations, population, data, exchange) -> None: click.echo(f"Gewichte gespeichert in: {cfg.training.state_file}") +@cli.command("serve") +@click.option("--config", "cfg_path", default="config.yaml", show_default=True) +@click.option("--host", default=None, help="Bind-Adresse (Default: server.host aus config.yaml)") +@click.option("--port", type=int, default=None, help="Port (Default: server.port aus config.yaml)") +@click.option("--data", type=click.Choice(["auto", "live", "mock"]), default="auto", + show_default=True, help="Kursdatenquelle für die API") +@click.option("--exchange", default=None, help="Exchange für Kursdaten (zB. binance, kraken)") +def serve(cfg_path, host, port, data, exchange) -> None: + """Startet die REST-API + HTML-Dashboard (Paper-Befehle, keine Live-Orders).""" + import os + + cfg = load_config(cfg_path) + host = host or os.getenv("TM_HOST") or cfg.server.host + port = int(port or os.getenv("TM_PORT") or cfg.server.port) + from .server import run_server + + run_server(cfg_path, host=host, port=port, data=data, exchange=exchange) + + @cli.command("show") @click.argument("path", default="config.yaml") def show(path) -> None: @@ -179,6 +198,7 @@ def show(path) -> None: "trading": cfg.trading.__dict__, "strategy": cfg.strategy.__dict__, "training": cfg.training.__dict__, + "server": cfg.server.__dict__, "exchanges": { n: { "api_key": "***" if e.api_key else "", diff --git a/trademind/config.py b/trademind/config.py index bcb2e31..d545325 100644 --- a/trademind/config.py +++ b/trademind/config.py @@ -138,11 +138,30 @@ class TrainingConfig: ) +@dataclass +class ServerConfig: + """HTTP-API/Dashboard (Befehl `trademind serve`).""" + + host: str = "0.0.0.0" + port: int = 8080 + public_base_url: str = "" # optional, zB. Reverse-Proxy-URL für den Dashboard-Link + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ServerConfig": + data = data or {} + return cls( + host=data.get("host", "0.0.0.0"), + port=int(data.get("port", 8080)), + public_base_url=data.get("public_base_url", "") or "", + ) + + @dataclass class Config: trading: TradingConfig strategy: StrategyConfig training: TrainingConfig + server: ServerConfig = field(default_factory=ServerConfig) exchanges: Dict[str, ExchangeConfig] = field(default_factory=dict) def active_exchange(self) -> Optional[ExchangeConfig]: @@ -162,6 +181,7 @@ def load(path: str) -> Config: trading = TradingConfig.from_dict(raw.get("trading", {})) strategy = StrategyConfig.from_dict(raw.get("strategy", {})) training = TrainingConfig.from_dict(raw.get("training", {})) + server = ServerConfig.from_dict(raw.get("server", {})) exchanges: Dict[str, ExchangeConfig] = {} for name, data in (raw.get("exchanges") or {}).items(): @@ -176,5 +196,6 @@ def load(path: str) -> Config: trading=trading, strategy=strategy, training=training, + server=server, exchanges=exchanges, ) diff --git a/trademind/server.py b/trademind/server.py new file mode 100644 index 0000000..d43adf9 --- /dev/null +++ b/trademind/server.py @@ -0,0 +1,190 @@ +"""Optionale REST-API + HTML-Dashboard (Befehl `trademind serve`). + +Endpoints: + GET / → HTML-Dashboard + GET /api/health → Liveness + GET /api/status → Strategie-Konfiguration, zuletzt berechnetes Signal, letztes Paper-Ergebnis + GET /api/signals → Signals der letzten Candles + GET /api/equity → Equity-Kurve des letzten Paper-Laufs + POST /api/paper → neuer Paper-Lauf (nutzt konfigurierte Datenquelle) + +Der Server ist read-only gegenüber dem Live-Handel: es werden hier nie +echte Orders platziert. +""" + +from __future__ import annotations + +import asyncio +import html +import json +import logging +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Dict, Optional + +import pandas as pd + +from . import __version__ +from .config import Config, load as load_config +from .engine import save_state +from .exchange import MockBroker +from .strategy import Strategy +from .trader import Trader + +log = logging.getLogger("trademind.server") + +DASHBOARD_DIR = Path(__file__).parent / "static" + + +def _safe_int(value: Any, default: int) -> int: + try: + return int(value) + except (TypeError, ValueError): + return default + + +def _candles_to_signals(strategy: Strategy, candles: pd.DataFrame, limit: int = 50): + prep = strategy.prepare(candles) + signals = strategy.decide(prep) + out = [] + for i, sig in enumerate(signals[-limit:]): + idx = len(signals) - len(signals[-limit:]) + i + ts = None + if "time" in candles.columns and len(candles) > idx: + ts = str(pd.Timestamp(candles["time"].iloc[idx])) + out.append( + { + "time": ts, + "action": int(sig.action), + "price": round(float(sig.price), 8), + "score": round(float(sig.score), 4), + "reason": sig.reason, + } + ) + return out + + +def make_app( + cfg_path: str, + data: str = "auto", + exchange: Optional[str] = None, +) -> "FastAPI": + """Erzeugt die FastAPI-App für eine Konfigurationsdatei.""" + from fastapi import FastAPI + from fastapi.responses import HTMLResponse, JSONResponse + + def _cfg() -> Config: + return load_config(cfg_path) + + app = FastAPI(title="TradeMind", version=__version__) + state: Dict[str, Any] = {} + + def _broker(cfg: Config): + import os + + from .cli import _data_broker + + ex = exchange or (cfg.active_exchange().name if cfg.active_exchange() else "binance") + return _data_broker(cfg, data, ex, int(os.environ.get("TM_SEED", "7"))) + + def _symbol(cfg: Config) -> str: + return f"{cfg.trading.base_currency}/{cfg.trading.quote_currency}" + + @app.get("/api/health") + def health() -> Dict[str, Any]: + return {"status": "ok", "version": __version__} + + @app.get("/api/status") + def status() -> Dict[str, Any]: + cfg = _cfg() + ex_names = [ + { + "name": name, + "sandbox": entry.sandbox, + "has_keys": bool(entry.api_key and entry.api_secret), + } + for name, entry in cfg.exchanges.items() + ] + last = state.get("last_run") + return { + "version": __version__, + "symbol": _symbol(cfg), + "trading": cfg.trading.__dict__, + "strategy": cfg.strategy.__dict__, + "exchanges": ex_names, + "state_file": cfg.training.state_file, + "last_run": last, + } + + @app.get("/api/signals") + def signals(limit: int = 50) -> Dict[str, Any]: + cfg = _cfg() + broker = _broker(cfg) + candles = broker.fetch_ohlcv(_symbol(cfg), cfg.trading.timeframe, cfg.trading.candles) + strat = Strategy(cfg.strategy) + out = _candles_to_signals(strat, candles, limit) + return { + "broker": broker.name, + "generated_at": datetime.now(timezone.utc).isoformat(), + "signals": out, + } + + @app.get("/api/equity") + def equity() -> Dict[str, Any]: + last = state.get("last_run") + if last is None: + return {"summary": None, "equity_curve": [], "hint": "POST /api/paper ausführen"} + return { + "summary": last["summary"], + "equity_curve": last.get("equity_curve", []), + } + + @app.post("/api/paper") + async def paper() -> Dict[str, Any]: + def _run() -> Dict[str, Any]: + cfg = _cfg() + broker = _broker(cfg) + strat = Strategy(cfg.strategy) + trader = Trader(cfg, broker, strat) + res = trader.simulate() + summary = res.summary() + payload = { + "summary": summary, + "equity_curve": res.equity_curve, + "broker": broker.name, + "finished_at": datetime.now(timezone.utc).isoformat(), + } + save_state("state/paper.json", res, strat.parameters()) + return payload + + payload = await asyncio.to_thread(_run) + state["last_run"] = payload + return payload + + @app.get("/", response_class=HTMLResponse) + async def index() -> str: + path = DASHBOARD_DIR / "dashboard.html" + return path.read_text(encoding="utf-8") + + # Fehler-Fallback, damit auch unklare Requests JSON liefern + @app.exception_handler(Exception) + async def _exc(request, exc: Exception): # pragma: no cover + log.exception("Fehler bei %s", request.url.path) + return JSONResponse({"error": str(exc)}, status_code=500) + + app.state.cfg_path = cfg_path + return app + + +def run_server( + cfg_path: str, + host: str, + port: int, + data: str = "auto", + exchange: Optional[str] = None, +) -> None: + import uvicorn + + app = make_app(cfg_path, data=data, exchange=exchange) + log.info("TradeMind-API läuft auf http://%s:%d (Dashboard: /)", host, port) + uvicorn.run(app, host=host, port=port, log_level="info") diff --git a/trademind/static/dashboard.html b/trademind/static/dashboard.html new file mode 100644 index 0000000..f791a72 --- /dev/null +++ b/trademind/static/dashboard.html @@ -0,0 +1,184 @@ + + + + + +TradeMind + + + +
+

TradeMind

+ + +
+
+
+
+ + +
+
+
+ +
+

Equity curve

+ +

No data yet – run a paper simulation.

+
+ +
+

Last signals

+ + + +
Time (UTC)ActionPriceScoreReason
+
+
+ + + + +