1 Commits

Author SHA1 Message Date
Tobias Zimmermann 98a332da79 Add REST API with dashboard and configurable port (server.host/port, TM_PORT, --port) 2026-08-22 12:26:17 +02:00
11 changed files with 570 additions and 3 deletions
+4 -1
View File
@@ -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"]
+28 -1
View File
@@ -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
```
+6
View File
@@ -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}
+12
View File
@@ -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
+2
View File
@@ -3,3 +3,5 @@ pandas>=2.0
numpy>=1.26
PyYAML>=6.0
click>=8.1
fastapi>=0.110
uvicorn>=0.29
+102
View File
@@ -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"
+1 -1
View File
@@ -1,3 +1,3 @@
"""TradeMind Krypto-Tradingbot mit Simulations- und Live-Modus."""
__version__ = "0.1.0"
__version__ = "0.2.0"
+20
View File
@@ -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 "",
+21
View File
@@ -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,
)
+190
View File
@@ -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")
+184
View File
@@ -0,0 +1,184 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>TradeMind</title>
<style>
:root {
--bg: #0e1116; --panel: #161b23; --border: #232b36;
--text: #d7dee8; --muted: #8b97a7;
--green: #2ecc71; --red: #e74c3c; --blue: #4da3ff;
}
* { box-sizing: border-box; }
body { margin: 0; background: var(--bg); color: var(--text);
font: 15px/1.5 system-ui, "Segoe UI", Roboto, sans-serif; }
header { display: flex; align-items: center; gap: 12px; padding: 14px 20px;
border-bottom: 1px solid var(--border); background: var(--panel); }
header h1 { font-size: 17px; margin: 0; }
header .sym { color: var(--blue); font-weight: 600; }
header .ver { color: var(--muted); font-size: 12px; }
main { padding: 20px; max-width: 1200px; margin: 0 auto; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px; }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .4px; }
.card .value { font-size: 22px; font-weight: 650; margin-top: 4px; }
.pos { color: var(--green); } .neg { color: var(--red); }
section { margin-top: 24px; }
h2 { font-size: 15px; color: var(--muted); margin: 0 0 10px; text-transform: uppercase; letter-spacing: .5px; }
table { width: 100%; border-collapse: collapse; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); font-size: 13.5px; }
th { color: var(--muted); font-weight: 500; }
tr:last-child td { border-bottom: none; }
badge, .badge { display: inline-block; padding: 2px 8px; border-radius: 6px; font-size: 12px; }
.buy { background: rgba(46,204,113,.15); color: var(--green); }
.sell { background: rgba(231,76,60,.15); color: var(--red); }
.hold { background: rgba(139,151,167,.15); color: var(--muted); }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
button { background: var(--blue); color: #08111e; border: 0; border-radius: 8px;
padding: 9px 16px; font-weight: 600; cursor: pointer; }
button:disabled { opacity: .5; cursor: default; }
canvas { width: 100%; height: 260px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; }
.hint { color: var(--muted); font-size: 13px; }
footer { color: var(--muted); font-size: 12px; text-align: center; padding: 18px; }
</style>
</head>
<body>
<header>
<h1>TradeMind <span class="sym" id="symbol"></span></h1>
<span class="ver" id="version"></span>
<span class="hint" id="updated"></span>
</header>
<main>
<section>
<div class="row">
<button id="runBtn" onclick="runPaper()">Run paper simulation</button>
<span class="hint" id="statusMsg"></span>
</div>
<div class="grid" id="cards"></div>
</section>
<section>
<h2>Equity curve</h2>
<canvas id="chart" width="1200" height="260"></canvas>
<p class="hint" id="chartHint">No data yet run a paper simulation.</p>
</section>
<section>
<h2>Last signals</h2>
<table>
<thead><tr><th>Time (UTC)</th><th>Action</th><th>Price</th><th>Score</th><th>Reason</th></tr></thead>
<tbody id="signals"></tbody>
</table>
</section>
</main>
<footer>Paper mode only this dashboard never places live orders.</footer>
<script>
"use strict";
const $ = (id) => document.getElementById(id);
function fmt(v, d = 2) {
if (v === null || v === undefined || isNaN(v)) return "";
return Number(v).toLocaleString("en-US", { minimumFractionDigits: d, maximumFractionDigits: d });
}
function badge(action) {
if (action === 1) return '<span class="badge buy">BUY</span>';
if (action === -1) return '<span class="badge sell">SELL</span>';
return '<span class="badge hold">HOLD</span>';
}
function card(label, value, cls = "") {
return `<div class="card"><div class="label">${label}</div><div class="value ${cls}">${value}</div></div>`;
}
async function jget(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(url + " -> " + r.status);
return r.json();
}
async function loadStatus() {
const s = await jget("/api/status");
$("symbol").textContent = s.symbol || "";
$("version").textContent = "v" + s.version;
const cards = [
card("Equity", fmt((s.last_run && s.last_run.summary && s.last_run.summary.final_equity) || s.trading.initial_balance)),
card("Total return", ((s.last_run?.summary?.total_return_pct)?.toFixed(2) ?? "") + " %",
(s.last_run?.summary?.total_return_pct ?? 0) >= 0 ? "pos" : "neg"),
card("Trades", s.last_run?.summary?.num_trades ?? ""),
card("Win rate", ((s.last_run?.summary?.win_rate ?? 0) * 100).toFixed(1) + " %"),
card("Sharpe", fmt(s.last_run?.summary?.sharpe ?? 0, 3)),
card("Max drawdown", ((s.last_run?.summary?.max_drawdown_pct ?? 0)).toFixed(2) + " %", "neg"),
];
$("cards").innerHTML = cards.join("");
if (s.last_run) $("chartHint").style.display = "none";
if (s.last_run) drawEquity(s.last_run.equity_curve || []);
}
async function loadSignals() {
const d = await jget("/api/signals?limit=25");
$("updated").textContent = "data: " + (d.broker || "") + " · " + new Date().toLocaleTimeString();
$("signals").innerHTML = d.signals
.map((x) => `<tr><td>${x.time || ""}</td><td>${badge(x.action)}</td><td>${fmt(x.price, 2)}</td><td>${x.score}</td><td>${x.reason}</td></tr>`)
.join("") || '<tr><td colspan="5" class="hint">no data</td></tr>';
}
async function runPaper() {
const btn = $("runBtn");
btn.disabled = true;
$("statusMsg").textContent = "running…";
try {
const r = await fetch("/api/paper", { method: "POST" });
if (!r.ok) throw new Error("HTTP " + r.status);
const d = await r.json();
$("statusMsg").innerHTML = `done: return <b class="${d.summary.total_return_pct >= 0 ? "pos" : "neg"}">${d.summary.total_return_pct.toFixed(2)} %</b> (${d.summary.num_trades} trades), data: ${d.broker}`;
$("chartHint").style.display = "none";
drawEquity(d.equity_curve || []);
loadStatus(); loadSignals();
} catch (e) {
$("statusMsg").textContent = "error: " + e.message;
} finally {
btn.disabled = false;
}
}
function drawEquity(curve) {
const c = $("chart");
const ctx = c.getContext("2d");
const W = c.width, H = c.height, P = 14;
ctx.clearRect(0, 0, W, H);
if (!curve || curve.length < 2) return;
const min = Math.min(...curve), max = Math.max(...curve);
const span = (max - min) || 1;
const x = (i) => P + (i / (curve.length - 1)) * (W - 2 * P);
const y = (v) => H - P - ((v - min) / span) * (H - 2 * P);
// grid
ctx.strokeStyle = "#232b36"; ctx.lineWidth = 1;
for (let g = 0; g <= 4; g++) {
const gy = P + (g / 4) * (H - 2 * P);
ctx.beginPath(); ctx.moveTo(P, gy); ctx.lineTo(W - P, gy); ctx.stroke();
}
// line
ctx.strokeStyle = curve[curve.length - 1] >= curve[0] ? "#2ecc71" : "#e74c3c";
ctx.lineWidth = 2; ctx.beginPath();
curve.forEach((v, i) => (i ? ctx.lineTo(x(i), y(v)) : ctx.moveTo(x(i), y(v))));
ctx.stroke();
// labels
ctx.fillStyle = "#8b97a7"; ctx.font = "12px sans-serif";
ctx.fillText(fmt(max), P, P - 3);
ctx.fillText(fmt(min, 0), P, H - 4);
}
async function loadAll() {
try {
await Promise.all([loadStatus(), loadSignals()]);
} catch (e) {
$("statusMsg").textContent = "api error: " + e.message;
}
}
loadAll();
setInterval(loadSignals, 60_000);
</script>
</body>
</html>