Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a332da79 |
+4
-1
@@ -16,7 +16,10 @@ USER trademind
|
|||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1 \
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
PYTHONDONTWRITEBYTECODE=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
|
# Default: Paper-Modus. Für Live: TM_MODE=live
|
||||||
ENTRYPOINT ["python", "-m", "trademind"]
|
ENTRYPOINT ["python", "-m", "trademind"]
|
||||||
|
|||||||
@@ -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 …).
|
- **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).
|
- **Antrainieren** (`train`): evolutionäre Optimierung der Strategie-Parameter + Signalgewichte auf Simulationsdaten (Fitness = gewichteter Return + Sharpe − Max-Drawdown).
|
||||||
- **Kernstrategie**: MACD-Cross + RSI + ATR-Stop-Loss.
|
- **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.
|
- **Konfiguration**: eine `config.yaml`, mehrere Exchanges in `state/weights.json` persistierbar.
|
||||||
|
|
||||||
## Installation (lokal / Dev)
|
## Installation (lokal / Dev)
|
||||||
@@ -44,6 +45,7 @@ podman-compose build
|
|||||||
podman-compose up paper # Simulationslauf
|
podman-compose up paper # Simulationslauf
|
||||||
podman-compose run --rm train # Antrainieren
|
podman-compose run --rm train # Antrainieren
|
||||||
podman-compose run --rm live # Live-Zyklus
|
podman-compose run --rm live # Live-Zyklus
|
||||||
|
TM_PORT=9000 podman-compose up serve # API/Dashboard auf Host-Port 9000
|
||||||
```
|
```
|
||||||
|
|
||||||
## Konfiguration (`config.yaml`)
|
## Konfiguration (`config.yaml`)
|
||||||
@@ -73,6 +75,10 @@ training:
|
|||||||
seed: 42
|
seed: 42
|
||||||
state_file: state/weights.json
|
state_file: state/weights.json
|
||||||
|
|
||||||
|
server:
|
||||||
|
host: 0.0.0.0
|
||||||
|
port: 8080 # REST-API/Dashboard (trademind serve)
|
||||||
|
|
||||||
exchanges:
|
exchanges:
|
||||||
binance:
|
binance:
|
||||||
api_key: ${BINANCE_API_KEY}
|
api_key: ${BINANCE_API_KEY}
|
||||||
@@ -98,9 +104,30 @@ exchanges:
|
|||||||
trademind paper # Paper-/Simulationslauf (Echte Marktkurse, Orders nur simuliert)
|
trademind paper # Paper-/Simulationslauf (Echte Marktkurse, Orders nur simuliert)
|
||||||
trademind live # ein Live-Zyklus (echte Orders)
|
trademind live # ein Live-Zyklus (echte Orders)
|
||||||
trademind train # optimiert die Strategie-Parameter & -gewichte
|
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)
|
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)
|
### Kursdatenquellen (paper & train)
|
||||||
Die Standard-Modus `--data auto` nutzt **echte Marktkurse** über die öffentliche
|
Die Standard-Modus `--data auto` nutzt **echte Marktkurse** über die öffentliche
|
||||||
ccxt-API (Binance ist default) – keine API-Keys nötig. Bei Erreichbarkeitsproblemen
|
ccxt-API (Binance ist default) – keine API-Keys nötig. Bei Erreichbarkeitsproblemen
|
||||||
@@ -131,6 +158,6 @@ automatisch geladen.
|
|||||||
|
|
||||||
## Tests
|
## Tests
|
||||||
```bash
|
```bash
|
||||||
pip install pytest
|
pip install pytest httpx
|
||||||
python -m pytest
|
python -m pytest
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -45,6 +45,12 @@ training:
|
|||||||
seed: 42
|
seed: 42
|
||||||
state_file: state/weights.json
|
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:
|
exchanges:
|
||||||
binance:
|
binance:
|
||||||
api_key: ${BINANCE_API_KEY}
|
api_key: ${BINANCE_API_KEY}
|
||||||
|
|||||||
@@ -41,6 +41,18 @@ services:
|
|||||||
- ./state:/app/state
|
- ./state:/app/state
|
||||||
restart: "no"
|
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:
|
live:
|
||||||
image: trademind:latest
|
image: trademind:latest
|
||||||
container_name: tm-live
|
container_name: tm-live
|
||||||
|
|||||||
@@ -3,3 +3,5 @@ pandas>=2.0
|
|||||||
numpy>=1.26
|
numpy>=1.26
|
||||||
PyYAML>=6.0
|
PyYAML>=6.0
|
||||||
click>=8.1
|
click>=8.1
|
||||||
|
fastapi>=0.110
|
||||||
|
uvicorn>=0.29
|
||||||
|
|||||||
@@ -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,3 +1,3 @@
|
|||||||
"""TradeMind – Krypto-Tradingbot mit Simulations- und Live-Modus."""
|
"""TradeMind – Krypto-Tradingbot mit Simulations- und Live-Modus."""
|
||||||
|
|
||||||
__version__ = "0.1.0"
|
__version__ = "0.2.0"
|
||||||
|
|||||||
@@ -170,6 +170,25 @@ def train(cfg_path, generations, population, data, exchange) -> None:
|
|||||||
click.echo(f"Gewichte gespeichert in: {cfg.training.state_file}")
|
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")
|
@cli.command("show")
|
||||||
@click.argument("path", default="config.yaml")
|
@click.argument("path", default="config.yaml")
|
||||||
def show(path) -> None:
|
def show(path) -> None:
|
||||||
@@ -179,6 +198,7 @@ def show(path) -> None:
|
|||||||
"trading": cfg.trading.__dict__,
|
"trading": cfg.trading.__dict__,
|
||||||
"strategy": cfg.strategy.__dict__,
|
"strategy": cfg.strategy.__dict__,
|
||||||
"training": cfg.training.__dict__,
|
"training": cfg.training.__dict__,
|
||||||
|
"server": cfg.server.__dict__,
|
||||||
"exchanges": {
|
"exchanges": {
|
||||||
n: {
|
n: {
|
||||||
"api_key": "***" if e.api_key else "",
|
"api_key": "***" if e.api_key else "",
|
||||||
|
|||||||
@@ -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
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
trading: TradingConfig
|
trading: TradingConfig
|
||||||
strategy: StrategyConfig
|
strategy: StrategyConfig
|
||||||
training: TrainingConfig
|
training: TrainingConfig
|
||||||
|
server: ServerConfig = field(default_factory=ServerConfig)
|
||||||
exchanges: Dict[str, ExchangeConfig] = field(default_factory=dict)
|
exchanges: Dict[str, ExchangeConfig] = field(default_factory=dict)
|
||||||
|
|
||||||
def active_exchange(self) -> Optional[ExchangeConfig]:
|
def active_exchange(self) -> Optional[ExchangeConfig]:
|
||||||
@@ -162,6 +181,7 @@ def load(path: str) -> Config:
|
|||||||
trading = TradingConfig.from_dict(raw.get("trading", {}))
|
trading = TradingConfig.from_dict(raw.get("trading", {}))
|
||||||
strategy = StrategyConfig.from_dict(raw.get("strategy", {}))
|
strategy = StrategyConfig.from_dict(raw.get("strategy", {}))
|
||||||
training = TrainingConfig.from_dict(raw.get("training", {}))
|
training = TrainingConfig.from_dict(raw.get("training", {}))
|
||||||
|
server = ServerConfig.from_dict(raw.get("server", {}))
|
||||||
|
|
||||||
exchanges: Dict[str, ExchangeConfig] = {}
|
exchanges: Dict[str, ExchangeConfig] = {}
|
||||||
for name, data in (raw.get("exchanges") or {}).items():
|
for name, data in (raw.get("exchanges") or {}).items():
|
||||||
@@ -176,5 +196,6 @@ def load(path: str) -> Config:
|
|||||||
trading=trading,
|
trading=trading,
|
||||||
strategy=strategy,
|
strategy=strategy,
|
||||||
training=training,
|
training=training,
|
||||||
|
server=server,
|
||||||
exchanges=exchanges,
|
exchanges=exchanges,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user