feat: add 5 dashboard features — dark mode, trade history, backtests, model insights, alerts
- Dark mode: class-based theme toggle with localStorage persistence and flash prevention - Trade History (/trades): paginated table, stats cards, equity curve chart with DB API endpoints - Backtest Viewer (/backtests): log parser for 35 backtest results, sidebar + detail + comparison tabs - Model Insights: dashboard card + dialog showing feature importance, regime distribution, training history - Alert/Signal Log (/alerts): signal stats, filterable table with execution tracking - API: 8 new endpoints with psycopg2 DB connection pool - Dark mode sweep across books page, about dialog, and all dashboard components - Architecture docs rewritten with Mermaid diagrams (23 docs) - README and FEATURES.md rewritten bilingual (Indonesian + English) - main_live.py: write model_metrics.json on startup and retrain Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f7ca8003ce
commit
d93d790428
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
Database connection pool for Trading Bot API.
|
||||
Uses psycopg2 with a simple connection pool.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from typing import Optional
|
||||
|
||||
import psycopg2
|
||||
from psycopg2 import pool
|
||||
from psycopg2.extras import RealDictCursor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_pool: Optional[pool.SimpleConnectionPool] = None
|
||||
|
||||
|
||||
def get_db_config() -> dict:
|
||||
return {
|
||||
"host": os.getenv("DB_HOST", "localhost"),
|
||||
"port": int(os.getenv("DB_PORT", "5432")),
|
||||
"dbname": os.getenv("DB_NAME", "trading_db"),
|
||||
"user": os.getenv("DB_USER", "trading_bot"),
|
||||
"password": os.getenv("DB_PASSWORD", "trading_bot_2026"),
|
||||
}
|
||||
|
||||
|
||||
def init_pool(minconn: int = 1, maxconn: int = 5):
|
||||
"""Initialize connection pool."""
|
||||
global _pool
|
||||
if _pool is not None:
|
||||
return
|
||||
try:
|
||||
config = get_db_config()
|
||||
_pool = pool.SimpleConnectionPool(minconn, maxconn, **config)
|
||||
logger.info("Database pool initialized: %s@%s:%s/%s", config["user"], config["host"], config["port"], config["dbname"])
|
||||
except Exception as e:
|
||||
logger.warning("Could not initialize DB pool: %s", e)
|
||||
_pool = None
|
||||
|
||||
|
||||
def close_pool():
|
||||
"""Close all pool connections."""
|
||||
global _pool
|
||||
if _pool:
|
||||
_pool.closeall()
|
||||
_pool = None
|
||||
logger.info("Database pool closed")
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_conn():
|
||||
"""Get a connection from the pool (context manager)."""
|
||||
if _pool is None:
|
||||
raise RuntimeError("Database pool not initialized")
|
||||
conn = _pool.getconn()
|
||||
try:
|
||||
yield conn
|
||||
finally:
|
||||
_pool.putconn(conn)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_cursor(commit: bool = False):
|
||||
"""Get a dict cursor from the pool."""
|
||||
with get_conn() as conn:
|
||||
cursor = conn.cursor(cursor_factory=RealDictCursor)
|
||||
try:
|
||||
yield cursor
|
||||
if commit:
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
def query(sql: str, params: tuple = (), one: bool = False):
|
||||
"""Execute a query and return results as list of dicts."""
|
||||
try:
|
||||
with get_cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
if one:
|
||||
return dict(rows[0]) if rows else None
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.error("DB query error: %s", e)
|
||||
return None if one else []
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Check if DB is available."""
|
||||
return _pool is not None
|
||||
+320
-7
@@ -2,19 +2,24 @@
|
||||
FastAPI Backend for Web Dashboard (Docker-compatible)
|
||||
=====================================================
|
||||
Serves trading bot status data to the web frontend.
|
||||
|
||||
Reads from data/bot_status.json which is written by main_live.py.
|
||||
This allows the API to run in Docker without needing MT5 (Windows-only).
|
||||
Reads from data/bot_status.json (written by main_live.py)
|
||||
and from PostgreSQL database for trade history, signals, model data.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
import db
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Trading Bot API", version="2.0.0")
|
||||
|
||||
# CORS for frontend
|
||||
@@ -28,6 +33,7 @@ app.add_middleware(
|
||||
|
||||
# Status file path (mounted as volume in Docker)
|
||||
STATUS_FILE = Path("/app/data/bot_status.json")
|
||||
MODEL_METRICS_FILE = Path("/app/data/model_metrics.json")
|
||||
|
||||
# Default empty response
|
||||
DEFAULT_STATUS = {
|
||||
@@ -67,10 +73,27 @@ DEFAULT_STATUS = {
|
||||
}
|
||||
|
||||
|
||||
# ─── Startup / Shutdown ───
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
try:
|
||||
db.init_pool()
|
||||
logger.info("DB pool ready")
|
||||
except Exception as e:
|
||||
logger.warning("DB not available: %s (trade history features disabled)", e)
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
db.close_pool()
|
||||
|
||||
|
||||
# ─── Status Endpoints ───
|
||||
|
||||
@app.get("/api/status")
|
||||
async def get_status():
|
||||
"""Get current trading status from bot's status file."""
|
||||
# Try local path first (non-Docker), then Docker path
|
||||
for path in [STATUS_FILE, Path("data/bot_status.json")]:
|
||||
if path.exists():
|
||||
try:
|
||||
@@ -79,7 +102,6 @@ async def get_status():
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
|
||||
# No status file — bot not running
|
||||
now = datetime.now(ZoneInfo("Asia/Jakarta"))
|
||||
result = DEFAULT_STATUS.copy()
|
||||
result["timestamp"] = now.strftime("%H:%M:%S")
|
||||
@@ -97,7 +119,298 @@ async def get_status():
|
||||
async def health():
|
||||
"""Health check endpoint."""
|
||||
bot_running = STATUS_FILE.exists() or Path("data/bot_status.json").exists()
|
||||
return {"status": "ok", "bot_running": bot_running}
|
||||
return {"status": "ok", "bot_running": bot_running, "db_available": db.is_available()}
|
||||
|
||||
|
||||
# ─── Trade History Endpoints ───
|
||||
|
||||
def _date_filter(field: str, start_date: Optional[str], end_date: Optional[str]):
|
||||
"""Build date filter SQL clauses."""
|
||||
clauses = []
|
||||
params = []
|
||||
if start_date:
|
||||
clauses.append(f"{field} >= %s")
|
||||
params.append(start_date)
|
||||
if end_date:
|
||||
clauses.append(f"{field} <= %s")
|
||||
params.append(end_date + " 23:59:59")
|
||||
return clauses, params
|
||||
|
||||
|
||||
@app.get("/api/trades")
|
||||
async def get_trades(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(25, ge=1, le=100),
|
||||
direction: str = Query("ALL"),
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
):
|
||||
"""Get paginated trade history."""
|
||||
if not db.is_available():
|
||||
return {"trades": [], "total": 0, "page": page, "limit": limit}
|
||||
|
||||
where = ["closed_at IS NOT NULL"]
|
||||
params = []
|
||||
|
||||
if direction and direction != "ALL":
|
||||
where.append("direction = %s")
|
||||
params.append(direction.upper())
|
||||
|
||||
date_clauses, date_params = _date_filter("closed_at", start_date, end_date)
|
||||
where.extend(date_clauses)
|
||||
params.extend(date_params)
|
||||
|
||||
where_sql = " AND ".join(where)
|
||||
offset = (page - 1) * limit
|
||||
|
||||
count_row = db.query(f"SELECT COUNT(*) as cnt FROM trades WHERE {where_sql}", tuple(params), one=True)
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
params_with_pagination = params + [limit, offset]
|
||||
trades = db.query(
|
||||
f"""SELECT id, ticket, direction, entry_price, exit_price, lot_size,
|
||||
profit_usd, profit_pips, sl_price, tp_price,
|
||||
opened_at, closed_at, exit_reason, confidence,
|
||||
regime, session, duration_minutes
|
||||
FROM trades
|
||||
WHERE {where_sql}
|
||||
ORDER BY closed_at DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
tuple(params_with_pagination),
|
||||
)
|
||||
|
||||
for t in trades:
|
||||
for k in ("opened_at", "closed_at"):
|
||||
if t.get(k) and hasattr(t[k], "isoformat"):
|
||||
t[k] = t[k].isoformat()
|
||||
|
||||
return {"trades": trades, "total": total, "page": page, "limit": limit}
|
||||
|
||||
|
||||
@app.get("/api/trades/stats")
|
||||
async def get_trade_stats(
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
):
|
||||
"""Get aggregate trade statistics."""
|
||||
if not db.is_available():
|
||||
return {"totalTrades": 0, "winRate": 0, "netProfit": 0, "profitFactor": 0, "avgWin": 0, "avgLoss": 0, "bestTrade": 0, "worstTrade": 0}
|
||||
|
||||
where = ["closed_at IS NOT NULL"]
|
||||
params = []
|
||||
date_clauses, date_params = _date_filter("closed_at", start_date, end_date)
|
||||
where.extend(date_clauses)
|
||||
params.extend(date_params)
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
row = db.query(
|
||||
f"""SELECT
|
||||
COUNT(*) as total_trades,
|
||||
COUNT(*) FILTER (WHERE profit_usd > 0) as wins,
|
||||
COALESCE(SUM(profit_usd), 0) as net_profit,
|
||||
COALESCE(SUM(profit_usd) FILTER (WHERE profit_usd > 0), 0) as gross_profit,
|
||||
COALESCE(ABS(SUM(profit_usd) FILTER (WHERE profit_usd < 0)), 0.01) as gross_loss,
|
||||
COALESCE(AVG(profit_usd) FILTER (WHERE profit_usd > 0), 0) as avg_win,
|
||||
COALESCE(AVG(profit_usd) FILTER (WHERE profit_usd < 0), 0) as avg_loss,
|
||||
COALESCE(MAX(profit_usd), 0) as best_trade,
|
||||
COALESCE(MIN(profit_usd), 0) as worst_trade
|
||||
FROM trades WHERE {where_sql}""",
|
||||
tuple(params),
|
||||
one=True,
|
||||
)
|
||||
|
||||
if not row or row["total_trades"] == 0:
|
||||
return {"totalTrades": 0, "winRate": 0, "netProfit": 0, "profitFactor": 0, "avgWin": 0, "avgLoss": 0, "bestTrade": 0, "worstTrade": 0}
|
||||
|
||||
return {
|
||||
"totalTrades": row["total_trades"],
|
||||
"winRate": round(row["wins"] / row["total_trades"] * 100, 1) if row["total_trades"] > 0 else 0,
|
||||
"netProfit": round(float(row["net_profit"]), 2),
|
||||
"profitFactor": round(float(row["gross_profit"]) / float(row["gross_loss"]), 2),
|
||||
"avgWin": round(float(row["avg_win"]), 2),
|
||||
"avgLoss": round(float(row["avg_loss"]), 2),
|
||||
"bestTrade": round(float(row["best_trade"]), 2),
|
||||
"worstTrade": round(float(row["worst_trade"]), 2),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/trades/equity-curve")
|
||||
async def get_equity_curve(
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
):
|
||||
"""Get cumulative equity curve from closed trades."""
|
||||
if not db.is_available():
|
||||
return {"points": []}
|
||||
|
||||
where = ["closed_at IS NOT NULL"]
|
||||
params = []
|
||||
date_clauses, date_params = _date_filter("closed_at", start_date, end_date)
|
||||
where.extend(date_clauses)
|
||||
params.extend(date_params)
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
rows = db.query(
|
||||
f"""SELECT closed_at, profit_usd,
|
||||
SUM(profit_usd) OVER (ORDER BY closed_at) as cumulative
|
||||
FROM trades WHERE {where_sql}
|
||||
ORDER BY closed_at ASC""",
|
||||
tuple(params),
|
||||
)
|
||||
|
||||
points = []
|
||||
for r in rows:
|
||||
dt = r["closed_at"].isoformat() if hasattr(r["closed_at"], "isoformat") else str(r["closed_at"])
|
||||
points.append({
|
||||
"time": dt,
|
||||
"profit": round(float(r["profit_usd"]), 2),
|
||||
"cumulative": round(float(r["cumulative"]), 2),
|
||||
})
|
||||
|
||||
return {"points": points}
|
||||
|
||||
|
||||
# ─── Model Insights Endpoints ───
|
||||
|
||||
@app.get("/api/model/metrics")
|
||||
async def get_model_metrics():
|
||||
"""Read model metrics from JSON file (written by bot on startup/retrain)."""
|
||||
for path in [MODEL_METRICS_FILE, Path("data/model_metrics.json")]:
|
||||
if path.exists():
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (json.JSONDecodeError, OSError):
|
||||
continue
|
||||
return {"featureImportance": [], "trainAuc": 0, "testAuc": 0, "sampleCount": 0, "updatedAt": None}
|
||||
|
||||
|
||||
@app.get("/api/model/training-history")
|
||||
async def get_training_history():
|
||||
"""Get model training run history."""
|
||||
if not db.is_available():
|
||||
return {"runs": []}
|
||||
|
||||
rows = db.query(
|
||||
"""SELECT id, started_at, completed_at, train_auc, test_auc,
|
||||
sample_count, features_used, trigger_reason
|
||||
FROM training_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 20"""
|
||||
)
|
||||
for r in rows:
|
||||
for k in ("started_at", "completed_at"):
|
||||
if r.get(k) and hasattr(r[k], "isoformat"):
|
||||
r[k] = r[k].isoformat()
|
||||
return {"runs": rows}
|
||||
|
||||
|
||||
@app.get("/api/model/regime-distribution")
|
||||
async def get_regime_distribution():
|
||||
"""Get regime distribution from recent market snapshots."""
|
||||
if not db.is_available():
|
||||
return {"distribution": []}
|
||||
|
||||
rows = db.query(
|
||||
"""SELECT regime, COUNT(*) as count
|
||||
FROM market_snapshots
|
||||
WHERE snapshot_time > NOW() - INTERVAL '7 days'
|
||||
GROUP BY regime
|
||||
ORDER BY count DESC"""
|
||||
)
|
||||
return {"distribution": rows}
|
||||
|
||||
|
||||
# ─── Signal / Alert Endpoints ───
|
||||
|
||||
@app.get("/api/signals")
|
||||
async def get_signals(
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
type: str = Query("ALL"),
|
||||
executed: str = Query("all"),
|
||||
start_date: Optional[str] = None,
|
||||
end_date: Optional[str] = None,
|
||||
):
|
||||
"""Get paginated signal/alert history."""
|
||||
if not db.is_available():
|
||||
return {"signals": [], "total": 0, "page": page, "limit": limit}
|
||||
|
||||
where = ["1=1"]
|
||||
params = []
|
||||
|
||||
if type and type != "ALL":
|
||||
where.append("signal_type = %s")
|
||||
params.append(type.upper())
|
||||
|
||||
if executed == "yes":
|
||||
where.append("executed = TRUE")
|
||||
elif executed == "no":
|
||||
where.append("executed = FALSE")
|
||||
|
||||
date_clauses, date_params = _date_filter("signal_time", start_date, end_date)
|
||||
where.extend(date_clauses)
|
||||
params.extend(date_params)
|
||||
|
||||
where_sql = " AND ".join(where)
|
||||
offset = (page - 1) * limit
|
||||
|
||||
count_row = db.query(f"SELECT COUNT(*) as cnt FROM signals WHERE {where_sql}", tuple(params), one=True)
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
params_with_pagination = params + [limit, offset]
|
||||
signals = db.query(
|
||||
f"""SELECT id, signal_time, signal_type, confidence, executed,
|
||||
execution_reason, regime, session, smc_signal, ml_signal,
|
||||
entry_price, sl_price, tp_price
|
||||
FROM signals
|
||||
WHERE {where_sql}
|
||||
ORDER BY signal_time DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
tuple(params_with_pagination),
|
||||
)
|
||||
|
||||
for s in signals:
|
||||
if s.get("signal_time") and hasattr(s["signal_time"], "isoformat"):
|
||||
s["signal_time"] = s["signal_time"].isoformat()
|
||||
|
||||
return {"signals": signals, "total": total, "page": page, "limit": limit}
|
||||
|
||||
|
||||
@app.get("/api/signals/stats")
|
||||
async def get_signal_stats(hours: int = Query(24, ge=1, le=168)):
|
||||
"""Get signal statistics for the last N hours."""
|
||||
if not db.is_available():
|
||||
return {"total": 0, "executed": 0, "executionRate": 0, "avgConfidence": 0, "byType": {}}
|
||||
|
||||
row = db.query(
|
||||
"""SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(*) FILTER (WHERE executed = TRUE) as executed,
|
||||
COALESCE(AVG(confidence), 0) as avg_confidence
|
||||
FROM signals
|
||||
WHERE signal_time > NOW() - MAKE_INTERVAL(hours => %s)""",
|
||||
(hours,),
|
||||
one=True,
|
||||
)
|
||||
|
||||
if not row or row["total"] == 0:
|
||||
return {"total": 0, "executed": 0, "executionRate": 0, "avgConfidence": 0, "byType": {}}
|
||||
|
||||
by_type = db.query(
|
||||
"""SELECT signal_type, COUNT(*) as count
|
||||
FROM signals
|
||||
WHERE signal_time > NOW() - MAKE_INTERVAL(hours => %s)
|
||||
GROUP BY signal_type""",
|
||||
(hours,),
|
||||
)
|
||||
|
||||
return {
|
||||
"total": row["total"],
|
||||
"executed": row["executed"],
|
||||
"executionRate": round(row["executed"] / row["total"] * 100, 1) if row["total"] > 0 else 0,
|
||||
"avgConfidence": round(float(row["avg_confidence"]), 1),
|
||||
"byType": {r["signal_type"]: r["count"] for r in by_type},
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
fastapi>=0.109.0
|
||||
uvicorn[standard]>=0.27.0
|
||||
psycopg2-binary>=2.9.0
|
||||
|
||||
Reference in New Issue
Block a user