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:
buckybonez
2026-02-09 05:46:54 +07:00
co-authored by Claude Opus 4.6
parent f7ca8003ce
commit d93d790428
230 changed files with 69573 additions and 5673 deletions
+97
View File
@@ -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
View File
@@ -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
View File
@@ -1,2 +1,3 @@
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
psycopg2-binary>=2.9.0