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
+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")