Add REST API with dashboard and configurable port (server.host/port, TM_PORT, --port)

This commit is contained in:
Tobias Zimmermann
2026-08-22 12:26:17 +02:00
parent 7959dd71ff
commit 98a332da79
11 changed files with 570 additions and 3 deletions
+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>