Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 98a332da79 |
+4
-1
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -3,3 +3,5 @@ pandas>=2.0
|
||||
numpy>=1.26
|
||||
PyYAML>=6.0
|
||||
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."""
|
||||
|
||||
__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}")
|
||||
|
||||
|
||||
@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 "",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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