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:
GifariKemal
2026-02-09 05:46:54 +07:00
co-authored by Claude Opus 4.6
parent b2dc2dacd7
commit e8355b3f62
230 changed files with 69573 additions and 5673 deletions
+1 -1
View File
@@ -8,7 +8,7 @@ WORKDIR /app
# Copy package files
COPY package.json package-lock.json* ./
RUN npm ci
RUN npm ci && npm install @next/swc-linux-x64-musl --save-optional 2>/dev/null || true
# Build the source code
FROM base AS builder
+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
-4
View File
@@ -11,15 +11,11 @@
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {
"@shadcn": "https://ui.shadcn.com/r"
}
}
+2546 -5
View File
File diff suppressed because it is too large Load Diff
+8
View File
@@ -9,14 +9,22 @@
"lint": "eslint"
},
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"geist": "^1.7.0",
"lucide-react": "^0.563.0",
"mermaid": "^11.12.2",
"next": "16.1.6",
"radix-ui": "^1.4.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"recharts": "^2.15.4",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
+240
View File
@@ -0,0 +1,240 @@
/**
* Script to generate src/data/backtests.ts from backtest result log files.
* Scans backtests/*_results/ for the most recent .log file in each directory,
* parses performance metrics and trade log, and outputs static TS data.
*
* Run: node scripts/generate-backtests.js
*/
const fs = require("fs");
const path = require("path");
const BACKTESTS_DIR = path.resolve(__dirname, "..", "..", "backtests");
const OUT = path.join(__dirname, "..", "src", "data", "backtests.ts");
function escapeForTemplate(str) {
return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
function parseMetric(text, pattern) {
const m = text.match(pattern);
return m ? m[1].trim() : null;
}
function parseNumber(text, pattern) {
const val = parseMetric(text, pattern);
if (!val) return 0;
return parseFloat(val.replace(/[,$]/g, "")) || 0;
}
function parsePercent(text, pattern) {
const val = parseMetric(text, pattern);
if (!val) return 0;
return parseFloat(val.replace("%", "")) || 0;
}
function parseExitReasons(text) {
const section = text.match(/--- EXIT REASON(?:S| BREAKDOWN) ---\n([\s\S]*?)(?=\n---|\n\n\n)/);
if (!section) return [];
const lines = section[1].trim().split("\n");
return lines.map((line) => {
const m = line.match(/^\s*(\S+)\s*:\s*(\d+)\s*\(\s*([\d.]+)%\)/);
if (!m) return null;
return { reason: m[1], count: parseInt(m[2]), pct: parseFloat(m[3]) };
}).filter(Boolean);
}
function parseDirectionBreakdown(text) {
const section = text.match(/--- DIRECTION(?:\s+BREAKDOWN)? ---\n([\s\S]*?)(?=\n---|\n\n\n)/);
if (!section) return [];
const lines = section[1].trim().split("\n");
return lines.map((line) => {
const m = line.match(/^\s*(BUY|SELL):\s*(\d+)\s*trades?,\s*([\d.]+)%\s*WR,\s*\$\s*([-\d,.]+)/);
if (!m) return null;
return { direction: m[1], trades: parseInt(m[2]), winRate: parseFloat(m[3]), pnl: parseFloat(m[4].replace(/,/g, "")) };
}).filter(Boolean);
}
function parseSessionBreakdown(text) {
const section = text.match(/--- SESSION BREAKDOWN ---\n([\s\S]*?)(?=\n---|\n\n\n)/);
if (!section) return [];
const lines = section[1].trim().split("\n");
return lines.map((line) => {
const m = line.match(/^\s*(.+?)\s*:\s*(\d+)\s*trades?,\s*([\d.]+)%\s*WR,\s*\$\s*([-\d,.]+)/);
if (!m) return null;
return { session: m[1].trim(), trades: parseInt(m[2]), winRate: parseFloat(m[3]), pnl: parseFloat(m[4].replace(/,/g, "")) };
}).filter(Boolean);
}
function parseTrades(text) {
const section = text.match(/--- TRADE LOG ---\n.*\n-+\n([\s\S]*?)$/);
if (!section) return [];
const lines = section[1].trim().split("\n");
return lines.slice(0, 500).map((line) => {
// Format: # date time DIR entry exit P/L result exit_reason conf mode session
const m = line.match(
/^\s*(\d+)\s+(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2})\s+(BUY|SELL)\s+([\d.]+)\s+([\d.]+)\s+([-\d.]+)\s+(WIN|LOSS)\s+(\S+)\s+(\d+)%\s+(\S+)\s+(.+)$/
);
if (!m) return null;
return {
num: parseInt(m[1]),
time: m[2],
dir: m[3],
entry: parseFloat(m[4]),
exit: parseFloat(m[5]),
pnl: parseFloat(m[6]),
result: m[7],
exitReason: m[8],
conf: parseInt(m[9]),
mode: m[10],
session: m[11].trim(),
};
}).filter(Boolean);
}
function formatName(dirName) {
// "01_smc_only_results" -> "SMC Only"
return dirName
.replace(/_results$/, "")
.replace(/^\d+_/, "")
.split("_")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
// Find all backtest result directories
const resultDirs = fs
.readdirSync(BACKTESTS_DIR)
.filter((d) => d.endsWith("_results") && fs.statSync(path.join(BACKTESTS_DIR, d)).isDirectory())
.sort();
console.log(`Found ${resultDirs.length} backtest result directories`);
const results = [];
for (const dir of resultDirs) {
const dirPath = path.join(BACKTESTS_DIR, dir);
const logFiles = fs
.readdirSync(dirPath)
.filter((f) => f.endsWith(".log"))
.sort()
.reverse(); // most recent first
if (logFiles.length === 0) {
console.warn(` SKIP ${dir}: no .log files`);
continue;
}
const logFile = logFiles[0];
const logPath = path.join(dirPath, logFile);
const text = fs.readFileSync(logPath, "utf-8");
const idMatch = dir.match(/^(\d+)/);
const id = idMatch ? parseInt(idMatch[1]) : results.length + 1;
const result = {
id,
slug: dir.replace(/_results$/, ""),
name: formatName(dir),
logFile,
generatedAt: parseMetric(text, /Generated:\s*(.+)/),
period: parseMetric(text, /Period:\s*(.+)/),
strategy: parseMetric(text, /Strategy:\s*(.+)/),
// Support both verbose "Total Trades: 686" and compact "Trades: 683 | WR: 73.4%" formats
totalTrades: parseNumber(text, /Total Trades:\s*([\d,]+)/) || parseNumber(text, /Trades:\s*([\d,]+)/),
wins: parseNumber(text, /Wins:\s*([\d,]+)/),
losses: parseNumber(text, /Losses:\s*([\d,]+)/),
winRate: parsePercent(text, /Win Rate:\s*([\d.]+)%/) || parsePercent(text, /WR:\s*([\d.]+)%/),
totalProfit: parseNumber(text, /Total Profit:\s*\$([\d,.]+)/),
totalLoss: parseNumber(text, /Total Loss:\s*\$([\d,.]+)/),
netPnl: parseNumber(text, /Net PnL:\s*\$\s*([-\d,.]+)/),
profitFactor: parseNumber(text, /Profit Factor:\s*([\d.]+)/) || parseNumber(text, /PF:\s*([\d.]+)/),
maxDrawdown: parsePercent(text, /Max Drawdown:\s*([\d.]+)%/) || parsePercent(text, /Max DD:\s*([\d.]+)%/),
maxDrawdownUsd: parseNumber(text, /Max Drawdown:\s*[\d.]+%\s*\(\$([\d,.]+)\)/),
avgWin: parseNumber(text, /Avg Win:\s*\$([\d,.]+)/),
avgLoss: parseNumber(text, /Avg Loss:\s*\$([\d,.]+)/),
expectancy: parseNumber(text, /Expectancy:\s*\$([-\d,.]+)/),
sharpeRatio: parseNumber(text, /Sharpe Ratio:\s*([-\d.]+)/) || parseNumber(text, /Sharpe:\s*([-\d.]+)/),
exitReasons: parseExitReasons(text),
directionBreakdown: parseDirectionBreakdown(text),
sessionBreakdown: parseSessionBreakdown(text),
tradeCount: 0, // set below
};
// Parse trades (store just count for the static file - trades are big)
const trades = parseTrades(text);
result.tradeCount = trades.length;
// Fix negative Net PnL (the regex may miss the sign)
if (text.includes("Net PnL:") && text.match(/Net PnL:\s*-/)) {
result.netPnl = -Math.abs(result.netPnl);
}
results.push(result);
console.log(` OK ${dir}: ${result.totalTrades} trades, ${result.winRate}% WR, $${result.netPnl} PnL`);
}
// Sort by id
results.sort((a, b) => a.id - b.id);
// Generate output
const outDir = path.dirname(OUT);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
let output = `// AUTO-GENERATED — do not edit manually.
// Run: node scripts/generate-backtests.js
export interface ExitReason {
reason: string;
count: number;
pct: number;
}
export interface DirectionBreakdown {
direction: string;
trades: number;
winRate: number;
pnl: number;
}
export interface SessionBreakdown {
session: string;
trades: number;
winRate: number;
pnl: number;
}
export interface BacktestResult {
id: number;
slug: string;
name: string;
logFile: string;
generatedAt: string | null;
period: string | null;
strategy: string | null;
totalTrades: number;
wins: number;
losses: number;
winRate: number;
totalProfit: number;
totalLoss: number;
netPnl: number;
profitFactor: number;
maxDrawdown: number;
maxDrawdownUsd: number;
avgWin: number;
avgLoss: number;
expectancy: number;
sharpeRatio: number;
exitReasons: ExitReason[];
directionBreakdown: DirectionBreakdown[];
sessionBreakdown: SessionBreakdown[];
tradeCount: number;
}
export const backtestResults: BacktestResult[] = ${JSON.stringify(results, null, 2)};
`;
fs.writeFileSync(OUT, output, "utf-8");
console.log(`\nGenerated ${OUT} with ${results.length} backtest results.`);
+121
View File
@@ -0,0 +1,121 @@
/**
* Script to generate src/data/books.ts from documentation files.
* Run: node scripts/generate-books.js
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.resolve(__dirname, "..", "..");
const OUT = path.join(__dirname, "..", "src", "data", "books.ts");
// Define all books with their source files and metadata (Indonesian)
const bookDefs = [
// Mulai di Sini
{ slug: "readme", title: "README", category: "Mulai di Sini", icon: "BookOpen", description: "Gambaran proyek, instalasi, dan panduan cepat memulai XAUBot AI", file: path.join(ROOT, "README.md") },
{ slug: "features", title: "Fitur & Komponen", category: "Mulai di Sini", icon: "Sparkles", description: "Daftar lengkap fitur — 14 filter entry, 12 kondisi exit, manajemen risiko", file: path.join(ROOT, "docs", "FEATURES.md") },
{ slug: "architecture-full", title: "Arsitektur Lengkap", category: "Mulai di Sini", icon: "LayoutDashboard", description: "Arsitektur menyeluruh sistem — alur data, komponen, dan interaksi antar modul", file: path.join(ROOT, "docs", "arsitektur-ai", "00-ARSITEKTUR-LENGKAP.md") },
{ slug: "architecture-index", title: "Indeks Arsitektur", category: "Mulai di Sini", icon: "List", description: "Daftar semua dokumen arsitektur dan status komponen terkini", file: path.join(ROOT, "docs", "arsitektur-ai", "README.md") },
// AI & Analisis
{ slug: "hmm-regime", title: "HMM Regime Detector", category: "AI & Analisis", icon: "Brain", description: "Deteksi kondisi pasar menggunakan Hidden Markov Model 3 state", file: path.join(ROOT, "docs", "arsitektur-ai", "01-HMM-Regime-Detector.md") },
{ slug: "xgboost", title: "XGBoost Signal Predictor", category: "AI & Analisis", icon: "Cpu", description: "Model machine learning untuk prediksi sinyal BUY/SELL/HOLD", file: path.join(ROOT, "docs", "arsitektur-ai", "02-XGBoost-Signal-Predictor.md") },
{ slug: "smc", title: "SMC Analyzer", category: "AI & Analisis", icon: "TrendingUp", description: "Analisis Smart Money Concepts — Order Block, FVG, BOS, CHoCH", file: path.join(ROOT, "docs", "arsitektur-ai", "03-SMC-Analyzer.md") },
{ slug: "feature-eng", title: "Feature Engineering", category: "AI & Analisis", icon: "Layers", description: "37 fitur teknikal — RSI, ATR, MACD, Bollinger, dan lainnya", file: path.join(ROOT, "docs", "arsitektur-ai", "04-Feature-Engineering.md") },
// Risiko & Proteksi
{ slug: "risk-management", title: "Manajemen Risiko", category: "Risiko & Proteksi", icon: "Shield", description: "Sistem manajemen risiko dinamis dengan mode kapital dan batas harian", file: path.join(ROOT, "docs", "arsitektur-ai", "05-Risk-Management.md") },
{ slug: "session-filter", title: "Filter Sesi", category: "Risiko & Proteksi", icon: "Clock", description: "Filter sesi perdagangan — Sydney, London, New York dalam zona waktu WIB", file: path.join(ROOT, "docs", "arsitektur-ai", "06-Session-Filter.md") },
{ slug: "stop-loss", title: "Stop Loss", category: "Risiko & Proteksi", icon: "ShieldAlert", description: "Proteksi SL berbasis ATR dan broker-level untuk keamanan maksimal", file: path.join(ROOT, "docs", "arsitektur-ai", "07-Stop-Loss.md") },
{ slug: "take-profit", title: "Take Profit", category: "Risiko & Proteksi", icon: "Target", description: "Target TP multi-level dengan perhitungan ATR dan struktur pasar", file: path.join(ROOT, "docs", "arsitektur-ai", "08-Take-Profit.md") },
// Proses Trading
{ slug: "entry-trade", title: "Entry Trade", category: "Proses Trading", icon: "ArrowRightCircle", description: "14 filter entry dan logika eksekusi perdagangan — dari sinyal hingga order", file: path.join(ROOT, "docs", "arsitektur-ai", "09-Entry-Trade.md") },
{ slug: "exit-trade", title: "Exit Trade", category: "Proses Trading", icon: "ArrowLeftCircle", description: "12 kondisi exit termasuk trailing SL, batas waktu, dan perubahan regime", file: path.join(ROOT, "docs", "arsitektur-ai", "10-Exit-Trade.md") },
// Infrastruktur
{ slug: "news-agent", title: "News Agent", category: "Infrastruktur", icon: "Newspaper", description: "Filter berita ekonomi dan penilaian dampak — saat ini nonaktif", file: path.join(ROOT, "docs", "arsitektur-ai", "11-News-Agent.md") },
{ slug: "telegram", title: "Notifikasi Telegram", category: "Infrastruktur", icon: "Send", description: "Notifikasi trade real-time dan ringkasan harian via Telegram Bot", file: path.join(ROOT, "docs", "arsitektur-ai", "12-Telegram-Notifications.md") },
{ slug: "auto-trainer", title: "Auto Trainer", category: "Infrastruktur", icon: "RefreshCw", description: "Pipeline retraining otomatis saat kondisi pasar berubah signifikan", file: path.join(ROOT, "docs", "arsitektur-ai", "13-Auto-Trainer.md") },
{ slug: "backtest", title: "Backtest", category: "Infrastruktur", icon: "BarChart3", description: "Framework backtesting yang disinkronkan dengan logika live trading", file: path.join(ROOT, "docs", "arsitektur-ai", "14-Backtest.md") },
{ slug: "dynamic-confidence", title: "Dynamic Confidence", category: "Infrastruktur", icon: "Gauge", description: "Ambang batas confidence adaptif berdasarkan kondisi dan performa pasar", file: path.join(ROOT, "docs", "arsitektur-ai", "15-Dynamic-Confidence.md") },
{ slug: "train-models", title: "Train Models", category: "Infrastruktur", icon: "GraduationCap", description: "Pipeline pelatihan model dan optimasi hyperparameter XGBoost", file: path.join(ROOT, "docs", "arsitektur-ai", "22-Train-Models.md") },
// Konektor & Konfigurasi
{ slug: "mt5-connector", title: "Konektor MT5", category: "Konektor & Konfigurasi", icon: "Plug", description: "Lapisan koneksi MetaTrader 5 dan eksekusi order trading", file: path.join(ROOT, "docs", "arsitektur-ai", "16-MT5-Connector.md") },
{ slug: "configuration", title: "Konfigurasi", category: "Konektor & Konfigurasi", icon: "Settings", description: "Pengaturan trading, mode kapital, dan konfigurasi environment", file: path.join(ROOT, "docs", "arsitektur-ai", "17-Configuration.md") },
{ slug: "trade-logger", title: "Trade Logger", category: "Konektor & Konfigurasi", icon: "FileText", description: "Pencatatan trade ke database PostgreSQL untuk analisis historis", file: path.join(ROOT, "docs", "arsitektur-ai", "18-Trade-Logger.md") },
{ slug: "position-manager", title: "Position Manager", category: "Konektor & Konfigurasi", icon: "ListChecks", description: "Pelacakan dan manajemen posisi terbuka secara real-time", file: path.join(ROOT, "docs", "arsitektur-ai", "19-Position-Manager.md") },
// Engine & Data
{ slug: "risk-engine", title: "Risk Engine", category: "Engine & Data", icon: "Calculator", description: "Perhitungan risiko, Kelly criterion, dan position sizing otomatis", file: path.join(ROOT, "docs", "arsitektur-ai", "20-Risk-Engine.md") },
{ slug: "database", title: "Database", category: "Engine & Data", icon: "Database", description: "Skema PostgreSQL dan penyimpanan data perdagangan", file: path.join(ROOT, "docs", "arsitektur-ai", "21-Database.md") },
// Orkestrator
{ slug: "main-live", title: "Orkestrator Utama", category: "Orkestrator", icon: "Play", description: "Async main loop — inti dari trading bot yang mengkoordinasi semua komponen", file: path.join(ROOT, "docs", "arsitektur-ai", "23-Main-Live-Orchestrator.md") },
// Analisis
{ slug: "weakness-analysis", title: "Analisis Kelemahan", category: "Analisis", icon: "AlertTriangle", description: "Kelemahan yang diketahui, risiko, dan prioritas perbaikan sistem", file: path.join(ROOT, "docs", "WEAKNESS_ANALYSIS.md") },
];
function escapeForTemplate(str) {
// Escape backticks and ${} in template literals
return str.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, "\\${");
}
// Ensure output directory exists
const outDir = path.dirname(OUT);
if (!fs.existsSync(outDir)) {
fs.mkdirSync(outDir, { recursive: true });
}
let output = `// AUTO-GENERATED — do not edit manually.
// Run: node scripts/generate-books.js
export interface BookEntry {
slug: string;
title: string;
category: string;
icon: string;
description: string;
content: string;
}
export const categories = [
"Mulai di Sini",
"AI & Analisis",
"Risiko & Proteksi",
"Proses Trading",
"Infrastruktur",
"Konektor & Konfigurasi",
"Engine & Data",
"Orkestrator",
"Analisis",
] as const;
export type Category = (typeof categories)[number];
export const books: BookEntry[] = [\n`;
for (const def of bookDefs) {
let content = "";
try {
content = fs.readFileSync(def.file, "utf-8");
} catch (e) {
console.warn(`WARNING: Could not read ${def.file}: ${e.message}`);
content = `# ${def.title}\n\n*Dokumen tidak ditemukan.*`;
}
output += ` {
slug: ${JSON.stringify(def.slug)},
title: ${JSON.stringify(def.title)},
category: ${JSON.stringify(def.category)},
icon: ${JSON.stringify(def.icon)},
description: ${JSON.stringify(def.description)},
content: \`${escapeForTemplate(content)}\`,
},\n`;
}
output += `];\n`;
fs.writeFileSync(OUT, output, "utf-8");
console.log(`Generated ${OUT} with ${bookDefs.length} books.`);
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Alert / Signal Log — XAUBOT AI",
description: "Complete signal and alert history with execution tracking",
};
export default function AlertsLayout({ children }: { children: React.ReactNode }) {
return children;
}
+284
View File
@@ -0,0 +1,284 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
Bell,
Activity,
CheckCircle2,
XCircle,
Filter,
ChevronLeft,
ChevronRight,
TrendingUp,
TrendingDown,
Minus,
Zap,
Gauge,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ThemeToggle } from "@/components/theme-toggle";
import { useSignals, useSignalStats } from "@/hooks/use-signals";
import { formatUSD } from "@/lib/utils";
import { format } from "date-fns";
function StatsRow() {
const { stats } = useSignalStats(24);
const items = [
{
label: "Signals (24h)",
value: stats?.total ?? 0,
fmt: (v: number) => String(v),
icon: Bell,
color: "text-apple-blue",
accent: "accent-top-blue",
},
{
label: "Executed",
value: stats?.executed ?? 0,
fmt: (v: number) => String(v),
icon: Zap,
color: "text-apple-green",
accent: "accent-top-green",
},
{
label: "Execution Rate",
value: stats?.executionRate ?? 0,
fmt: (v: number) => `${v.toFixed(1)}%`,
icon: Activity,
color: "text-apple-purple",
accent: "accent-top-purple",
},
{
label: "Avg Confidence",
value: stats?.avgConfidence ?? 0,
fmt: (v: number) => `${v.toFixed(1)}%`,
icon: Gauge,
color: "text-apple-cyan",
accent: "accent-top-cyan",
},
];
return (
<div className="grid grid-cols-4 gap-3">
{items.map((item) => (
<div key={item.label} className={`glass rounded-xl p-4 ${item.accent}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-medium">{item.label}</span>
<item.icon className={`h-4 w-4 ${item.color}`} />
</div>
<p className={`text-2xl font-bold font-number ${item.color}`}>
{item.fmt(item.value)}
</p>
</div>
))}
</div>
);
}
const signalIcon = (type: string) => {
switch (type) {
case "BUY": return <TrendingUp className="h-3.5 w-3.5" />;
case "SELL": return <TrendingDown className="h-3.5 w-3.5" />;
default: return <Minus className="h-3.5 w-3.5" />;
}
};
const signalBadgeVariant = (type: string) => {
switch (type) {
case "BUY": return "success" as const;
case "SELL": return "danger" as const;
default: return "warning" as const;
}
};
function SignalTable({
filters,
setFilters,
}: {
filters: { page: number; limit: number; type: string; executed: string; startDate: string; endDate: string };
setFilters: React.Dispatch<React.SetStateAction<typeof filters>>;
}) {
const { signals, total, loading } = useSignals(filters);
const totalPages = Math.ceil(total / filters.limit) || 1;
return (
<div className="glass rounded-xl overflow-hidden">
{/* Filter bar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
<Filter className="h-4 w-4 text-muted-foreground" />
<select
value={filters.type}
onChange={(e) => setFilters((p) => ({ ...p, type: e.target.value, page: 1 }))}
className="text-sm px-2 py-1 rounded-md bg-surface border border-border"
>
<option value="ALL">All Types</option>
<option value="BUY">BUY</option>
<option value="SELL">SELL</option>
<option value="HOLD">HOLD</option>
</select>
<select
value={filters.executed}
onChange={(e) => setFilters((p) => ({ ...p, executed: e.target.value, page: 1 }))}
className="text-sm px-2 py-1 rounded-md bg-surface border border-border"
>
<option value="all">All Status</option>
<option value="yes">Executed</option>
<option value="no">Not Executed</option>
</select>
<input
type="date"
value={filters.startDate}
onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value, page: 1 }))}
className="text-sm px-2 py-1 rounded-md bg-surface border border-border"
/>
<span className="text-xs text-muted-foreground">to</span>
<input
type="date"
value={filters.endDate}
onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value, page: 1 }))}
className="text-sm px-2 py-1 rounded-md bg-surface border border-border"
/>
<span className="ml-auto text-xs text-muted-foreground font-number">
{total} signals
</span>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left px-4 py-2 font-medium">Time</th>
<th className="text-left px-3 py-2 font-medium">Signal</th>
<th className="text-right px-3 py-2 font-medium">Confidence</th>
<th className="text-center px-3 py-2 font-medium">Executed</th>
<th className="text-left px-3 py-2 font-medium">Reason</th>
<th className="text-left px-3 py-2 font-medium">SMC</th>
<th className="text-left px-3 py-2 font-medium">ML</th>
<th className="text-left px-3 py-2 font-medium">Regime</th>
<th className="text-left px-3 py-2 font-medium">Session</th>
<th className="text-right px-3 py-2 font-medium">Entry</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={10} className="text-center py-8 text-muted-foreground">Loading...</td>
</tr>
) : signals.length === 0 ? (
<tr>
<td colSpan={10} className="text-center py-8 text-muted-foreground">No signals found</td>
</tr>
) : (
signals.map((s) => (
<tr key={s.id} className="border-b border-border/50 row-hover">
<td className="px-4 py-2 font-number text-xs whitespace-nowrap">
{(() => {
try { return format(new Date(s.signal_time), "dd MMM HH:mm"); }
catch { return s.signal_time; }
})()}
</td>
<td className="px-3 py-2">
<Badge variant={signalBadgeVariant(s.signal_type)} className="gap-1 text-xs">
{signalIcon(s.signal_type)}
{s.signal_type}
</Badge>
</td>
<td className="px-3 py-2 text-right font-number">
{(s.confidence * 100).toFixed(0)}%
</td>
<td className="px-3 py-2 text-center">
{s.executed ? (
<CheckCircle2 className="h-4 w-4 text-success inline" />
) : (
<XCircle className="h-4 w-4 text-muted-foreground inline" />
)}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground max-w-[200px] truncate">
{s.execution_reason || "—"}
</td>
<td className="px-3 py-2 text-xs">{s.smc_signal || "—"}</td>
<td className="px-3 py-2 text-xs">{s.ml_signal || "—"}</td>
<td className="px-3 py-2 text-xs">{s.regime || "—"}</td>
<td className="px-3 py-2 text-xs">{s.session || "—"}</td>
<td className="px-3 py-2 text-right font-number">
{s.entry_price ? s.entry_price.toFixed(2) : "—"}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-xs text-muted-foreground">
Page {filters.page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setFilters((p) => ({ ...p, page: Math.max(1, p.page - 1) }))}
disabled={filters.page <= 1}
className="p-1.5 rounded-md hover:bg-surface disabled:opacity-30"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={() => setFilters((p) => ({ ...p, page: Math.min(totalPages, p.page + 1) }))}
disabled={filters.page >= totalPages}
className="p-1.5 rounded-md hover:bg-surface disabled:opacity-30"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
export default function AlertsPage() {
const [filters, setFilters] = useState({
page: 1,
limit: 50,
type: "ALL",
executed: "all",
startDate: "",
endDate: "",
});
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Header */}
<header className="shrink-0 w-full border-b border-border bg-white/70 dark:bg-white/[0.03] backdrop-blur-2xl">
<div className="flex h-11 items-center justify-between px-4">
<div className="flex items-center gap-3">
<Link
href="/"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface border border-border hover:border-primary/20 transition-colors text-sm text-muted-foreground hover:text-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Dashboard</span>
</Link>
<div className="w-px h-5 bg-border" />
<Bell className="h-4 w-4 text-apple-orange" />
<h1 className="text-base font-bold">Alert / Signal Log</h1>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<span className="text-xs text-muted-foreground font-mono">XAUBOT AI</span>
</div>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-y-auto p-4 space-y-4">
<StatsRow />
<SignalTable filters={filters} setFilters={setFilters} />
</main>
</div>
);
}
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Backtest Viewer — XAUBOT AI",
description: "Compare and analyze backtest results across strategies",
};
export default function BacktestsLayout({ children }: { children: React.ReactNode }) {
return children;
}
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import {
ArrowLeft,
FlaskConical,
Trophy,
TrendingUp,
TrendingDown,
BarChart3,
Activity,
Layers,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ThemeToggle } from "@/components/theme-toggle";
import { backtestResults, type BacktestResult } from "@/data/backtests";
import { formatUSD } from "@/lib/utils";
import { cn } from "@/lib/utils";
function MetricsGrid({ bt }: { bt: BacktestResult }) {
const metrics = [
{ label: "Total Trades", value: bt.totalTrades, fmt: (v: number) => String(v), color: "text-apple-blue" },
{ label: "Win Rate", value: bt.winRate, fmt: (v: number) => `${v.toFixed(1)}%`, color: "text-apple-green" },
{ label: "Net PnL", value: bt.netPnl, fmt: (v: number) => formatUSD(v), color: bt.netPnl >= 0 ? "text-success" : "text-danger" },
{ label: "Profit Factor", value: bt.profitFactor, fmt: (v: number) => v.toFixed(2), color: "text-apple-purple" },
{ label: "Max Drawdown", value: bt.maxDrawdown, fmt: (v: number) => `${v.toFixed(1)}%`, color: "text-apple-orange" },
{ label: "Sharpe Ratio", value: bt.sharpeRatio, fmt: (v: number) => v.toFixed(2), color: "text-apple-cyan" },
{ label: "Avg Win", value: bt.avgWin, fmt: (v: number) => formatUSD(v), color: "text-success" },
{ label: "Avg Loss", value: bt.avgLoss, fmt: (v: number) => formatUSD(v), color: "text-danger" },
];
return (
<div className="grid grid-cols-4 gap-2">
{metrics.map((m) => (
<div key={m.label} className="glass rounded-lg p-3">
<p className="text-xs text-muted-foreground mb-1">{m.label}</p>
<p className={`text-lg font-bold font-number ${m.color}`}>{m.fmt(m.value)}</p>
</div>
))}
</div>
);
}
function ExitReasonsBar({ bt }: { bt: BacktestResult }) {
if (bt.exitReasons.length === 0) return null;
const max = Math.max(...bt.exitReasons.map((r) => r.count));
const colors = [
"bar-blue", "bar-green", "bar-orange", "bar-red", "bar-purple", "bar-cyan",
"bar-blue", "bar-green", "bar-orange", "bar-red", "bar-purple",
];
return (
<div className="glass rounded-xl p-4">
<h3 className="text-sm font-semibold mb-3">Exit Reasons</h3>
<div className="space-y-2">
{bt.exitReasons.map((r, i) => (
<div key={r.reason} className="flex items-center gap-2 text-xs">
<span className="w-28 text-muted-foreground truncate">{r.reason}</span>
<div className="flex-1 h-4 bg-surface-light rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${colors[i % colors.length]} bar-animate-in`}
style={{ width: `${(r.count / max) * 100}%`, animationDelay: `${i * 50}ms` }}
/>
</div>
<span className="w-8 text-right font-number">{r.count}</span>
<span className="w-12 text-right font-number text-muted-foreground">{r.pct}%</span>
</div>
))}
</div>
</div>
);
}
function SessionBars({ bt }: { bt: BacktestResult }) {
if (bt.sessionBreakdown.length === 0) return null;
return (
<div className="glass rounded-xl p-4">
<h3 className="text-sm font-semibold mb-3">Session Performance</h3>
<div className="space-y-3">
{bt.sessionBreakdown.map((s) => (
<div key={s.session} className="flex items-center gap-3 text-xs">
<span className="w-40 text-muted-foreground truncate">{s.session}</span>
<Badge variant={s.pnl >= 0 ? "success" : "danger"} className="text-xs">
{s.winRate}% WR
</Badge>
<span className="font-number">{s.trades} trades</span>
<span className={`ml-auto font-number font-semibold ${s.pnl >= 0 ? "text-success" : "text-danger"}`}>
{formatUSD(s.pnl)}
</span>
</div>
))}
</div>
</div>
);
}
function ComparisonTable({ results }: { results: BacktestResult[] }) {
const sorted = [...results].filter((r) => r.totalTrades > 0).sort((a, b) => b.netPnl - a.netPnl);
return (
<div className="glass rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-border">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Layers className="h-4 w-4 text-apple-purple" />
Perbandingan Strategi
</h3>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left px-4 py-2 font-medium">#</th>
<th className="text-left px-3 py-2 font-medium">Strategy</th>
<th className="text-right px-3 py-2 font-medium">Trades</th>
<th className="text-right px-3 py-2 font-medium">Win Rate</th>
<th className="text-right px-3 py-2 font-medium">Net PnL</th>
<th className="text-right px-3 py-2 font-medium">PF</th>
<th className="text-right px-3 py-2 font-medium">Max DD</th>
<th className="text-right px-3 py-2 font-medium">Sharpe</th>
<th className="text-right px-3 py-2 font-medium">Expectancy</th>
</tr>
</thead>
<tbody>
{sorted.map((bt, i) => (
<tr key={bt.id} className="border-b border-border/50 row-hover">
<td className="px-4 py-2 font-number text-muted-foreground">{i + 1}</td>
<td className="px-3 py-2 font-medium">{bt.name}</td>
<td className="px-3 py-2 text-right font-number">{bt.totalTrades}</td>
<td className="px-3 py-2 text-right font-number">{bt.winRate.toFixed(1)}%</td>
<td className={`px-3 py-2 text-right font-number font-semibold ${bt.netPnl >= 0 ? "text-success" : "text-danger"}`}>
{formatUSD(bt.netPnl)}
</td>
<td className="px-3 py-2 text-right font-number">{bt.profitFactor.toFixed(2)}</td>
<td className="px-3 py-2 text-right font-number text-apple-orange">{bt.maxDrawdown.toFixed(1)}%</td>
<td className="px-3 py-2 text-right font-number">{bt.sharpeRatio.toFixed(2)}</td>
<td className={`px-3 py-2 text-right font-number ${bt.expectancy >= 0 ? "text-success" : "text-danger"}`}>
{formatUSD(bt.expectancy)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export default function BacktestsPage() {
const [selectedId, setSelectedId] = useState(backtestResults[0]?.id ?? 1);
const [tab, setTab] = useState<"detail" | "compare">("detail");
const validResults = useMemo(
() => backtestResults.filter((r) => r.totalTrades > 0),
[]
);
const selected = useMemo(
() => validResults.find((r) => r.id === selectedId) ?? validResults[0],
[selectedId, validResults]
);
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Header */}
<header className="shrink-0 w-full border-b border-border bg-white/70 dark:bg-white/[0.03] backdrop-blur-2xl">
<div className="flex h-11 items-center justify-between px-4">
<div className="flex items-center gap-3">
<Link
href="/"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface border border-border hover:border-primary/20 transition-colors text-sm text-muted-foreground hover:text-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Dashboard</span>
</Link>
<div className="w-px h-5 bg-border" />
<FlaskConical className="h-4 w-4 text-apple-purple" />
<h1 className="text-base font-bold">Backtest Viewer</h1>
</div>
<div className="flex items-center gap-3">
{/* Tab switcher */}
<div className="flex rounded-lg bg-surface-light border border-border p-0.5">
<button
onClick={() => setTab("detail")}
className={cn(
"px-3 py-1 rounded-md text-xs font-medium transition-colors",
tab === "detail" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
Detail
</button>
<button
onClick={() => setTab("compare")}
className={cn(
"px-3 py-1 rounded-md text-xs font-medium transition-colors",
tab === "compare" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
Perbandingan
</button>
</div>
<ThemeToggle />
<span className="text-xs text-muted-foreground font-mono">XAUBOT AI</span>
</div>
</div>
</header>
<div className="flex flex-1 min-h-0">
{/* Sidebar — backtest list */}
<aside className="w-72 shrink-0 border-r border-border bg-white/60 dark:bg-white/[0.02] backdrop-blur-sm overflow-y-auto">
<div className="p-2.5 border-b border-border">
<p className="text-xs text-muted-foreground font-medium">
{validResults.length} backtests
</p>
</div>
<div className="py-1">
{validResults.map((bt) => (
<button
key={bt.id}
onClick={() => { setSelectedId(bt.id); setTab("detail"); }}
className={cn(
"w-full flex items-center justify-between px-3 py-2 text-sm transition-colors",
bt.id === selectedId
? "bg-primary/10 text-primary font-medium border-r-2 border-primary"
: "text-muted-foreground hover:text-foreground hover:bg-surface-light"
)}
>
<span className="truncate">
<span className="font-number text-xs opacity-50 mr-1.5">#{bt.id}</span>
{bt.name}
</span>
<div className="flex items-center gap-1.5 shrink-0 ml-2">
<Badge
variant={bt.netPnl >= 0 ? "success" : "danger"}
className="text-[10px] px-1.5 py-0"
>
{bt.netPnl >= 0 ? "+" : ""}{formatUSD(bt.netPnl)}
</Badge>
</div>
</button>
))}
</div>
</aside>
{/* Main content */}
<main className="flex-1 overflow-y-auto p-4 space-y-4">
{tab === "detail" && selected ? (
<>
{/* Title */}
<div className="glass rounded-xl p-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-bold flex items-center gap-2">
<Activity className="h-5 w-5 text-apple-blue" />
#{selected.id} {selected.name}
</h2>
{selected.strategy && (
<p className="text-xs text-muted-foreground mt-1">{selected.strategy}</p>
)}
</div>
<div className="text-right text-xs text-muted-foreground">
{selected.period && <p>{selected.period}</p>}
{selected.generatedAt && <p>{selected.generatedAt}</p>}
</div>
</div>
</div>
<MetricsGrid bt={selected} />
<div className="grid grid-cols-2 gap-4">
<ExitReasonsBar bt={selected} />
<SessionBars bt={selected} />
</div>
{/* Direction breakdown */}
{selected.directionBreakdown.length > 0 && (
<div className="glass rounded-xl p-4">
<h3 className="text-sm font-semibold mb-3">Direction Breakdown</h3>
<div className="grid grid-cols-2 gap-4">
{selected.directionBreakdown.map((d) => (
<div key={d.direction} className="flex items-center gap-3">
<Badge variant={d.direction === "BUY" ? "success" : "danger"}>
{d.direction}
</Badge>
<span className="text-sm font-number">{d.trades} trades</span>
<span className="text-sm font-number">{d.winRate}% WR</span>
<span className={`ml-auto text-sm font-number font-semibold ${d.pnl >= 0 ? "text-success" : "text-danger"}`}>
{formatUSD(d.pnl)}
</span>
</div>
))}
</div>
</div>
)}
</>
) : (
<ComparisonTable results={validResults} />
)}
</main>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "XAUBOT AI — Documentation",
description: "System documentation and architecture reference for XAUBOT AI",
};
export default function BooksLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="h-full overflow-auto">{children}</div>
);
}
+286
View File
@@ -0,0 +1,286 @@
"use client";
import { useState, useMemo, useCallback } from "react";
import Link from "next/link";
import {
BookOpen,
Sparkles,
LayoutDashboard,
List,
Brain,
Cpu,
TrendingUp,
Layers,
Shield,
Clock,
ShieldAlert,
Target,
ArrowRightCircle,
ArrowLeftCircle,
Newspaper,
Send,
RefreshCw,
BarChart3,
Gauge,
GraduationCap,
Plug,
Settings,
FileText,
ListChecks,
Calculator,
Database,
Play,
AlertTriangle,
ChevronDown,
ChevronRight,
ArrowLeft,
Search,
X,
PanelLeftClose,
PanelLeftOpen,
Info,
type LucideIcon,
} from "lucide-react";
import { books, categories, type BookEntry } from "@/data/books";
import { MarkdownRenderer } from "@/components/books/markdown-renderer";
import { AboutDialog } from "@/components/about-dialog";
import { ThemeToggle } from "@/components/theme-toggle";
import { cn } from "@/lib/utils";
const iconMap: Record<string, LucideIcon> = {
BookOpen, Sparkles, LayoutDashboard, List, Brain, Cpu, TrendingUp, Layers,
Shield, Clock, ShieldAlert, Target, ArrowRightCircle, ArrowLeftCircle,
Newspaper, Send, RefreshCw, BarChart3, Gauge, GraduationCap, Plug,
Settings, FileText, ListChecks, Calculator, Database, Play, AlertTriangle,
};
const categoryIcons: Record<string, LucideIcon> = {
"Mulai di Sini": BookOpen,
"AI & Analisis": Brain,
"Risiko & Proteksi": Shield,
"Proses Trading": TrendingUp,
"Infrastruktur": Settings,
"Konektor & Konfigurasi": Plug,
"Engine & Data": Database,
"Orkestrator": Play,
"Analisis": AlertTriangle,
};
export default function BooksPage() {
const [selectedSlug, setSelectedSlug] = useState<string>("readme");
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
() => new Set(categories)
);
const [sidebarOpen, setSidebarOpen] = useState(true);
const [search, setSearch] = useState("");
const selectedBook = useMemo(
() => books.find((b) => b.slug === selectedSlug) ?? books[0],
[selectedSlug]
);
const filteredBooks = useMemo(() => {
if (!search.trim()) return books;
const q = search.toLowerCase();
return books.filter(
(b) =>
b.title.toLowerCase().includes(q) ||
b.description.toLowerCase().includes(q) ||
b.category.toLowerCase().includes(q)
);
}, [search]);
const groupedBooks = useMemo(() => {
const map = new Map<string, BookEntry[]>();
for (const cat of categories) {
const items = filteredBooks.filter((b) => b.category === cat);
if (items.length > 0) map.set(cat, items);
}
return map;
}, [filteredBooks]);
const toggleCategory = useCallback((cat: string) => {
setExpandedCategories((prev) => {
const next = new Set(prev);
if (next.has(cat)) next.delete(cat);
else next.add(cat);
return next;
});
}, []);
const selectBook = useCallback((slug: string) => {
setSelectedSlug(slug);
document.getElementById("books-content")?.scrollTo(0, 0);
}, []);
return (
<div className="flex flex-col h-full min-h-0 bg-background">
{/* ── Header ── */}
<header className="shrink-0 w-full border-b border-border bg-white/80 dark:bg-white/[0.03] backdrop-blur-xl">
<div className="flex h-11 items-center justify-between px-4">
<div className="flex items-center gap-3">
<Link
href="/"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface border border-border hover:border-primary/20 transition-colors text-sm text-muted-foreground hover:text-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Dashboard</span>
</Link>
<button
onClick={() => setSidebarOpen((p) => !p)}
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-light transition-colors text-muted-foreground hover:text-foreground"
title={sidebarOpen ? "Tutup sidebar" : "Buka sidebar"}
>
{sidebarOpen ? (
<PanelLeftClose className="h-4 w-4" />
) : (
<PanelLeftOpen className="h-4 w-4" />
)}
</button>
<div className="w-px h-5 bg-border" />
<div className="flex items-center gap-2">
<BookOpen className="h-4 w-4 text-amber-600 dark:text-amber-400" />
<h1 className="text-base font-bold">Dokumentasi Sistem</h1>
</div>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{books.length} dokumen</span>
<span className="text-border">|</span>
<span className="font-semibold">XAUBOT AI</span>
<span className="text-border">|</span>
<ThemeToggle />
<AboutDialog>
<button
className="flex items-center gap-1.5 px-2 py-0.5 rounded-lg bg-surface border border-border hover:border-primary/20 transition-colors text-muted-foreground hover:text-primary"
title="About XAUBOT AI"
>
<Info className="h-3.5 w-3.5" />
<span className="hidden sm:inline text-xs">About</span>
</button>
</AboutDialog>
</div>
</div>
</header>
{/* ── Body ── */}
<div className="flex flex-1 min-h-0">
{/* ── Sidebar ── */}
<aside
className={cn(
"shrink-0 border-r border-border bg-white/60 dark:bg-white/[0.02] backdrop-blur-sm flex flex-col transition-all duration-200 ease-in-out",
sidebarOpen ? "w-80" : "w-0 overflow-hidden"
)}
>
{/* Search */}
<div className="p-2.5 border-b border-border">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground" />
<input
type="text"
placeholder="Cari dokumen..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full pl-8 pr-8 py-1.5 rounded-lg bg-surface-light border border-border text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-amber-400/60 dark:focus:border-amber-500/40 focus:ring-1 focus:ring-amber-200/40 dark:focus:ring-amber-500/20"
/>
{search && (
<button
onClick={() => setSearch("")}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
</div>
{/* Category list */}
<nav className="flex-1 overflow-y-auto py-1.5">
{Array.from(groupedBooks.entries()).map(([cat, items]) => {
const CatIcon = categoryIcons[cat] ?? BookOpen;
const isExpanded = expandedCategories.has(cat);
return (
<div key={cat} className="mb-0.5">
<button
onClick={() => toggleCategory(cat)}
className="w-full flex items-center gap-2 px-3 py-2 text-[13px] font-semibold text-muted-foreground hover:text-foreground hover:bg-surface-light transition-colors"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<CatIcon className="h-4 w-4" />
<span className="uppercase tracking-widest">{cat}</span>
<span className="ml-auto text-[11px] font-normal bg-surface-light px-1.5 rounded-full">
{items.length}
</span>
</button>
{isExpanded && (
<div className="pb-1">
{items.map((book) => {
const Icon = iconMap[book.icon] ?? BookOpen;
const isActive = book.slug === selectedSlug;
return (
<button
key={book.slug}
onClick={() => selectBook(book.slug)}
className={cn(
"w-full flex items-center gap-2.5 pl-9 pr-3 py-[7px] text-[15px] transition-all",
isActive
? "bg-amber-50/80 dark:bg-amber-900/20 text-amber-800 dark:text-amber-300 font-medium border-r-2 border-amber-500"
: "text-muted-foreground hover:text-foreground hover:bg-surface-light"
)}
>
<Icon
className={cn(
"h-4 w-4 shrink-0",
isActive ? "text-amber-600 dark:text-amber-400" : "text-muted-foreground"
)}
/>
<span className="truncate">{book.title}</span>
</button>
);
})}
</div>
)}
</div>
);
})}
</nav>
</aside>
{/* ── Content ── */}
<main id="books-content" className="flex-1 min-w-0 overflow-y-auto bg-background">
<div className="max-w-7xl mx-auto px-10 py-8">
{/* Breadcrumb */}
<div className="flex items-center gap-2 mb-5 text-xs text-muted-foreground">
<BookOpen className="h-3 w-3" />
<span>{selectedBook.category}</span>
<ChevronRight className="h-3 w-3" />
<span className="text-foreground font-medium">
{selectedBook.title}
</span>
</div>
{/* Description card */}
<div className="mb-8 px-5 py-4 rounded-xl bg-white dark:bg-white/[0.04] border border-border shadow-sm">
<p className="text-[1rem] text-muted-foreground leading-relaxed">
{selectedBook.description}
</p>
</div>
{/* Markdown content */}
<article className="pb-16">
<MarkdownRenderer content={selectedBook.content} />
</article>
</div>
</main>
</div>
</div>
);
}
+442 -114
View File
@@ -1,78 +1,96 @@
@import "tailwindcss";
@theme {
/* Background layers — soft dark, GitHub Dark Dimmed inspired */
--color-background: oklch(0.21 0.01 250);
--color-foreground: oklch(0.85 0.01 250);
/* ═══════════════════════════════════════════════════════════════
XAUBOT AI — Apple Liquid Glass Theme
Inspired by iOS 26 / macOS Tahoe design language
Fit-screen design: no scrolling, everything visible at once
═══════════════════════════════════════════════════════════════ */
--color-surface: oklch(0.25 0.01 250);
--color-surface-light: oklch(0.30 0.008 250);
--color-surface-hover: oklch(0.34 0.008 250);
/* Background layers — light with vibrant gradient showing through */
--color-background: #f5f5f7;
--color-foreground: #1d1d1f;
--color-card: oklch(0.25 0.01 250);
--color-card-foreground: oklch(0.85 0.01 250);
--color-surface: rgba(255, 255, 255, 0.55);
--color-surface-light: rgba(0, 0, 0, 0.04);
--color-surface-hover: rgba(0, 0, 0, 0.06);
--color-popover: oklch(0.25 0.01 250);
--color-popover-foreground: oklch(0.85 0.01 250);
--color-card: rgba(255, 255, 255, 0.55);
--color-card-foreground: #1d1d1f;
/* Primary — calm blue */
--color-primary: oklch(0.62 0.18 255);
--color-primary-foreground: oklch(0.98 0 0);
--color-primary-dark: oklch(0.56 0.18 255);
--color-popover: rgba(255, 255, 255, 0.85);
--color-popover-foreground: #1d1d1f;
--color-secondary: oklch(0.30 0.008 250);
--color-secondary-foreground: oklch(0.85 0.01 250);
/* Primary — Apple Blue */
--color-primary: #007AFF;
--color-primary-foreground: #ffffff;
--color-primary-dark: #0062CC;
--color-muted: oklch(0.30 0.008 250);
--color-muted-foreground: oklch(0.58 0.01 250);
--color-secondary: rgba(0, 0, 0, 0.05);
--color-secondary-foreground: #1d1d1f;
--color-accent: oklch(0.62 0.17 290);
--color-accent-foreground: oklch(0.98 0 0);
--color-muted: rgba(0, 0, 0, 0.04);
--color-muted-foreground: #86868b;
--color-destructive: oklch(0.62 0.19 25);
--color-destructive-foreground: oklch(0.98 0 0);
--color-accent: #AF52DE;
--color-accent-foreground: #ffffff;
/* Borders — gentle, not harsh */
--color-border: oklch(0.34 0.008 250);
--color-border-light: oklch(0.40 0.006 250);
--color-destructive: #FF3B30;
--color-destructive-foreground: #ffffff;
--color-input: oklch(0.34 0.008 250);
--color-ring: oklch(0.62 0.18 255);
/* Borders */
--color-border: rgba(0, 0, 0, 0.08);
--color-border-light: rgba(0, 0, 0, 0.06);
/* Semantic colors — softer, less saturated */
--color-success: oklch(0.68 0.15 155);
--color-success-bg: oklch(0.68 0.15 155 / 0.12);
--color-input: rgba(0, 0, 0, 0.08);
--color-ring: #007AFF;
--color-warning: oklch(0.76 0.14 75);
--color-warning-bg: oklch(0.76 0.14 75 / 0.12);
/* Semantic — Apple system colors */
--color-success: #34C759;
--color-success-bg: rgba(52, 199, 89, 0.12);
--color-danger: oklch(0.62 0.19 25);
--color-danger-bg: oklch(0.62 0.19 25 / 0.12);
--color-warning: #FF9500;
--color-warning-bg: rgba(255, 149, 0, 0.12);
--color-info: oklch(0.65 0.15 250);
--color-info-bg: oklch(0.65 0.15 250 / 0.12);
--color-danger: #FF3B30;
--color-danger-bg: rgba(255, 59, 48, 0.12);
--color-info: #007AFF;
--color-info-bg: rgba(0, 122, 255, 0.12);
/* Charts */
--color-chart-1: oklch(0.62 0.18 255);
--color-chart-2: oklch(0.68 0.15 155);
--color-chart-3: oklch(0.76 0.14 75);
--color-chart-4: oklch(0.62 0.17 290);
--color-chart-5: oklch(0.62 0.19 25);
--color-chart-1: #007AFF;
--color-chart-2: #34C759;
--color-chart-3: #FF9500;
--color-chart-4: #AF52DE;
--color-chart-5: #FF3B30;
/* Apple system color palette */
--apple-green: #34C759;
--apple-blue: #007AFF;
--apple-red: #FF3B30;
--apple-orange: #FF9500;
--apple-purple: #AF52DE;
--apple-cyan: #32ADE6;
--apple-pink: #FF2D55;
--apple-indigo: #5856D6;
--apple-teal: #5AC8FA;
--apple-mint: #00C7BE;
/* Radius */
--radius-sm: calc(0.625rem - 4px);
--radius-md: calc(0.625rem - 2px);
--radius-lg: 0.625rem;
--radius-xl: 0.875rem;
--radius-sm: 6px;
--radius-md: 10px;
--radius-lg: 14px;
--radius-xl: 20px;
/* Fonts */
--font-sans: var(--font-inter), 'Inter', system-ui, sans-serif;
--font-mono: var(--font-jetbrains), 'JetBrains Mono', 'Fira Code', monospace;
/* Fonts — IBM Plex Sans + IBM Plex Mono */
--font-sans: var(--font-ibm-plex-sans), 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: var(--font-ibm-plex-mono), 'IBM Plex Mono', monospace;
/* Animations */
--animate-pulse-slow: pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite;
--animate-fade-in: fadeIn 0.4s ease-out;
--animate-slide-up: slideUp 0.4s ease-out;
--animate-fade-in: fadeIn 0.3s ease-out;
--animate-slide-up: slideUp 0.3s ease-out;
--animate-shimmer: shimmer 2s ease-in-out infinite;
}
@@ -80,27 +98,51 @@
@layer base {
* {
border-color: var(--color-border);
outline-color: color-mix(in oklch, var(--color-ring) 50%, transparent);
outline-color: color-mix(in srgb, var(--color-ring) 50%, transparent);
}
html {
color-scheme: dark;
color-scheme: light;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
html.dark {
color-scheme: dark;
}
html, body {
@apply bg-background text-foreground font-sans;
@apply text-foreground font-sans;
height: 100%;
overflow: hidden;
font-size: 15px;
line-height: 1.45;
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
overflow: hidden;
background:
radial-gradient(ellipse at 10% 10%, rgba(0, 122, 255, 0.12) 0%, transparent 50%),
radial-gradient(ellipse at 90% 10%, rgba(175, 82, 222, 0.10) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(52, 199, 89, 0.06) 0%, transparent 60%),
radial-gradient(ellipse at 80% 80%, rgba(255, 149, 0, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 20% 90%, rgba(255, 45, 85, 0.06) 0%, transparent 50%),
#f5f5f7;
}
html.dark body,
html.dark {
background:
radial-gradient(ellipse at 10% 10%, rgba(0, 122, 255, 0.08) 0%, transparent 50%),
radial-gradient(ellipse at 90% 10%, rgba(175, 82, 222, 0.06) 0%, transparent 50%),
radial-gradient(ellipse at 50% 50%, rgba(52, 199, 89, 0.04) 0%, transparent 60%),
radial-gradient(ellipse at 80% 80%, rgba(255, 149, 0, 0.05) 0%, transparent 50%),
radial-gradient(ellipse at 20% 90%, rgba(255, 45, 85, 0.04) 0%, transparent 50%),
#0d0d0f;
}
}
/* ─── Scrollbar ─── */
/* ─── Scrollbar (internal card scroll only) ─── */
::-webkit-scrollbar {
width: 6px;
height: 6px;
width: 4px;
height: 4px;
}
::-webkit-scrollbar-track {
@@ -108,93 +150,303 @@
}
::-webkit-scrollbar-thumb {
background: var(--color-border);
background: rgba(0, 0, 0, 0.15);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-border-light);
background: rgba(0, 0, 0, 0.25);
}
/* ─── Dark Theme Overrides ─── */
html.dark {
--color-background: #0d0d0f;
--color-foreground: #e5e5e7;
--color-surface: rgba(30, 30, 32, 0.55);
--color-surface-light: rgba(255, 255, 255, 0.04);
--color-surface-hover: rgba(255, 255, 255, 0.06);
--color-card: rgba(30, 30, 32, 0.55);
--color-card-foreground: #e5e5e7;
--color-popover: rgba(30, 30, 32, 0.85);
--color-popover-foreground: #e5e5e7;
--color-primary: #0A84FF;
--color-primary-foreground: #ffffff;
--color-primary-dark: #409CFF;
--color-secondary: rgba(255, 255, 255, 0.06);
--color-secondary-foreground: #e5e5e7;
--color-muted: rgba(255, 255, 255, 0.06);
--color-muted-foreground: #98989d;
--color-accent: #BF5AF2;
--color-accent-foreground: #ffffff;
--color-destructive: #FF453A;
--color-destructive-foreground: #ffffff;
--color-border: rgba(255, 255, 255, 0.08);
--color-border-light: rgba(255, 255, 255, 0.06);
--color-input: rgba(255, 255, 255, 0.08);
--color-ring: #0A84FF;
--color-success: #30D158;
--color-success-bg: rgba(48, 209, 88, 0.15);
--color-warning: #FF9F0A;
--color-warning-bg: rgba(255, 159, 10, 0.15);
--color-danger: #FF453A;
--color-danger-bg: rgba(255, 69, 58, 0.15);
--color-info: #0A84FF;
--color-info-bg: rgba(10, 132, 255, 0.15);
--color-chart-1: #0A84FF;
--color-chart-2: #30D158;
--color-chart-3: #FF9F0A;
--color-chart-4: #BF5AF2;
--color-chart-5: #FF453A;
--apple-green: #30D158;
--apple-blue: #0A84FF;
--apple-red: #FF453A;
--apple-orange: #FF9F0A;
--apple-purple: #BF5AF2;
--apple-cyan: #64D2FF;
--apple-pink: #FF375F;
--apple-indigo: #5E5CE6;
--apple-teal: #6AC4DC;
--apple-mint: #63E6E2;
}
html.dark ::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.15);
}
html.dark ::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.25);
}
/* ─── Utilities ─── */
@layer utilities {
/* Glass — soft frosted effect */
/* Glass card — Apple Liquid Glass */
.glass {
background: color-mix(in oklch, var(--color-surface) 90%, transparent);
backdrop-filter: blur(10px) saturate(120%);
-webkit-backdrop-filter: blur(10px) saturate(120%);
border: 1px solid color-mix(in oklch, var(--color-border) 50%, transparent);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(40px) saturate(180%);
-webkit-backdrop-filter: blur(40px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.6);
box-shadow:
0 1px 2px rgba(0, 0, 0, 0.12),
0 0 1px rgba(0, 0, 0, 0.08);
transition: border-color 0.2s ease;
0 1px 3px rgba(0, 0, 0, 0.06),
0 4px 16px rgba(0, 0, 0, 0.04),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
transition: border-color 0.3s ease, box-shadow 0.3s ease;
}
.glass:hover {
border-color: var(--color-border-light);
border-color: rgba(0, 122, 255, 0.2);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.08),
0 8px 24px rgba(0, 122, 255, 0.06),
inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
/* Monospace numbers with tabular figures */
/* Colored glass hover variants */
.glass-green:hover {
border-color: rgba(52, 199, 89, 0.3);
box-shadow: 0 4px 20px rgba(52, 199, 89, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.glass-red:hover {
border-color: rgba(255, 59, 48, 0.3);
box-shadow: 0 4px 20px rgba(255, 59, 48, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.glass-purple:hover {
border-color: rgba(175, 82, 222, 0.3);
box-shadow: 0 4px 20px rgba(175, 82, 222, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.glass-cyan:hover {
border-color: rgba(50, 173, 230, 0.3);
box-shadow: 0 4px 20px rgba(50, 173, 230, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.glass-orange:hover {
border-color: rgba(255, 149, 0, 0.3);
box-shadow: 0 4px 20px rgba(255, 149, 0, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.glass-pink:hover {
border-color: rgba(255, 45, 85, 0.3);
box-shadow: 0 4px 20px rgba(255, 45, 85, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
.glass-blue:hover {
border-color: rgba(0, 122, 255, 0.3);
box-shadow: 0 4px 20px rgba(0, 122, 255, 0.08), inset 0 1px 0 rgba(255, 255, 255, 0.8);
}
/* Accent top borders — soft colored */
.accent-top-blue {
border-top: 2px solid var(--apple-blue);
box-shadow: inset 0 2px 8px -2px rgba(0, 122, 255, 0.1);
}
.accent-top-green {
border-top: 2px solid var(--apple-green);
box-shadow: inset 0 2px 8px -2px rgba(52, 199, 89, 0.1);
}
.accent-top-purple {
border-top: 2px solid var(--apple-purple);
box-shadow: inset 0 2px 8px -2px rgba(175, 82, 222, 0.1);
}
.accent-top-cyan {
border-top: 2px solid var(--apple-cyan);
box-shadow: inset 0 2px 8px -2px rgba(50, 173, 230, 0.1);
}
.accent-top-orange {
border-top: 2px solid var(--apple-orange);
box-shadow: inset 0 2px 8px -2px rgba(255, 149, 0, 0.1);
}
.accent-top-red {
border-top: 2px solid var(--apple-red);
box-shadow: inset 0 2px 8px -2px rgba(255, 59, 48, 0.1);
}
.accent-top-pink {
border-top: 2px solid var(--apple-pink);
box-shadow: inset 0 2px 8px -2px rgba(255, 45, 85, 0.1);
}
/* Monospace numbers */
.font-number {
font-family: var(--font-mono);
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
}
/* Section label */
.section-label {
@apply text-[11px] font-medium text-muted-foreground uppercase;
letter-spacing: 0.1em;
}
/* Signal border accents */
/* Signal border accents — soft */
.signal-buy {
border-left: 3px solid var(--color-success);
border-left: 3px solid var(--apple-green);
box-shadow: inset 4px 0 12px -3px rgba(52, 199, 89, 0.1);
}
.signal-sell {
border-left: 3px solid var(--color-danger);
border-left: 3px solid var(--apple-red);
box-shadow: inset 4px 0 12px -3px rgba(255, 59, 48, 0.1);
}
.signal-hold {
border-left: 3px solid var(--color-warning);
border-left: 3px solid var(--apple-orange);
box-shadow: inset 4px 0 12px -3px rgba(255, 149, 0, 0.1);
}
.signal-none {
border-left: 3px solid var(--color-muted);
}
/* Badge variants */
.badge-success {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-success-bg text-success;
}
.badge-warning {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-warning-bg text-warning;
}
.badge-danger {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-danger-bg text-danger;
}
.badge-info {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-info-bg text-info;
}
/* Text gradient */
/* Text gradient — Apple multi-color */
.text-gradient {
@apply bg-gradient-to-r from-primary to-accent bg-clip-text text-transparent;
@apply bg-clip-text text-transparent;
background-image: linear-gradient(135deg, var(--apple-blue), var(--apple-cyan), var(--apple-teal));
}
.text-gradient-warm {
@apply bg-clip-text text-transparent;
background-image: linear-gradient(135deg, var(--apple-orange), var(--apple-red), var(--apple-pink));
}
.text-gradient-purple {
@apply bg-clip-text text-transparent;
background-image: linear-gradient(135deg, var(--apple-blue), var(--apple-purple), var(--apple-pink));
}
/* Progress bars — soft gradient fills */
.bar-green {
background: linear-gradient(90deg, #28a745, var(--apple-green));
}
.bar-blue {
background: linear-gradient(90deg, #0062CC, var(--apple-blue));
}
.bar-red {
background: linear-gradient(90deg, #cc2d24, var(--apple-red));
}
.bar-orange {
background: linear-gradient(90deg, #cc7700, var(--apple-orange));
}
.bar-purple {
background: linear-gradient(90deg, #8e3cb8, var(--apple-purple));
}
.bar-cyan {
background: linear-gradient(90deg, #2890c0, var(--apple-cyan));
}
/* Dark glass overrides */
:is(html.dark) .glass {
background: rgba(30, 30, 32, 0.55);
border-color: rgba(255, 255, 255, 0.08);
box-shadow:
0 1px 3px rgba(0, 0, 0, 0.2),
0 4px 16px rgba(0, 0, 0, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
:is(html.dark) .glass:hover {
border-color: rgba(10, 132, 255, 0.25);
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.3),
0 8px 24px rgba(10, 132, 255, 0.08),
inset 0 1px 0 rgba(255, 255, 255, 0.05);
}
:is(html.dark) .row-hover:hover {
background-color: rgba(10, 132, 255, 0.06);
}
/* Skeleton */
.skeleton {
@apply bg-surface-light rounded;
@apply rounded-xl;
animation: shimmer 2s ease-in-out infinite;
background: linear-gradient(
90deg,
var(--color-surface) 0%,
var(--color-surface-light) 50%,
var(--color-surface) 100%
rgba(255, 255, 255, 0.4) 0%,
rgba(255, 255, 255, 0.7) 50%,
rgba(255, 255, 255, 0.4) 100%
);
background-size: 200% 100%;
}
:is(html.dark) .skeleton {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.04) 0%,
rgba(255, 255, 255, 0.08) 50%,
rgba(255, 255, 255, 0.04) 100%
);
background-size: 200% 100%;
}
:is(html.dark) .skeleton-glass {
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.03) 0%,
rgba(255, 255, 255, 0.06) 50%,
rgba(255, 255, 255, 0.03) 100%
);
background-size: 200% 100%;
}
@@ -202,13 +454,15 @@
/* Live pulse dot */
.pulse-live::before {
content: '';
@apply absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-success rounded-full;
@apply absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full;
background: var(--apple-green);
animation: pulse-dot 2s infinite;
}
.pulse-stale::before {
content: '';
@apply absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 bg-warning rounded-full;
@apply absolute -left-2 top-1/2 -translate-y-1/2 w-1.5 h-1.5 rounded-full;
background: var(--apple-orange);
animation: pulse-dot 1.5s infinite;
}
@@ -220,23 +474,17 @@
/* ─── Keyframes ─── */
@keyframes pulse-dot {
0%, 100% {
opacity: 1;
transform: translateY(-50%) scale(1);
}
50% {
opacity: 0.4;
transform: translateY(-50%) scale(1.8);
}
0%, 100% { opacity: 1; transform: translateY(-50%) scale(1); }
50% { opacity: 0.4; transform: translateY(-50%) scale(1.8); }
}
@keyframes fadeIn {
0% { opacity: 0; transform: translateY(8px); }
0% { opacity: 0; transform: translateY(6px); }
100% { opacity: 1; transform: translateY(0); }
}
@keyframes slideUp {
0% { transform: translateY(12px); opacity: 0; }
0% { transform: translateY(10px); opacity: 0; }
100% { transform: translateY(0); opacity: 1; }
}
@@ -244,3 +492,83 @@
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes flashGreen {
0% { background-color: rgba(52, 199, 89, 0.25); }
100% { background-color: transparent; }
}
@keyframes flashRed {
0% { background-color: rgba(255, 59, 48, 0.25); }
100% { background-color: transparent; }
}
@keyframes barSlideIn {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
/* ─── Interactivity ─── */
@layer utilities {
/* Card hover lift */
.card-interactive {
transition: transform 0.2s ease, box-shadow 0.2s ease, border-color 0.3s ease;
}
.card-interactive:hover {
transform: translateY(-2px) scale(1.01);
}
/* Value flash on change */
.flash-up {
animation: flashGreen 0.6s ease-out;
border-radius: 4px;
}
.flash-down {
animation: flashRed 0.6s ease-out;
border-radius: 4px;
}
/* Progress bar slide-in on mount */
.bar-animate-in {
transform-origin: left;
animation: barSlideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
/* Row hover highlight */
.row-hover {
transition: background-color 0.15s ease;
}
.row-hover:hover {
background-color: rgba(0, 122, 255, 0.04);
}
/* Glass skeleton shimmer */
.skeleton-glass {
@apply rounded-xl;
background: linear-gradient(
90deg,
rgba(255, 255, 255, 0.35) 0%,
rgba(255, 255, 255, 0.65) 50%,
rgba(255, 255, 255, 0.35) 100%
);
background-size: 200% 100%;
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
animation: shimmer 2s ease-in-out infinite;
}
/* Stagger entry animation */
.stagger-enter {
opacity: 0;
transform: translateY(8px);
transition: opacity 0.3s ease-out, transform 0.3s ease-out;
}
.stagger-enter.visible {
opacity: 1;
transform: translateY(0);
}
}
+24 -7
View File
@@ -1,16 +1,18 @@
import type { Metadata } from "next";
import { Inter, JetBrains_Mono } from "next/font/google";
import { IBM_Plex_Sans, IBM_Plex_Mono } from "next/font/google";
import "./globals.css";
const inter = Inter({
variable: "--font-inter",
const ibmPlexSans = IBM_Plex_Sans({
subsets: ["latin"],
weight: ["400", "500", "600", "700"],
variable: "--font-ibm-plex-sans",
display: "swap",
});
const jetbrainsMono = JetBrains_Mono({
variable: "--font-jetbrains",
const ibmPlexMono = IBM_Plex_Mono({
subsets: ["latin"],
weight: ["400", "700"],
variable: "--font-ibm-plex-mono",
display: "swap",
});
@@ -19,15 +21,30 @@ export const metadata: Metadata = {
description: "Real-time monitoring dashboard for XAUBOT AI Trading Bot",
};
// Inline script to prevent flash of wrong theme
const themeScript = `
(function() {
try {
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
document.documentElement.classList.add('dark');
}
} catch(e) {}
})();
`;
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" className="dark">
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body
className={`${inter.variable} ${jetbrainsMono.variable} antialiased bg-background text-foreground`}
className={`${ibmPlexSans.variable} ${ibmPlexMono.variable} antialiased bg-background text-foreground`}
>
{children}
</body>
+150 -122
View File
@@ -1,6 +1,7 @@
"use client";
import { useTradingData } from "@/hooks/use-trading-data";
import { useStaggerEntry } from "@/hooks/use-stagger-entry";
import {
Header,
PriceCard,
@@ -12,27 +13,37 @@ import {
PositionsCard,
LogCard,
PriceChart,
EquityChart,
BotStatusCard,
EntryFilterCard,
PerformanceCard,
ModelCard,
} from "@/components/dashboard";
import { Skeleton } from "@/components/ui/skeleton";
import { TooltipProvider } from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
function LoadingSkeleton() {
return (
<div className="flex-1 min-h-0 flex flex-col gap-1.5 p-1.5">
<div className="flex gap-1.5">
<div className="flex-[1] min-h-0 grid grid-cols-4 gap-1.5">
{[...Array(4)].map((_, i) => (
<Skeleton key={`r1-${i}`} className="flex-1 h-[80px] rounded-lg" />
<Skeleton key={i} className="rounded-lg" />
))}
</div>
<div className="flex gap-1.5">
<div className="flex-[1] min-h-0 grid grid-cols-4 gap-1.5">
{[...Array(4)].map((_, i) => (
<Skeleton key={`r2-${i}`} className="flex-1 h-[90px] rounded-lg" />
<Skeleton key={i} className="rounded-lg" />
))}
</div>
<div className="flex-1 min-h-0 flex gap-1.5">
<Skeleton className="flex-[3] rounded-lg" />
<Skeleton className="flex-1 rounded-lg" />
<div className="flex-[1.6] min-h-0 grid grid-cols-5 gap-1.5">
<Skeleton className="col-span-3 rounded-lg" />
<Skeleton className="col-span-2 rounded-lg" />
</div>
<div className="flex-[1.2] min-h-0 grid grid-cols-4 gap-1.5">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="rounded-lg" />
))}
</div>
</div>
);
@@ -41,13 +52,13 @@ function LoadingSkeleton() {
function ErrorDisplay({ message }: { message: string }) {
return (
<div className="flex-1 flex items-center justify-center">
<div className="text-center space-y-3">
<div className="w-12 h-12 rounded-full bg-danger-bg mx-auto flex items-center justify-center">
<span className="text-danger text-xl">!</span>
<div className="text-center space-y-4">
<div className="w-14 h-14 rounded-full bg-danger-bg mx-auto flex items-center justify-center">
<span className="text-danger text-2xl font-bold">!</span>
</div>
<p className="text-danger text-base font-semibold">Connection Error</p>
<p className="text-muted-foreground text-sm">{message}</p>
<p className="text-muted-foreground/60 text-xs">
<p className="text-danger text-lg font-semibold">Connection Error</p>
<p className="text-muted-foreground">{message}</p>
<p className="text-muted-foreground/60 text-sm">
Make sure the API server is running on port 8000
</p>
</div>
@@ -57,6 +68,7 @@ function ErrorDisplay({ message }: { message: string }) {
export default function Dashboard() {
const { data, loading, error, dataAge } = useTradingData();
const visible = useStaggerEntry(17, 40);
const now = new Date();
const wibTime = now.toLocaleTimeString("en-US", {
@@ -69,7 +81,7 @@ export default function Dashboard() {
if (loading && !data) {
return (
<div className="fixed inset-0 overflow-hidden flex flex-col bg-background">
<div className="h-screen flex flex-col overflow-hidden bg-background">
<Header connected={false} lastUpdate={wibTime} dataAge={999} />
<LoadingSkeleton />
</div>
@@ -78,7 +90,7 @@ export default function Dashboard() {
if (error && !data) {
return (
<div className="fixed inset-0 overflow-hidden flex flex-col bg-background">
<div className="h-screen flex flex-col overflow-hidden bg-background">
<Header connected={false} lastUpdate={wibTime} dataAge={999} />
<ErrorDisplay message={error} />
</div>
@@ -88,127 +100,143 @@ export default function Dashboard() {
if (!data) return null;
return (
<div className="fixed inset-0 overflow-hidden flex flex-col bg-background max-w-full">
<Header
connected={data.connected}
lastUpdate={wibTime}
dataAge={dataAge}
/>
<TooltipProvider delayDuration={200}>
<div className="h-screen flex flex-col overflow-hidden bg-background">
<Header
connected={data.connected}
lastUpdate={wibTime}
dataAge={dataAge}
/>
<main className="flex-1 min-h-0 flex flex-col gap-1.5 p-1.5 overflow-hidden">
{/* ── Row 1: Status ── */}
<div
className="grid gap-1.5 overflow-hidden"
style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}
>
<div className="min-w-0 overflow-hidden">
<PriceCard
price={data.price}
spread={data.spread}
priceChange={data.priceChange}
priceHistory={data.priceHistory}
/>
</div>
<div className="min-w-0 overflow-hidden">
<AccountCard
balance={data.balance}
equity={data.equity}
profit={data.profit}
equityHistory={data.equityHistory}
/>
</div>
<div className="min-w-0 overflow-hidden">
<SessionCard
session={data.session}
isGoldenTime={data.isGoldenTime}
canTrade={data.canTrade}
sessionMultiplier={data.sessionMultiplier}
timeFilter={data.timeFilter}
/>
</div>
<div className="min-w-0 overflow-hidden">
<RiskCard
dailyLoss={data.dailyLoss}
dailyProfit={data.dailyProfit}
consecutiveLosses={data.consecutiveLosses}
riskPercent={data.riskPercent}
riskMode={data.riskMode}
/>
</div>
</div>
{/* Main grid — fills remaining height, NO scroll */}
<main className="flex-1 min-h-0 flex flex-col gap-1.5 p-1.5">
{/* ── Row 2: Signals + Bot Status ── */}
<div
className="grid gap-1.5 overflow-hidden"
style={{ gridTemplateColumns: 'repeat(4, minmax(0, 1fr))' }}
>
<div className="min-w-0 overflow-hidden">
<SignalCard
title="SMC Signal"
icon="smc"
signal={data.smc.signal}
confidence={data.smc.confidence}
detail={`${data.smc.reason || ""}${data.h1Bias ? ` | H1: ${data.h1Bias}` : ""}`}
updatedAt={data.smc.updatedAt}
/>
{/* Row 1: Market Overview */}
<div className="flex-[1] min-h-0 grid grid-cols-4 gap-1.5">
<div className={cn("stagger-enter h-full", visible[0] && "visible")}>
<PriceCard
price={data.price}
spread={data.spread}
priceChange={data.priceChange}
priceHistory={data.priceHistory}
/>
</div>
<div className={cn("stagger-enter h-full", visible[1] && "visible")}>
<AccountCard
balance={data.balance}
equity={data.equity}
profit={data.profit}
equityHistory={data.equityHistory}
/>
</div>
<div className={cn("stagger-enter h-full", visible[2] && "visible")}>
<SessionCard
session={data.session}
isGoldenTime={data.isGoldenTime}
canTrade={data.canTrade}
sessionMultiplier={data.sessionMultiplier}
timeFilter={data.timeFilter}
/>
</div>
<div className={cn("stagger-enter h-full", visible[3] && "visible")}>
<RiskCard
dailyLoss={data.dailyLoss}
dailyProfit={data.dailyProfit}
consecutiveLosses={data.consecutiveLosses}
riskPercent={data.riskPercent}
riskMode={data.riskMode}
/>
</div>
</div>
<div className="min-w-0 overflow-hidden">
<SignalCard
title="ML Prediction"
icon="ml"
signal={data.ml.signal}
confidence={data.ml.confidence}
buyProb={data.ml.buyProb}
sellProb={data.ml.sellProb}
updatedAt={data.ml.updatedAt}
threshold={data.dynamicThreshold}
marketQuality={data.marketQuality}
/>
</div>
<div className="min-w-0 overflow-hidden">
<RegimeCard
name={data.regime.name}
volatility={data.regime.volatility}
confidence={data.regime.confidence}
updatedAt={data.regime.updatedAt}
h1Bias={data.h1Bias}
/>
</div>
<div className="min-w-0 overflow-hidden">
<BotStatusCard
riskMode={data.riskMode}
cooldown={data.cooldown}
autoTrainer={data.autoTrainer}
performance={data.performance}
marketClose={data.marketClose}
/>
</div>
</div>
{/* ── Row 3: Chart + Sidebar (fills remaining) ── */}
<div
className="flex-1 min-h-0 grid gap-1.5 overflow-hidden"
style={{ gridTemplateColumns: '3fr 1fr' }}
>
<div className="min-w-0 min-h-0 overflow-hidden">
<PriceChart data={data.priceHistory} />
{/* Row 2: AI Signals */}
<div className="flex-[1] min-h-0 grid grid-cols-5 gap-1.5">
<div className={cn("stagger-enter h-full", visible[4] && "visible")}>
<SignalCard
title="SMC Signal"
icon="smc"
signal={data.smc.signal}
confidence={data.smc.confidence}
detail={`${data.smc.reason || ""}${data.h1Bias ? ` | H1: ${data.h1Bias}` : ""}`}
updatedAt={data.smc.updatedAt}
/>
</div>
<div className={cn("stagger-enter h-full", visible[5] && "visible")}>
<SignalCard
title="ML Prediction"
icon="ml"
signal={data.ml.signal}
confidence={data.ml.confidence}
buyProb={data.ml.buyProb}
sellProb={data.ml.sellProb}
updatedAt={data.ml.updatedAt}
threshold={data.dynamicThreshold}
marketQuality={data.marketQuality}
/>
</div>
<div className={cn("stagger-enter h-full", visible[6] && "visible")}>
<RegimeCard
name={data.regime.name}
volatility={data.regime.volatility}
confidence={data.regime.confidence}
updatedAt={data.regime.updatedAt}
h1Bias={data.h1Bias}
/>
</div>
<div className={cn("stagger-enter h-full", visible[7] && "visible")}>
<PerformanceCard
marketScore={data.marketScore}
marketQuality={data.marketQuality}
dynamicThreshold={data.dynamicThreshold}
performance={data.performance}
riskMode={data.riskMode}
/>
</div>
<div className={cn("stagger-enter h-full", visible[8] && "visible")}>
<ModelCard />
</div>
</div>
<div className="min-w-0 min-h-0 overflow-hidden flex flex-col gap-1.5">
<div className="min-h-0" style={{ flex: '0 0 auto', maxHeight: '40%' }}>
{/* Row 3: Charts */}
<div className="flex-[1.6] min-h-0 grid grid-cols-5 gap-1.5">
<div className={cn("stagger-enter col-span-3 min-h-0", visible[9] && "visible")}>
<PriceChart data={data.priceHistory} />
</div>
<div className={cn("stagger-enter col-span-2 min-h-0", visible[10] && "visible")}>
<EquityChart
equityData={data.equityHistory}
balanceData={data.balanceHistory}
/>
</div>
</div>
{/* Row 4: Trading + Log */}
<div className="flex-[1.2] min-h-0 grid grid-cols-4 gap-1.5">
<div className={cn("stagger-enter h-full", visible[11] && "visible")}>
<EntryFilterCard filters={data.entryFilters || []} />
</div>
<div className="flex-1 min-h-0">
<div className={cn("stagger-enter h-full", visible[12] && "visible")}>
<PositionsCard
positions={data.positions}
positionDetails={data.positionDetails}
/>
</div>
<div className="flex-1 min-h-0">
<div className={cn("stagger-enter h-full", visible[13] && "visible")}>
<BotStatusCard
riskMode={data.riskMode}
cooldown={data.cooldown}
autoTrainer={data.autoTrainer}
performance={data.performance}
marketClose={data.marketClose}
settings={data.settings}
/>
</div>
<div className={cn("stagger-enter h-full", visible[14] && "visible")}>
<LogCard logs={data.logs} />
</div>
</div>
</div>
</main>
</div>
</main>
</div>
</TooltipProvider>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Trade History — XAUBOT AI",
description: "Complete trade history and performance analytics",
};
export default function TradesLayout({ children }: { children: React.ReactNode }) {
return children;
}
+314
View File
@@ -0,0 +1,314 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import {
ArrowLeft,
History,
TrendingUp,
TrendingDown,
Trophy,
BarChart3,
ChevronLeft,
ChevronRight,
Filter,
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { ThemeToggle } from "@/components/theme-toggle";
import { useTrades, useTradeStats, useEquityCurve } from "@/hooks/use-trades";
import { formatUSD } from "@/lib/utils";
import { format } from "date-fns";
import {
LineChart,
Line,
XAxis,
YAxis,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
function StatsCards({ startDate, endDate }: { startDate: string; endDate: string }) {
const { stats } = useTradeStats(startDate, endDate);
const cards = [
{
label: "Total Trades",
value: stats?.totalTrades ?? 0,
format: (v: number) => String(v),
icon: History,
color: "text-apple-blue",
accent: "accent-top-blue",
},
{
label: "Win Rate",
value: stats?.winRate ?? 0,
format: (v: number) => `${v.toFixed(1)}%`,
icon: Trophy,
color: "text-apple-green",
accent: "accent-top-green",
},
{
label: "Net Profit",
value: stats?.netProfit ?? 0,
format: (v: number) => formatUSD(v),
icon: TrendingUp,
color: (stats?.netProfit ?? 0) >= 0 ? "text-success" : "text-danger",
accent: (stats?.netProfit ?? 0) >= 0 ? "accent-top-green" : "accent-top-red",
},
{
label: "Profit Factor",
value: stats?.profitFactor ?? 0,
format: (v: number) => v.toFixed(2),
icon: BarChart3,
color: "text-apple-purple",
accent: "accent-top-purple",
},
];
return (
<div className="grid grid-cols-4 gap-3">
{cards.map((c) => (
<div key={c.label} className={`glass rounded-xl p-4 ${c.accent}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-xs text-muted-foreground font-medium">{c.label}</span>
<c.icon className={`h-4 w-4 ${c.color}`} />
</div>
<p className={`text-2xl font-bold font-number ${c.color}`}>
{c.format(c.value)}
</p>
</div>
))}
</div>
);
}
function EquityCurveChart({ startDate, endDate }: { startDate: string; endDate: string }) {
const { points, loading } = useEquityCurve(startDate, endDate);
if (loading || points.length === 0) {
return (
<div className="glass rounded-xl p-4 h-64 flex items-center justify-center text-muted-foreground text-sm">
{loading ? "Loading equity curve..." : "No trade data available"}
</div>
);
}
return (
<div className="glass rounded-xl p-4">
<h3 className="text-sm font-semibold mb-3">Equity Curve</h3>
<div className="h-56">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={points}>
<XAxis
dataKey="time"
tick={{ fontSize: 10 }}
tickFormatter={(v) => {
try { return format(new Date(v), "dd MMM"); } catch { return v; }
}}
stroke="var(--color-muted-foreground)"
tickLine={false}
axisLine={false}
/>
<YAxis
tick={{ fontSize: 10 }}
tickFormatter={(v) => `$${v}`}
stroke="var(--color-muted-foreground)"
tickLine={false}
axisLine={false}
width={60}
/>
<Tooltip
contentStyle={{
background: "var(--color-popover)",
border: "1px solid var(--color-border)",
borderRadius: 10,
fontSize: 12,
}}
formatter={(v: number) => [formatUSD(v), "Cumulative P/L"]}
labelFormatter={(v) => {
try { return format(new Date(v), "dd MMM yyyy HH:mm"); } catch { return v; }
}}
/>
<ReferenceLine y={0} stroke="var(--color-border)" strokeDasharray="3 3" />
<Line
type="monotone"
dataKey="cumulative"
stroke="var(--apple-blue)"
strokeWidth={2}
dot={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
</div>
);
}
function TradeTable({
filters,
setFilters,
}: {
filters: { page: number; limit: number; direction: string; startDate: string; endDate: string };
setFilters: React.Dispatch<React.SetStateAction<typeof filters>>;
}) {
const { trades, total, loading } = useTrades(filters);
const totalPages = Math.ceil(total / filters.limit) || 1;
return (
<div className="glass rounded-xl overflow-hidden">
{/* Filter bar */}
<div className="flex items-center gap-3 px-4 py-3 border-b border-border">
<Filter className="h-4 w-4 text-muted-foreground" />
<select
value={filters.direction}
onChange={(e) => setFilters((p) => ({ ...p, direction: e.target.value, page: 1 }))}
className="text-sm px-2 py-1 rounded-md bg-surface border border-border"
>
<option value="ALL">All Directions</option>
<option value="BUY">BUY Only</option>
<option value="SELL">SELL Only</option>
</select>
<input
type="date"
value={filters.startDate}
onChange={(e) => setFilters((p) => ({ ...p, startDate: e.target.value, page: 1 }))}
className="text-sm px-2 py-1 rounded-md bg-surface border border-border"
/>
<span className="text-xs text-muted-foreground">to</span>
<input
type="date"
value={filters.endDate}
onChange={(e) => setFilters((p) => ({ ...p, endDate: e.target.value, page: 1 }))}
className="text-sm px-2 py-1 rounded-md bg-surface border border-border"
/>
<span className="ml-auto text-xs text-muted-foreground font-number">
{total} trades
</span>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground">
<th className="text-left px-4 py-2 font-medium">Time</th>
<th className="text-left px-3 py-2 font-medium">Dir</th>
<th className="text-right px-3 py-2 font-medium">Entry</th>
<th className="text-right px-3 py-2 font-medium">Exit</th>
<th className="text-right px-3 py-2 font-medium">Lot</th>
<th className="text-right px-3 py-2 font-medium">P/L</th>
<th className="text-left px-3 py-2 font-medium">Exit Reason</th>
<th className="text-right px-3 py-2 font-medium">Conf</th>
<th className="text-left px-3 py-2 font-medium">Regime</th>
<th className="text-left px-3 py-2 font-medium">Session</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={10} className="text-center py-8 text-muted-foreground">Loading...</td>
</tr>
) : trades.length === 0 ? (
<tr>
<td colSpan={10} className="text-center py-8 text-muted-foreground">No trades found</td>
</tr>
) : (
trades.map((t) => (
<tr key={t.id} className="border-b border-border/50 row-hover">
<td className="px-4 py-2 font-number text-xs whitespace-nowrap">
{(() => {
try { return format(new Date(t.closed_at), "dd MMM HH:mm"); }
catch { return t.closed_at; }
})()}
</td>
<td className="px-3 py-2">
<Badge variant={t.direction === "BUY" ? "success" : "danger"} className="text-xs">
{t.direction}
</Badge>
</td>
<td className="px-3 py-2 text-right font-number">{t.entry_price?.toFixed(2)}</td>
<td className="px-3 py-2 text-right font-number">{t.exit_price?.toFixed(2)}</td>
<td className="px-3 py-2 text-right font-number">{t.lot_size?.toFixed(2)}</td>
<td className={`px-3 py-2 text-right font-number font-semibold ${t.profit_usd >= 0 ? "text-success" : "text-danger"}`}>
{formatUSD(t.profit_usd)}
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{t.exit_reason}</td>
<td className="px-3 py-2 text-right font-number">{(t.confidence * 100).toFixed(0)}%</td>
<td className="px-3 py-2 text-xs">{t.regime}</td>
<td className="px-3 py-2 text-xs">{t.session}</td>
</tr>
))
)}
</tbody>
</table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between px-4 py-3 border-t border-border">
<span className="text-xs text-muted-foreground">
Page {filters.page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setFilters((p) => ({ ...p, page: Math.max(1, p.page - 1) }))}
disabled={filters.page <= 1}
className="p-1.5 rounded-md hover:bg-surface disabled:opacity-30"
>
<ChevronLeft className="h-4 w-4" />
</button>
<button
onClick={() => setFilters((p) => ({ ...p, page: Math.min(totalPages, p.page + 1) }))}
disabled={filters.page >= totalPages}
className="p-1.5 rounded-md hover:bg-surface disabled:opacity-30"
>
<ChevronRight className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
export default function TradesPage() {
const [filters, setFilters] = useState({
page: 1,
limit: 25,
direction: "ALL",
startDate: "",
endDate: "",
});
return (
<div className="flex flex-col h-screen overflow-hidden">
{/* Header */}
<header className="shrink-0 w-full border-b border-border bg-white/70 dark:bg-white/[0.03] backdrop-blur-2xl">
<div className="flex h-11 items-center justify-between px-4">
<div className="flex items-center gap-3">
<Link
href="/"
className="flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface border border-border hover:border-primary/20 transition-colors text-sm text-muted-foreground hover:text-primary"
>
<ArrowLeft className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Dashboard</span>
</Link>
<div className="w-px h-5 bg-border" />
<History className="h-4 w-4 text-apple-blue" />
<h1 className="text-base font-bold">Trade History</h1>
</div>
<div className="flex items-center gap-2">
<ThemeToggle />
<span className="text-xs text-muted-foreground font-mono">XAUBOT AI</span>
</div>
</div>
</header>
{/* Content */}
<main className="flex-1 overflow-y-auto p-4 space-y-4">
<StatsCards startDate={filters.startDate} endDate={filters.endDate} />
<EquityCurveChart startDate={filters.startDate} endDate={filters.endDate} />
<TradeTable filters={filters} setFilters={setFilters} />
</main>
</div>
);
}
@@ -0,0 +1,130 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog";
import {
Bot,
Github,
Shield,
Brain,
TrendingUp,
Code2,
Scale,
} from "lucide-react";
export function AboutDialog({ children }: { children: React.ReactNode }) {
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-blue-500 to-purple-600 shadow-lg">
<Bot className="h-5 w-5 text-white" />
</div>
<div>
<DialogTitle className="text-xl">XAUBOT AI</DialogTitle>
<DialogDescription className="text-sm">
Smart Gold Trading Bot v2.0
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-5 pt-4">
{/* Description */}
<p className="text-sm text-muted-foreground leading-relaxed">
Bot trading XAUUSD (Emas) otomatis berbasis AI yang menggabungkan{" "}
<strong className="text-foreground">XGBoost Machine Learning</strong>,{" "}
<strong className="text-foreground">Smart Money Concepts</strong> (SMC), dan{" "}
<strong className="text-foreground">Hidden Markov Model</strong> untuk deteksi regime pasar pada
MetaTrader 5.
</p>
{/* Tech Stack */}
<div className="grid grid-cols-2 gap-2">
{[
{ icon: Brain, label: "XGBoost ML", desc: "37-fitur prediksi sinyal" },
{ icon: TrendingUp, label: "Smart Money", desc: "OB, FVG, BOS, CHoCH" },
{ icon: Shield, label: "HMM Regime", desc: "3-state deteksi pasar" },
{ icon: Code2, label: "Polars Engine", desc: "Data processing cepat" },
].map((item) => (
<div
key={item.label}
className="flex items-start gap-2.5 p-2.5 rounded-lg bg-surface-light border border-border"
>
<item.icon className="h-4 w-4 text-primary mt-0.5 shrink-0" />
<div>
<p className="text-xs font-semibold">{item.label}</p>
<p className="text-[11px] text-muted-foreground">{item.desc}</p>
</div>
</div>
))}
</div>
{/* Author */}
<div className="p-3.5 rounded-xl bg-gradient-to-r from-blue-50 to-purple-50 dark:from-blue-950/30 dark:to-purple-950/30 border border-blue-100/60 dark:border-blue-800/30">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-blue-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
GK
</div>
<div>
<p className="text-sm font-semibold">Gifari Kemal</p>
<p className="text-xs text-muted-foreground">Developer & Maintainer</p>
</div>
<a
href="https://github.com/GifariKemal/xaubot-ai"
target="_blank"
rel="noopener noreferrer"
className="ml-auto flex items-center gap-1.5 px-2.5 py-1 rounded-lg bg-surface border border-border hover:border-primary/30 text-xs text-muted-foreground hover:text-primary transition-colors"
>
<Github className="h-3.5 w-3.5" />
GitHub
</a>
</div>
</div>
{/* Legal / License */}
<div className="space-y-3 pt-1">
<div className="flex items-start gap-2.5">
<Scale className="h-4 w-4 text-muted-foreground mt-0.5 shrink-0" />
<div>
<p className="text-xs font-semibold">MIT License</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
Perangkat lunak sumber terbuka bebas digunakan, dimodifikasi, dan
didistribusikan sesuai ketentuan lisensi MIT.
</p>
</div>
</div>
<div className="flex items-start gap-2.5">
<Shield className="h-4 w-4 text-amber-500 mt-0.5 shrink-0" />
<div>
<p className="text-xs font-semibold">Disclaimer</p>
<p className="text-[11px] text-muted-foreground leading-relaxed">
Perangkat lunak ini dibuat <strong className="text-foreground">hanya untuk tujuan edukasi dan riset</strong>.
Trading dengan margin memiliki risiko tinggi. Kinerja masa lalu bukan
indikasi hasil di masa depan. Gunakan dengan risiko Anda sendiri.
</p>
</div>
</div>
</div>
{/* Footer */}
<div className="pt-3 border-t border-border flex items-center justify-between">
<p className="text-[11px] text-muted-foreground">
&copy; 20252026 Gifari Kemal. All rights reserved.
</p>
<p className="text-[11px] text-muted-foreground font-mono">v2.0</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,170 @@
"use client";
import { Children, isValidElement, useMemo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { MermaidDiagram } from "./mermaid-diagram";
interface MarkdownRendererProps {
content: string;
}
function getTextContent(node: React.ReactNode): string {
if (typeof node === "string") return node;
if (typeof node === "number") return String(node);
if (!node) return "";
if (Array.isArray(node)) return node.map(getTextContent).join("");
if (isValidElement(node) && (node.props as Record<string, unknown>)?.children)
return getTextContent((node.props as Record<string, unknown>).children as React.ReactNode);
return "";
}
const components: Components = {
// ── Headings ──
h1: ({ children }) => (
<h1 className="text-[2rem] font-bold text-slate-800 mt-10 mb-5 pb-3 border-b-2 border-blue-200/60 first:mt-0">
{children}
</h1>
),
h2: ({ children }) => (
<h2 className="text-[1.6rem] font-semibold text-slate-800 mt-9 mb-4 pb-2 border-b border-slate-200/80">
{children}
</h2>
),
h3: ({ children }) => (
<h3 className="text-[1.35rem] font-semibold text-slate-700 mt-6 mb-3 pl-3 border-l-3 border-blue-400/40">
{children}
</h3>
),
h4: ({ children }) => (
<h4 className="text-[1.15rem] font-semibold text-slate-700 mt-5 mb-2">
{children}
</h4>
),
// ── Paragraphs & text ──
p: ({ children }) => (
<p className="text-[1.05rem] leading-[1.8] text-slate-600 mb-4">
{children}
</p>
),
strong: ({ children }) => (
<strong className="font-semibold text-slate-800">{children}</strong>
),
em: ({ children }) => (
<em className="italic text-slate-500">{children}</em>
),
// ── Lists ──
ul: ({ children }) => (
<ul className="list-disc list-outside ml-5 mb-4 space-y-1.5 text-[1.05rem] text-slate-600">
{children}
</ul>
),
ol: ({ children }) => (
<ol className="list-decimal list-outside ml-5 mb-4 space-y-1.5 text-[1.05rem] text-slate-600">
{children}
</ol>
),
li: ({ children }) => <li className="leading-[1.7] pl-1">{children}</li>,
// ── Links ──
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:text-blue-800 underline underline-offset-2 decoration-blue-300 hover:decoration-blue-500 transition-colors"
>
{children}
</a>
),
// ── Blockquotes ──
blockquote: ({ children }) => (
<blockquote className="border-l-[3px] border-amber-400/60 pl-4 py-2 my-4 bg-amber-50/40 rounded-r-lg">
<div className="text-slate-600 [&>p]:text-slate-600 [&>p]:mb-1">{children}</div>
</blockquote>
),
// ── Code blocks — with Mermaid detection ──
pre: ({ children }) => {
const child = Children.only(children);
const childProps = isValidElement(child) ? (child.props as Record<string, unknown>) : null;
if (childProps?.className === "language-mermaid") {
const chart = getTextContent(childProps.children as React.ReactNode);
return <MermaidDiagram chart={chart} />;
}
return (
<pre className="my-4 p-4 rounded-xl bg-slate-50/80 border border-slate-200/80 overflow-x-auto text-[0.95rem] leading-relaxed">
{children}
</pre>
);
},
code: ({ className, children }) => {
const isBlock = className?.startsWith("language-");
if (isBlock) {
return (
<code className="block font-mono text-slate-700">{children}</code>
);
}
return (
<code className="px-1.5 py-0.5 rounded-md bg-blue-50/70 text-blue-700 font-mono text-[0.95rem] border border-blue-100/50">
{children}
</code>
);
},
// ── Tables — Excel-style ──
table: ({ children }) => (
<div className="my-5 overflow-x-auto rounded-xl border border-slate-200 shadow-sm">
<table className="w-full text-[1rem] border-collapse">{children}</table>
</div>
),
thead: ({ children }) => (
<thead className="bg-gradient-to-b from-slate-100 to-slate-50 border-b-2 border-slate-200">
{children}
</thead>
),
th: ({ children }) => (
<th className="px-4 py-2.5 text-left font-semibold text-slate-700 text-[0.95rem] uppercase tracking-wide border-r border-slate-200/60 last:border-r-0">
{children}
</th>
),
tbody: ({ children }) => <tbody className="divide-y divide-slate-100">{children}</tbody>,
tr: ({ children }) => (
<tr className="hover:bg-blue-50/30 transition-colors even:bg-slate-50/40">
{children}
</tr>
),
td: ({ children }) => (
<td className="px-4 py-2.5 text-slate-600 border-r border-slate-100/80 last:border-r-0 align-top">
{children}
</td>
),
// ── Horizontal rule ──
hr: () => (
<hr className="my-8 border-0 h-px bg-gradient-to-r from-transparent via-slate-300 to-transparent" />
),
// ── Images (placeholder) ──
img: ({ alt }) => (
<span className="block my-4 text-center text-slate-400 text-sm italic">
[{alt || "image"}]
</span>
),
};
export function MarkdownRenderer({ content }: MarkdownRendererProps) {
const processedContent = useMemo(() => content, [content]);
return (
<div className="max-w-none prose-slate">
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
{processedContent}
</ReactMarkdown>
</div>
);
}
@@ -0,0 +1,281 @@
"use client";
import { useEffect, useRef, useState, useCallback } from "react";
import {
ZoomIn,
ZoomOut,
Maximize2,
Move,
RotateCcw,
} from "lucide-react";
interface MermaidDiagramProps {
chart: string;
}
const MIN_ZOOM = 0.3;
const MAX_ZOOM = 3;
const ZOOM_STEP = 0.2;
export function MermaidDiagram({ chart }: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement>(null);
const viewportRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>("");
const [error, setError] = useState<string>("");
// Transform state
const [zoom, setZoom] = useState(1);
const [pan, setPan] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
useEffect(() => {
let cancelled = false;
async function render() {
try {
const mermaid = (await import("mermaid")).default;
mermaid.initialize({
startOnLoad: false,
theme: "base",
themeVariables: {
primaryColor: "#e8f0fe",
primaryTextColor: "#1d1d1f",
primaryBorderColor: "#007AFF",
lineColor: "#86868b",
secondaryColor: "#f0f7ee",
tertiaryColor: "#fef7e0",
fontFamily: "IBM Plex Sans, system-ui, sans-serif",
fontSize: "13px",
nodeBorder: "#007AFF",
mainBkg: "#e8f0fe",
clusterBkg: "#f5f5f7",
clusterBorder: "#d1d1d6",
titleColor: "#1d1d1f",
edgeLabelBackground: "#ffffff",
},
flowchart: {
htmlLabels: true,
curve: "basis",
padding: 12,
},
});
const id = `mermaid-${Math.random().toString(36).slice(2, 9)}`;
const { svg: renderedSvg } = await mermaid.render(id, chart.trim());
if (!cancelled) {
setSvg(renderedSvg);
setError("");
}
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Diagram error");
}
}
}
render();
return () => {
cancelled = true;
};
}, [chart]);
// Reset when chart changes
useEffect(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, [chart]);
const handleZoomIn = useCallback(() => {
setZoom((z) => Math.min(MAX_ZOOM, z + ZOOM_STEP));
}, []);
const handleZoomOut = useCallback(() => {
setZoom((z) => Math.max(MIN_ZOOM, z - ZOOM_STEP));
}, []);
const handleReset = useCallback(() => {
setZoom(1);
setPan({ x: 0, y: 0 });
}, []);
const handleFitToView = useCallback(() => {
if (!containerRef.current || !viewportRef.current) return;
const viewport = viewportRef.current.getBoundingClientRect();
const svgEl = containerRef.current.querySelector("svg");
if (!svgEl) return;
const svgW = svgEl.getBoundingClientRect().width / zoom;
const svgH = svgEl.getBoundingClientRect().height / zoom;
if (svgW === 0 || svgH === 0) return;
const fitZoom = Math.min(
(viewport.width - 32) / svgW,
(viewport.height - 32) / svgH,
MAX_ZOOM
);
setZoom(Math.max(MIN_ZOOM, fitZoom));
setPan({ x: 0, y: 0 });
}, [zoom]);
// Mouse wheel zoom
const handleWheel = useCallback((e: React.WheelEvent) => {
e.preventDefault();
const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP;
setZoom((z) => Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z + delta)));
}, []);
// Drag to pan
const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
if (e.button !== 0) return;
setIsDragging(true);
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
},
[pan]
);
const handleMouseMove = useCallback(
(e: React.MouseEvent) => {
if (!isDragging) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
},
[isDragging]
);
const handleMouseUp = useCallback(() => {
setIsDragging(false);
}, []);
// Touch support for mobile
const touchStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
const handleTouchStart = useCallback(
(e: React.TouchEvent) => {
if (e.touches.length === 1) {
const t = e.touches[0];
setIsDragging(true);
touchStart.current = { x: t.clientX, y: t.clientY, panX: pan.x, panY: pan.y };
}
},
[pan]
);
const handleTouchMove = useCallback(
(e: React.TouchEvent) => {
if (!isDragging || e.touches.length !== 1) return;
const t = e.touches[0];
const dx = t.clientX - touchStart.current.x;
const dy = t.clientY - touchStart.current.y;
setPan({ x: touchStart.current.panX + dx, y: touchStart.current.panY + dy });
},
[isDragging]
);
const handleTouchEnd = useCallback(() => {
setIsDragging(false);
}, []);
if (error) {
return (
<div className="my-4 p-4 rounded-xl bg-red-50 border border-red-200 text-sm text-red-700">
<p className="font-medium mb-1">Diagram Error</p>
<pre className="text-xs whitespace-pre-wrap">{error}</pre>
<details className="mt-2">
<summary className="cursor-pointer text-xs text-red-500">Source</summary>
<pre className="mt-1 text-xs whitespace-pre-wrap text-red-600">{chart}</pre>
</details>
</div>
);
}
if (!svg) {
return (
<div className="my-4 flex items-center justify-center p-8 rounded-xl bg-slate-50 border border-slate-200">
<div className="animate-pulse text-sm text-slate-400">Rendering diagram...</div>
</div>
);
}
const zoomPercent = Math.round(zoom * 100);
return (
<div className="my-4 rounded-xl bg-white/60 border border-slate-200 overflow-hidden">
{/* Toolbar */}
<div className="flex items-center justify-between px-3 py-1.5 bg-slate-50/80 border-b border-slate-200/60">
<div className="flex items-center gap-1">
<button
onClick={handleZoomOut}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Zoom out"
>
<ZoomOut className="h-3.5 w-3.5" />
</button>
<span className="text-[11px] font-mono text-slate-400 w-10 text-center select-none">
{zoomPercent}%
</span>
<button
onClick={handleZoomIn}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Zoom in"
>
<ZoomIn className="h-3.5 w-3.5" />
</button>
<div className="w-px h-4 bg-slate-200 mx-1" />
<button
onClick={handleFitToView}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Fit to view"
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
<button
onClick={handleReset}
className="p-1.5 rounded-md hover:bg-slate-200/60 text-slate-500 hover:text-slate-700 transition-colors"
title="Reset"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center gap-1.5 text-[10px] text-slate-400">
<Move className="h-3 w-3" />
<span>Drag untuk geser, scroll untuk zoom</span>
</div>
</div>
{/* Viewport */}
<div
ref={viewportRef}
className="relative overflow-hidden"
style={{ height: "clamp(200px, 50vh, 600px)", cursor: isDragging ? "grabbing" : "grab" }}
onWheel={handleWheel}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
<div
ref={containerRef}
className="flex justify-center items-start min-w-full min-h-full p-4 select-none"
style={{
transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`,
transformOrigin: "center top",
transition: isDragging ? "none" : "transform 0.15s ease-out",
}}
dangerouslySetInnerHTML={{ __html: svg }}
/>
</div>
</div>
);
}
@@ -1,9 +1,12 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Wallet } from "lucide-react";
import { Sparkline } from "./sparkline";
import { cn, formatUSD, getValueColor } from "@/lib/utils";
import { cn, formatUSD } from "@/lib/utils";
import { useAnimatedValue } from "@/hooks/use-animated-value";
interface AccountCardProps {
balance: number;
@@ -14,41 +17,135 @@ interface AccountCardProps {
export function AccountCard({ balance, equity, profit, equityHistory = [] }: AccountCardProps) {
const isProfit = profit >= 0;
const animBalance = useAnimatedValue(balance);
const animEquity = useAnimatedValue(equity);
const animProfit = useAnimatedValue(profit);
const margin = balance > 0 ? ((equity / balance) * 100) : 100;
const drawdown = equityHistory.length > 0 ? Math.max(...equityHistory) - equity : 0;
return (
<Card className="glass">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Wallet className="h-3.5 w-3.5" />
Account
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">Balance</span>
<span className="text-sm font-semibold font-number">{formatUSD(balance)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">Equity</span>
<span className="text-sm font-semibold font-number">{formatUSD(equity)}</span>
</div>
<div className="flex justify-between items-center pt-1 border-t border-border">
<span className="text-[11px] text-muted-foreground">P/L</span>
<span className={cn("text-base font-bold font-number", getValueColor(profit))}>
{isProfit ? "+" : ""}{formatUSD(profit)}
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Card className={cn("glass h-full overflow-hidden flex flex-col cursor-pointer", isProfit ? "accent-top-green glass-green" : "accent-top-red glass-red")}>
<CardHeader>
<CardTitle className={cn(
"text-sm font-medium flex items-center gap-1.5 uppercase tracking-wider",
isProfit ? "text-apple-green" : "text-apple-red"
)}>
<Wallet className="h-4 w-4" />
Account
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 flex flex-col justify-between">
<div className="space-y-1">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Balance</span>
<span
key={animBalance.changeKey}
className={cn(
"text-base font-semibold font-number text-foreground",
animBalance.direction === "up" && "flash-up",
animBalance.direction === "down" && "flash-down"
)}
>
{formatUSD(animBalance.displayValue)}
</span>
</div>
</TooltipTrigger>
<TooltipContent><p>Click card for detail</p></TooltipContent>
</Tooltip>
{equityHistory.length > 2 && (
<div className="-mx-1">
<Sparkline
data={equityHistory.slice(-30)}
color={isProfit ? "#22c55e" : "#ef4444"}
height={20}
/>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Equity</span>
<span
key={animEquity.changeKey}
className={cn(
"text-base font-semibold font-number text-apple-cyan",
animEquity.direction === "up" && "flash-up",
animEquity.direction === "down" && "flash-down"
)}
>
{formatUSD(animEquity.displayValue)}
</span>
</div>
</div>
<div className="flex justify-between items-center pt-1.5 border-t border-border">
<span className="text-sm text-muted-foreground">P/L</span>
<span
key={animProfit.changeKey}
className={cn(
"text-xl font-bold font-number",
isProfit ? "text-success" : "text-danger",
animProfit.direction === "up" && "flash-up",
animProfit.direction === "down" && "flash-down"
)}
>
{animProfit.displayValue >= 0 ? "+" : ""}{formatUSD(animProfit.displayValue)}
</span>
</div>
{equityHistory.length > 2 && (
<div className="-mx-1 mt-auto">
<Sparkline data={equityHistory.slice(-30)} color={isProfit ? "#34C759" : "#FF3B30"} height={18} />
</div>
)}
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className={cn("flex items-center gap-2", isProfit ? "text-apple-green" : "text-apple-red")}>
<Wallet className="h-5 w-5" />
Account Detail
</DialogTitle>
<DialogDescription>Balance, equity, and performance metrics</DialogDescription>
</DialogHeader>
<div className="pt-4 space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Balance</span>
<p className="text-2xl font-bold font-number">{formatUSD(balance)}</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Equity</span>
<p className="text-2xl font-bold font-number text-apple-cyan">{formatUSD(equity)}</p>
</div>
</div>
)}
</CardContent>
</Card>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Floating P/L</span>
<p className={cn("text-xl font-bold font-number", isProfit ? "text-success" : "text-danger")}>
{profit >= 0 ? "+" : ""}{formatUSD(profit)}
</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Margin Level</span>
<p className={cn("text-xl font-bold font-number", margin >= 100 ? "text-apple-green" : "text-apple-red")}>
{margin.toFixed(1)}%
</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Drawdown</span>
<p className={cn("text-xl font-bold font-number", drawdown > 0 ? "text-apple-red" : "text-muted-foreground")}>
{formatUSD(drawdown)}
</p>
</div>
</div>
{equityHistory.length > 2 && (
<div>
<span className="text-sm text-muted-foreground mb-1 block">Equity Curve ({equityHistory.length} points)</span>
<Sparkline data={equityHistory} color={isProfit ? "#34C759" : "#FF3B30"} height={80} />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -2,9 +2,11 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Activity, Timer, Brain, Gauge, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
import type { RiskMode, CooldownStatus, AutoTrainerStatus, PerformanceStatus, MarketCloseStatus } from "@/types/trading";
import type { RiskMode, CooldownStatus, AutoTrainerStatus, PerformanceStatus, MarketCloseStatus, BotSettings } from "@/types/trading";
interface BotStatusCardProps {
riskMode?: RiskMode;
@@ -12,103 +14,181 @@ interface BotStatusCardProps {
autoTrainer?: AutoTrainerStatus;
performance?: PerformanceStatus;
marketClose?: MarketCloseStatus;
settings?: BotSettings;
}
function getRiskModeVariant(mode: string) {
switch (mode) {
case "normal": return "success";
case "recovery": return "warning";
case "protected": return "danger";
case "stopped": return "danger";
case "protected": case "stopped": return "danger";
default: return "secondary";
}
}
export function BotStatusCard({ riskMode, cooldown, autoTrainer, performance, marketClose }: BotStatusCardProps) {
export function BotStatusCard({ riskMode, cooldown, autoTrainer, performance, marketClose, settings }: BotStatusCardProps) {
const mode = riskMode?.mode || "unknown";
const aucColor = (autoTrainer?.currentAuc ?? 0) >= 0.7 ? "text-success" : (autoTrainer?.currentAuc ?? 0) >= 0.65 ? "text-warning" : "text-danger";
const aucColor = (autoTrainer?.currentAuc ?? 0) >= 0.7 ? "text-apple-green" : (autoTrainer?.currentAuc ?? 0) >= 0.65 ? "text-apple-orange" : "text-apple-red";
return (
<Card className="glass">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Activity className="h-3.5 w-3.5" />
Bot Status
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
{/* Risk Mode */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Risk Mode</span>
<Badge
variant={getRiskModeVariant(mode) as "success" | "warning" | "danger" | "secondary"}
className={cn("text-[10px] h-4 px-1.5 uppercase", mode === "stopped" && "animate-pulse")}
>
{mode}
</Badge>
</div>
<Dialog>
<DialogTrigger asChild>
<Card className="glass h-full overflow-hidden flex flex-col accent-top-blue glass-blue cursor-pointer">
<CardHeader>
<CardTitle className="text-sm font-medium text-apple-blue flex items-center gap-1.5 uppercase tracking-wider">
<Activity className="h-4 w-4" />
Bot Status
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-auto space-y-1">
<div className="flex items-center justify-between rounded px-1 -mx-1 row-hover">
<span className="text-sm text-muted-foreground">Mode</span>
<Badge variant={getRiskModeVariant(mode) as any} className={cn("text-xs h-5 px-1.5 uppercase", mode === "stopped" && "animate-pulse")}>{mode}</Badge>
</div>
{/* Cooldown */}
<div className="space-y-0.5">
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Timer className="h-2.5 w-2.5" />
Cooldown
</span>
<span className={cn("text-[10px] font-number", cooldown?.active ? "text-warning" : "text-muted-foreground/60")}>
{cooldown?.active ? `${cooldown.secondsRemaining}s` : "Ready"}
</span>
<div className="flex items-center justify-between rounded px-1 -mx-1 row-hover">
<span className="text-sm text-muted-foreground flex items-center gap-1"><Timer className="h-3.5 w-3.5 text-apple-orange" />Cooldown</span>
<span className={cn("text-sm font-number", cooldown?.active ? "text-apple-orange font-semibold" : "text-muted-foreground/60")}>
{cooldown?.active ? `${cooldown.secondsRemaining}s` : "Ready"}
</span>
</div>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center justify-between cursor-help rounded px-1 -mx-1 row-hover">
<span className="text-sm text-muted-foreground flex items-center gap-1"><Brain className="h-3.5 w-3.5 text-apple-purple" />AUC</span>
<span className={cn("text-sm font-bold font-number", aucColor)}>
{autoTrainer?.currentAuc != null ? autoTrainer.currentAuc.toFixed(3) : "N/A"}
</span>
</div>
</TooltipTrigger>
<TooltipContent><p>Model accuracy. &gt;0.70 good</p></TooltipContent>
</Tooltip>
<div className="flex items-center justify-between rounded px-1 -mx-1 row-hover">
<span className="text-sm text-muted-foreground flex items-center gap-1"><Gauge className="h-3.5 w-3.5 text-apple-cyan" />Uptime</span>
<span className="text-sm font-number text-apple-cyan">{performance ? `${performance.uptimeHours}h` : "\u2014"}</span>
</div>
<div className="flex items-center justify-between rounded px-1 -mx-1 row-hover">
<span className="text-sm text-muted-foreground">Speed</span>
<span className={cn("text-sm font-number", (performance?.avgExecutionMs ?? 0) > 50 ? "text-apple-orange" : "text-apple-green")}>
{performance ? `${performance.avgExecutionMs}ms` : "\u2014"}
</span>
</div>
<div className="flex items-center justify-between rounded px-1 -mx-1 row-hover">
<span className="text-sm text-muted-foreground flex items-center gap-1"><Clock className="h-3.5 w-3.5 text-apple-blue" />Close</span>
<span className={cn("text-sm font-number", marketClose?.nearWeekend ? "text-apple-orange font-bold" : "text-muted-foreground")}>
{marketClose ? `D:${marketClose.hoursToDailyClose}h W:${marketClose.hoursToWeekendClose}h` : "\u2014"}
</span>
</div>
{settings && (
<div className="pt-1 border-t border-border space-y-0.5">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Capital</span>
<span className="font-number font-semibold text-apple-green">${settings.capital.toLocaleString()}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Risk</span>
<span className="font-number text-apple-orange">{settings.riskPerTrade}% | R:R 1:{settings.minRR}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">TF</span>
<span className="font-number text-apple-blue">{settings.executionTF}/{settings.trendTF}</span>
</div>
</div>
)}
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-apple-blue">
<Activity className="h-5 w-5" />
Bot Status Detail
</DialogTitle>
<DialogDescription>Full bot operational status and configuration</DialogDescription>
</DialogHeader>
<div className="pt-4 space-y-4">
<div className="flex items-center gap-3">
<Badge variant={getRiskModeVariant(mode) as any} className="text-sm px-3 py-1 uppercase">{mode}</Badge>
{cooldown?.active && <Badge variant="warning" className="text-sm px-3 py-1">Cooldown {cooldown.secondsRemaining}s</Badge>}
</div>
{cooldown?.active && (
<div className="h-1 w-full bg-surface-light rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-warning transition-all duration-1000"
style={{ width: `${cooldown.totalSeconds > 0 ? ((cooldown.totalSeconds - cooldown.secondsRemaining) / cooldown.totalSeconds) * 100 : 0}%` }}
/>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Model AUC</span>
<p className={cn("text-xl font-bold font-number", aucColor)}>
{autoTrainer?.currentAuc != null ? autoTrainer.currentAuc.toFixed(4) : "N/A"}
</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Uptime</span>
<p className="text-xl font-bold font-number text-apple-cyan">{performance?.uptimeHours ?? 0}h</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Avg Execution</span>
<p className={cn("text-xl font-bold font-number", (performance?.avgExecutionMs ?? 0) > 50 ? "text-apple-orange" : "text-apple-green")}>
{performance?.avgExecutionMs ?? 0}ms
</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Session Trades</span>
<p className="text-xl font-bold font-number text-apple-blue">{performance?.totalSessionTrades ?? 0}</p>
</div>
</div>
{marketClose && (
<div className="pt-3 border-t border-border">
<span className="text-sm text-muted-foreground mb-2 block">Market Close</span>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Daily Close</span>
<p className="text-lg font-bold font-number">{marketClose.hoursToDailyClose}h</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Weekend Close</span>
<p className={cn("text-lg font-bold font-number", marketClose.nearWeekend ? "text-apple-orange" : "text-foreground")}>
{marketClose.hoursToWeekendClose}h
{marketClose.nearWeekend && " (NEAR)"}
</p>
</div>
</div>
</div>
)}
{settings && (
<div className="pt-3 border-t border-border">
<span className="text-sm text-muted-foreground mb-2 block">Configuration</span>
<div className="grid grid-cols-2 gap-x-8 gap-y-1 text-sm">
<div className="flex justify-between"><span className="text-muted-foreground">Capital</span><span className="font-semibold font-number">${settings.capital.toLocaleString()}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Mode</span><span className="font-semibold uppercase">{settings.capitalMode}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Risk/Trade</span><span className="font-semibold font-number">{settings.riskPerTrade}%</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Max Daily Loss</span><span className="font-semibold font-number">{settings.maxDailyLoss}%</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Timeframes</span><span className="font-semibold font-number">{settings.executionTF}/{settings.trendTF}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Leverage</span><span className="font-semibold font-number">1:{settings.leverage}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Max Lot</span><span className="font-semibold font-number">{settings.maxLotSize}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Max Positions</span><span className="font-semibold font-number">{settings.maxPositions}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Min R:R</span><span className="font-semibold font-number">1:{settings.minRR}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">ML Confidence</span><span className="font-semibold font-number">{(settings.mlConfidence * 100).toFixed(0)}%</span></div>
</div>
</div>
)}
{autoTrainer && (
<div className="pt-3 border-t border-border">
<span className="text-sm text-muted-foreground mb-2 block">Auto-Trainer</span>
<div className="grid grid-cols-2 gap-4 text-sm">
<div className="flex justify-between"><span className="text-muted-foreground">Current AUC</span><span className={cn("font-bold font-number", aucColor)}>{autoTrainer.currentAuc?.toFixed(4) ?? "N/A"}</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Last Train</span><span className="font-number">{autoTrainer.lastRetrain ?? "Never"}</span></div>
</div>
</div>
)}
</div>
{/* Auto Trainer */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Brain className="h-2.5 w-2.5" />
Model AUC
</span>
<span className={cn("text-[10px] font-bold font-number", aucColor)}>
{autoTrainer?.currentAuc != null ? autoTrainer.currentAuc.toFixed(3) : "N/A"}
</span>
</div>
{/* Performance */}
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Gauge className="h-2.5 w-2.5" />
Uptime
</span>
<span className="text-[10px] font-number text-foreground">
{performance ? `${performance.uptimeHours}h | ${performance.loopCount} loops` : "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">Exec Speed</span>
<span className={cn("text-[10px] font-number", (performance?.avgExecutionMs ?? 0) > 50 ? "text-warning" : "text-success")}>
{performance ? `${performance.avgExecutionMs}ms` : "—"}
</span>
</div>
{/* Market Close */}
<div className="pt-0.5 border-t border-border flex items-center justify-between">
<span className="text-[10px] text-muted-foreground flex items-center gap-1">
<Clock className="h-2.5 w-2.5" />
Close
</span>
<span className={cn("text-[10px] font-number", marketClose?.nearWeekend ? "text-warning font-bold" : "text-muted-foreground")}>
{marketClose ? `D:${marketClose.hoursToDailyClose}h W:${marketClose.hoursToWeekendClose}h` : "—"}
</span>
</div>
</CardContent>
</Card>
</DialogContent>
</Dialog>
);
}
@@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Filter, Check, X, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
import type { EntryFilter } from "@/types/trading";
@@ -14,21 +15,19 @@ export function EntryFilterCard({ filters }: EntryFilterCardProps) {
const passedCount = filters.filter((f) => f.passed).length;
const totalCount = filters.length;
const hasBlocker = filters.some((f) => !f.passed);
// Find the first blocker index — filters after it were not evaluated
const firstBlockerIdx = filters.findIndex((f) => !f.passed);
return (
<Card className="glass h-full flex flex-col">
<Card className={cn("glass h-full overflow-hidden flex flex-col", hasBlocker ? "accent-top-red" : "accent-top-green")}>
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Filter className="h-3.5 w-3.5" />
Entry Filters
<CardTitle className={cn(
"text-sm font-medium flex items-center gap-1.5 uppercase tracking-wider",
hasBlocker ? "text-apple-red" : "text-apple-green"
)}>
<Filter className="h-4 w-4" />
Filters
{totalCount > 0 && (
<Badge
variant={hasBlocker ? "danger" : "success"}
className="ml-auto text-[10px] h-4 px-1.5"
>
<Badge variant={hasBlocker ? "danger" : "success"} className="ml-auto text-xs h-5 px-1.5">
{passedCount}/{totalCount}
</Badge>
)}
@@ -36,46 +35,42 @@ export function EntryFilterCard({ filters }: EntryFilterCardProps) {
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-auto">
{totalCount === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<Minus className="h-4 w-4 text-muted-foreground/30 mb-1" />
<p className="text-[10px] text-muted-foreground/60">Waiting for candle...</p>
<div className="flex flex-col items-center justify-center h-full">
<Minus className="h-5 w-5 text-muted-foreground/30 mb-1" />
<p className="text-sm text-muted-foreground/60">Waiting...</p>
</div>
) : (
<div className="space-y-0.5">
{filters.map((filter, idx) => {
// Determine status: passed, blocked, or not evaluated
const isNotEvaluated = firstBlockerIdx >= 0 && idx > firstBlockerIdx;
const isBlocker = !filter.passed && idx === firstBlockerIdx;
return (
<div
key={`${filter.name}-${idx}`}
className={cn(
"flex items-center gap-1.5 px-1.5 py-0.5 rounded text-[10px]",
isBlocker && "bg-danger/10",
isNotEvaluated && "opacity-40"
)}
>
{isNotEvaluated ? (
<Minus className="h-2.5 w-2.5 text-muted-foreground/40 flex-shrink-0" />
) : filter.passed ? (
<Check className="h-2.5 w-2.5 text-success flex-shrink-0" />
) : (
<X className="h-2.5 w-2.5 text-danger flex-shrink-0" />
)}
<span className={cn(
"truncate flex-1",
isBlocker ? "text-danger font-semibold" : "text-muted-foreground"
)}>
{filter.name}
</span>
<span className={cn(
"text-[9px] truncate max-w-[80px]",
isBlocker ? "text-danger" : "text-muted-foreground/60"
)}>
{filter.detail}
</span>
</div>
<Tooltip key={`${filter.name}-${idx}`}>
<TooltipTrigger asChild>
<div className={cn(
"flex items-center gap-1.5 px-2 py-0.5 rounded text-sm",
isBlocker && "bg-danger/8 border-l-2 border-l-apple-red",
isNotEvaluated && "opacity-40",
filter.passed && !isNotEvaluated && "border-l-2 border-l-apple-green/30",
!isBlocker && !isNotEvaluated && "row-hover"
)}>
{isNotEvaluated ? (
<Minus className="h-3.5 w-3.5 text-muted-foreground/40 flex-shrink-0" />
) : filter.passed ? (
<Check className="h-3.5 w-3.5 text-apple-green flex-shrink-0" />
) : (
<X className="h-3.5 w-3.5 text-apple-red flex-shrink-0" />
)}
<span className={cn("truncate flex-1", isBlocker ? "text-apple-red font-semibold" : filter.passed ? "text-foreground/80" : "text-muted-foreground")}>
{filter.name}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="right">
<p className="max-w-[220px]">{filter.detail || (filter.passed ? "Passed" : "Blocked")}</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
@@ -1,7 +1,8 @@
"use client";
import { useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, Tooltip, CartesianGrid } from "recharts";
import { Wallet } from "lucide-react";
interface EquityChartProps {
@@ -9,75 +10,119 @@ interface EquityChartProps {
balanceData: number[];
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
timeZone: "Asia/Jakarta",
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
}
export function EquityChart({ equityData, balanceData }: EquityChartProps) {
const chartData = equityData.map((equity, i) => ({
index: i,
equity,
balance: balanceData[i] || equity,
}));
const chartData = useMemo(() => {
if (equityData.length === 0) return [];
const now = Date.now();
const totalMs = 2 * 60 * 60 * 1000;
const step = equityData.length > 1 ? totalMs / (equityData.length - 1) : 0;
return equityData.map((equity, i) => {
const ts = new Date(now - totalMs + i * step);
return {
time: formatTime(ts),
timestamp: ts.getTime(),
equity,
balance: balanceData[i] || equity,
};
});
}, [equityData, balanceData]);
const tickIndices = useMemo(() => {
if (chartData.length <= 6) return chartData.map((d) => d.timestamp);
const step = Math.floor(chartData.length / 5);
const ticks: number[] = [];
for (let i = 0; i < chartData.length; i += step) {
ticks.push(chartData[i].timestamp);
}
if (ticks[ticks.length - 1] !== chartData[chartData.length - 1].timestamp) {
ticks.push(chartData[chartData.length - 1].timestamp);
}
return ticks;
}, [chartData]);
return (
<Card className="glass">
<Card className="glass h-full overflow-hidden flex flex-col accent-top-green">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Wallet className="h-3.5 w-3.5" />
Equity vs Balance (2H)
<CardTitle className="text-sm font-medium text-apple-green flex items-center gap-1.5 uppercase tracking-wider">
<Wallet className="h-4 w-4" />
Equity (2H)
{equityData.length > 0 && (
<span className="ml-auto text-xs font-number text-success">
<span className="ml-auto text-base font-number text-apple-green font-bold">
${equityData[equityData.length - 1]?.toFixed(2)}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[100px] w-full">
{equityData.length > 1 ? (
<CardContent className="flex-1 min-h-0 pb-1">
<div className="h-full w-full">
{chartData.length > 1 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<XAxis dataKey="index" hide />
<YAxis domain={["auto", "auto"]} hide />
<AreaChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0, 0, 0, 0.06)" />
<XAxis
dataKey="timestamp"
type="number"
domain={["dataMin", "dataMax"]}
ticks={tickIndices}
tickFormatter={(ts: number) => formatTime(new Date(ts))}
tick={{ fontSize: 11, fill: "#86868b", fontFamily: "var(--font-mono)" }}
axisLine={{ stroke: "rgba(0,0,0,0.08)" }}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={["auto", "auto"]}
tick={{ fontSize: 11, fill: "#86868b", fontFamily: "var(--font-mono)" }}
axisLine={false}
tickLine={false}
width={58}
tickFormatter={(v: number) => `$${v.toFixed(0)}`}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--color-card)",
border: "1px solid var(--color-border)",
borderRadius: "6px",
fontSize: "11px",
backgroundColor: "rgba(255, 255, 255, 0.85)",
border: "1px solid rgba(52, 199, 89, 0.2)",
borderRadius: "12px",
fontSize: "13px",
fontFamily: "var(--font-mono)",
color: "#1d1d1f",
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.08)",
backdropFilter: "blur(20px)",
padding: "6px 10px",
}}
labelStyle={{ display: "none" }}
labelFormatter={(ts: number) => formatTime(new Date(ts))}
formatter={(value: number, name: string) => [
`$${value.toFixed(2)}`,
name === "equity" ? "Equity" : "Balance",
]}
cursor={{ stroke: "#34C759", strokeDasharray: "3 3" }}
/>
<defs>
<linearGradient id="equityGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22c55e" stopOpacity={0.2} />
<stop offset="95%" stopColor="#22c55e" stopOpacity={0} />
<stop offset="0%" stopColor="#34C759" stopOpacity={0.25} />
<stop offset="40%" stopColor="#34C759" stopOpacity={0.08} />
<stop offset="100%" stopColor="#34C759" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="balance"
stroke="#555"
strokeWidth={1}
strokeDasharray="4 4"
fill="none"
/>
<Area
type="monotone"
dataKey="equity"
stroke="#22c55e"
strokeWidth={1.5}
fill="url(#equityGradient)"
/>
<Area type="monotone" dataKey="balance" stroke="#AF52DE" strokeWidth={1.5} strokeDasharray="4 4" fill="none" dot={false} isAnimationActive={false} />
<Area type="monotone" dataKey="equity" stroke="#34C759" strokeWidth={2.5} fill="url(#equityGradient)" dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50">
<div className="text-center space-y-1">
<Wallet className="h-5 w-5 mx-auto opacity-30" />
<p className="text-xs">Collecting data...</p>
<Wallet className="h-6 w-6 mx-auto opacity-30" />
<p className="text-base">Collecting data...</p>
</div>
</div>
)}
@@ -1,8 +1,11 @@
"use client";
import Link from "next/link";
import { Badge } from "@/components/ui/badge";
import { Bot, Wifi, WifiOff, Clock } from "lucide-react";
import { ThemeToggle } from "@/components/theme-toggle";
import { Bot, Wifi, WifiOff, Clock, BookOpen, Info, History, FlaskConical, Bell } from "lucide-react";
import { cn } from "@/lib/utils";
import { AboutDialog } from "@/components/about-dialog";
interface HeaderProps {
connected: boolean;
@@ -10,6 +13,13 @@ interface HeaderProps {
dataAge: number;
}
const navLinks = [
{ href: "/trades", icon: History, label: "Trades" },
{ href: "/backtests", icon: FlaskConical, label: "Backtests" },
{ href: "/alerts", icon: Bell, label: "Alerts" },
{ href: "/books", icon: BookOpen, label: "Docs" },
];
export function Header({ connected, lastUpdate, dataAge }: HeaderProps) {
const getDataStatus = () => {
if (dataAge > 45) return { label: "OFFLINE", variant: "danger" as const, dot: "bg-danger" };
@@ -20,37 +30,64 @@ export function Header({ connected, lastUpdate, dataAge }: HeaderProps) {
const status = getDataStatus();
return (
<header className="sticky top-0 z-50 w-full border-b border-border bg-background/80 backdrop-blur-xl">
<div className="flex h-10 items-center justify-between px-3">
{/* Brand */}
<div className="flex items-center gap-2.5">
<div className="flex items-center justify-center w-7 h-7 rounded-lg bg-primary/10">
<Bot className="h-4 w-4 text-primary" />
<header className="shrink-0 w-full border-b border-border bg-white/70 dark:bg-white/[0.03] backdrop-blur-2xl"
style={{ borderImage: "linear-gradient(90deg, rgba(0,122,255,0.2), rgba(175,82,222,0.2), rgba(52,199,89,0.2)) 1" }}
>
<div className="flex h-9 items-center justify-between px-3">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-6 h-6 rounded-md bg-primary/10 border border-primary/20">
<Bot className="h-3.5 w-3.5 text-primary" />
</div>
<h1 className="text-base font-bold text-gradient">XAUBOT AI</h1>
<span className="text-[10px] text-muted-foreground font-medium uppercase tracking-widest hidden sm:block">
Monitor
</span>
<Link href="/">
<h1 className="text-base font-bold text-gradient">XAUBOT AI</h1>
</Link>
<span className="text-xs text-muted-foreground/50 font-number">v2.0</span>
<div className="w-px h-4 bg-border mx-1" />
{navLinks.map((link) => (
<Link
key={link.href}
href={link.href}
className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-white/40 dark:bg-white/[0.06] backdrop-blur-sm border border-white/30 dark:border-white/10 hover:border-primary/20 transition-colors text-sm text-muted-foreground hover:text-primary"
title={link.label}
>
<link.icon className="h-3.5 w-3.5" />
<span className="hidden lg:inline">{link.label}</span>
</Link>
))}
</div>
{/* Status */}
<div className="flex items-center gap-2">
<Badge variant={status.variant} className="gap-1.5 font-number text-[11px]">
<span className={cn("w-1.5 h-1.5 rounded-full", status.dot)} />
<ThemeToggle />
<AboutDialog>
<button
className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-white/40 dark:bg-white/[0.06] backdrop-blur-sm border border-white/30 dark:border-white/10 hover:border-primary/20 transition-colors text-sm text-muted-foreground hover:text-primary"
title="About XAUBOT AI"
>
<Info className="h-3.5 w-3.5" />
<span className="hidden sm:inline">About</span>
</button>
</AboutDialog>
<Badge variant={status.variant} className="gap-1.5 font-number text-sm h-6">
<span className={cn(
"w-2 h-2 rounded-full",
status.dot
)} />
{status.label}
</Badge>
<Badge variant={connected ? "success" : "danger"} className="gap-1.5 text-[11px] hidden sm:inline-flex">
{connected ? <Wifi className="h-3 w-3" /> : <WifiOff className="h-3 w-3" />}
{connected ? "Connected" : "Disconnected"}
<Badge variant={connected ? "success" : "danger"} className="gap-1.5 text-sm h-6">
{connected ? <Wifi className="h-3.5 w-3.5" /> : <WifiOff className="h-3.5 w-3.5" />}
{connected ? "MT5" : "OFF"}
</Badge>
<div className="hidden md:flex items-center gap-1.5 px-2.5 py-1 rounded-md bg-surface border border-border text-[11px]">
<Clock className="h-3 w-3 text-muted-foreground" />
<span className="font-number font-medium">
{lastUpdate || "--:--:--"}
</span>
<span className="text-muted-foreground">WIB</span>
<div className="flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-white/40 dark:bg-white/[0.06] backdrop-blur-sm border border-white/30 dark:border-white/10 text-sm">
<Clock className="h-3.5 w-3.5 text-apple-cyan" />
<span className="font-number font-medium">{lastUpdate || "--:--:--"}</span>
<span className="text-muted-foreground text-xs">WIB</span>
</div>
</div>
</div>
@@ -13,3 +13,6 @@ export { Sparkline } from "./sparkline";
export { SettingsCard } from "./settings-card";
export { BotStatusCard } from "./bot-status-card";
export { EntryFilterCard } from "./entry-filter-card";
export { PerformanceCard } from "./performance-card";
export { ModelCard } from "./model-card";
export { ModelDialog } from "./model-dialog";
@@ -11,10 +11,10 @@ interface LogCardProps {
export function LogCard({ logs }: LogCardProps) {
const getLevelColor = (level: string) => {
switch (level) {
case "error": return "text-danger";
case "warn": return "text-warning";
case "trade": return "text-info";
default: return "text-success";
case "error": return "text-apple-red";
case "warn": return "text-apple-orange";
case "trade": return "text-apple-blue";
default: return "text-apple-green";
}
};
@@ -28,25 +28,23 @@ export function LogCard({ logs }: LogCardProps) {
};
return (
<Card className="glass h-full flex flex-col">
<Card className="glass h-full overflow-hidden flex flex-col accent-top-purple">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Terminal className="h-3.5 w-3.5" />
<CardTitle className="text-sm font-medium text-apple-purple flex items-center gap-1.5 uppercase tracking-wider">
<Terminal className="h-4 w-4" />
Activity
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<div className="h-full overflow-auto rounded-md bg-background/60 p-2 font-mono text-[10px] leading-relaxed">
<div className="h-full overflow-auto rounded-md bg-white/40 backdrop-blur-sm p-2 font-mono text-sm leading-relaxed border border-apple-purple/10">
{logs.length === 0 ? (
<p className="text-muted-foreground/60">Waiting for activity...</p>
) : (
<div className="space-y-0.5">
{logs.map((log, i) => (
<div key={i} className="flex gap-1.5">
<span className="text-muted-foreground/60 shrink-0">{log.time}</span>
<span className={`font-semibold shrink-0 ${getLevelColor(log.level)}`}>
{getLevelBadge(log.level)}
</span>
<div key={i} className="flex gap-1.5 rounded px-1 -mx-1 row-hover">
<span className="text-muted-foreground/50 shrink-0">{log.time}</span>
<span className={`font-semibold shrink-0 ${getLevelColor(log.level)}`}>{getLevelBadge(log.level)}</span>
<span className="text-foreground/70 truncate">{log.message}</span>
</div>
))}
@@ -0,0 +1,45 @@
"use client";
import { Brain } from "lucide-react";
import { Card, CardHeader, CardTitle, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { useModelMetrics } from "@/hooks/use-model-insights";
import { ModelDialog } from "./model-dialog";
export function ModelCard() {
const { metrics, loading } = useModelMetrics();
const auc = metrics?.testAuc ?? 0;
const topFeature = metrics?.featureImportance?.[0]?.name ?? "—";
const aucColor = auc >= 0.8 ? "text-success" : auc >= 0.7 ? "text-apple-blue" : auc >= 0.6 ? "text-warning" : "text-danger";
const aucBadge = auc >= 0.8 ? "success" : auc >= 0.7 ? "info" : auc >= 0.6 ? "warning" : "danger";
return (
<ModelDialog>
<Card className="glass glass-purple h-full cursor-pointer">
<CardHeader className="flex-row items-center justify-between space-y-0">
<CardTitle className="flex items-center gap-1.5 text-sm">
<Brain className="h-3.5 w-3.5 text-apple-purple" />
Model
</CardTitle>
<Badge variant={aucBadge as "success" | "info" | "warning" | "danger"} className="text-xs">
AUC {loading ? "..." : auc.toFixed(2)}
</Badge>
</CardHeader>
<CardContent>
<div className="space-y-1">
<p className={`text-xl font-bold font-number ${aucColor}`}>
{loading ? "—" : `${(auc * 100).toFixed(1)}%`}
</p>
<p className="text-xs text-muted-foreground truncate">
Top: {topFeature}
</p>
<p className="text-[10px] text-muted-foreground">
{metrics?.sampleCount ? `${metrics.sampleCount.toLocaleString()} samples` : "Click for details"}
</p>
</div>
</CardContent>
</Card>
</ModelDialog>
);
}
@@ -0,0 +1,182 @@
"use client";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog";
import { Badge } from "@/components/ui/badge";
import { Brain, BarChart3, Clock, Layers } from "lucide-react";
import {
useModelMetrics,
useTrainingHistory,
useRegimeDistribution,
} from "@/hooks/use-model-insights";
import { formatUSD } from "@/lib/utils";
function FeatureImportanceBars() {
const { metrics, loading } = useModelMetrics();
if (loading || !metrics?.featureImportance?.length) {
return <p className="text-sm text-muted-foreground">No feature data available</p>;
}
const features = metrics.featureImportance.slice(0, 15);
const max = Math.max(...features.map((f) => f.importance));
return (
<div className="space-y-1.5">
{features.map((f, i) => (
<div key={f.name} className="flex items-center gap-2 text-xs">
<span className="w-32 text-muted-foreground truncate text-right">{f.name}</span>
<div className="flex-1 h-3 bg-surface-light rounded-full overflow-hidden">
<div
className="h-full rounded-full bar-purple bar-animate-in"
style={{ width: `${(f.importance / max) * 100}%`, animationDelay: `${i * 30}ms` }}
/>
</div>
<span className="w-10 text-right font-number">{(f.importance * 100).toFixed(1)}</span>
</div>
))}
</div>
);
}
function RegimePie() {
const { distribution, loading } = useRegimeDistribution();
const colors: Record<string, string> = {
trending: "bg-apple-green",
ranging: "bg-apple-blue",
volatile: "bg-apple-red",
};
if (loading || distribution.length === 0) {
return <p className="text-sm text-muted-foreground">No regime data</p>;
}
const total = distribution.reduce((s, d) => s + d.count, 0);
return (
<div className="space-y-2">
{distribution.map((d) => (
<div key={d.regime} className="flex items-center gap-2 text-sm">
<span className={`w-3 h-3 rounded-full ${colors[d.regime] ?? "bg-muted"}`} />
<span className="capitalize">{d.regime}</span>
<span className="ml-auto font-number text-muted-foreground">
{total > 0 ? `${((d.count / total) * 100).toFixed(1)}%` : "0%"}
</span>
<span className="font-number w-10 text-right">{d.count}</span>
</div>
))}
</div>
);
}
function TrainingHistory() {
const { runs, loading } = useTrainingHistory();
if (loading || runs.length === 0) {
return <p className="text-sm text-muted-foreground">No training history</p>;
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border text-muted-foreground">
<th className="text-left px-2 py-1.5 font-medium">Time</th>
<th className="text-right px-2 py-1.5 font-medium">Train AUC</th>
<th className="text-right px-2 py-1.5 font-medium">Test AUC</th>
<th className="text-right px-2 py-1.5 font-medium">Samples</th>
<th className="text-left px-2 py-1.5 font-medium">Trigger</th>
</tr>
</thead>
<tbody>
{runs.slice(0, 10).map((r) => (
<tr key={r.id} className="border-b border-border/50 row-hover">
<td className="px-2 py-1.5 font-number">
{r.started_at ? new Date(r.started_at).toLocaleString("en-GB", { day: "2-digit", month: "short", hour: "2-digit", minute: "2-digit" }) : "—"}
</td>
<td className="px-2 py-1.5 text-right font-number">{r.train_auc?.toFixed(3) ?? "—"}</td>
<td className="px-2 py-1.5 text-right font-number">{r.test_auc?.toFixed(3) ?? "—"}</td>
<td className="px-2 py-1.5 text-right font-number">{r.sample_count?.toLocaleString() ?? "—"}</td>
<td className="px-2 py-1.5 text-muted-foreground">{r.trigger_reason ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
export function ModelDialog({ children }: { children: React.ReactNode }) {
const { metrics } = useModelMetrics();
return (
<Dialog>
<DialogTrigger asChild>{children}</DialogTrigger>
<DialogContent className="max-w-2xl max-h-[90vh]">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-gradient-to-br from-purple-500 to-pink-600 shadow-lg">
<Brain className="h-5 w-5 text-white" />
</div>
<div>
<DialogTitle className="text-xl">Model Insights</DialogTitle>
<DialogDescription>
XGBoost model performance & feature analysis
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-6 pt-4 overflow-y-auto">
{/* Quick stats */}
<div className="grid grid-cols-4 gap-2">
{[
{ label: "Train AUC", value: metrics?.trainAuc?.toFixed(3) ?? "—", color: "text-apple-blue" },
{ label: "Test AUC", value: metrics?.testAuc?.toFixed(3) ?? "—", color: "text-apple-green" },
{ label: "Samples", value: metrics?.sampleCount?.toLocaleString() ?? "—", color: "text-apple-purple" },
{ label: "Updated", value: metrics?.updatedAt ? new Date(metrics.updatedAt).toLocaleDateString() : "—", color: "text-muted-foreground" },
].map((s) => (
<div key={s.label} className="p-2.5 rounded-lg bg-surface-light border border-border">
<p className="text-[10px] text-muted-foreground">{s.label}</p>
<p className={`text-sm font-bold font-number ${s.color}`}>{s.value}</p>
</div>
))}
</div>
{/* Feature importance */}
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
<BarChart3 className="h-4 w-4 text-apple-purple" />
Feature Importance (Top 15)
</h3>
<FeatureImportanceBars />
</div>
{/* Regime distribution */}
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
<Layers className="h-4 w-4 text-apple-blue" />
Regime Distribution (7d)
</h3>
<RegimePie />
</div>
{/* Training history */}
<div>
<h3 className="text-sm font-semibold mb-3 flex items-center gap-1.5">
<Clock className="h-4 w-4 text-apple-cyan" />
Training History
</h3>
<TrainingHistory />
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,194 @@
"use client";
import { useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { BarChart3, Target, Zap, TrendingUp } from "lucide-react";
import { cn, formatUSD } from "@/lib/utils";
import { useAnimatedValue } from "@/hooks/use-animated-value";
import type { PerformanceStatus, RiskMode } from "@/types/trading";
interface PerformanceCardProps {
marketScore?: number;
marketQuality?: string;
dynamicThreshold?: number;
performance?: PerformanceStatus;
riskMode?: RiskMode;
}
function getQualityVariant(quality: string): "success" | "warning" | "danger" | "info" | "secondary" {
switch (quality?.toUpperCase()) {
case "EXCELLENT": return "success";
case "GOOD": return "info";
case "MODERATE": return "warning";
case "POOR": case "AVOID": return "danger";
default: return "secondary";
}
}
function getScoreColor(score: number) {
if (score >= 80) return "text-apple-green";
if (score >= 65) return "text-apple-blue";
if (score >= 50) return "text-apple-orange";
return "text-apple-red";
}
function getScoreBarClass(score: number) {
if (score >= 80) return "bar-green";
if (score >= 65) return "bar-blue";
if (score >= 50) return "bar-orange";
return "bar-red";
}
export function PerformanceCard({ marketScore = 0, marketQuality = "unknown", dynamicThreshold = 0, performance, riskMode }: PerformanceCardProps) {
const firstMount = useRef(true);
const animScore = useAnimatedValue(marketScore);
const shouldAnimate = firstMount.current;
if (firstMount.current) firstMount.current = false;
return (
<Dialog>
<DialogTrigger asChild>
<Card className="glass h-full overflow-hidden flex flex-col accent-top-orange glass-orange cursor-pointer">
<CardHeader>
<CardTitle className="text-sm font-medium text-apple-orange flex items-center gap-1.5 uppercase tracking-wider">
<BarChart3 className="h-4 w-4" />
Performance
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 flex flex-col justify-between">
<div className="space-y-1">
<div className="flex items-center justify-between">
<Tooltip>
<TooltipTrigger asChild>
<span className="text-sm text-muted-foreground flex items-center gap-1 cursor-help">
<Target className="h-3.5 w-3.5 text-apple-orange" />
Score
</span>
</TooltipTrigger>
<TooltipContent><p>Click for detail</p></TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
<span
key={animScore.changeKey}
className={cn(
"text-xl font-bold font-number",
getScoreColor(marketScore),
animScore.direction === "up" && "flash-up",
animScore.direction === "down" && "flash-down"
)}
>
{animScore.displayValue.toFixed(0)}
</span>
<span className="text-xs text-muted-foreground">/100</span>
</div>
</div>
<div className="h-2.5 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
getScoreBarClass(marketScore),
shouldAnimate && "bar-animate-in"
)}
style={{ width: `${Math.min(animScore.displayValue, 100)}%` }}
/>
</div>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Quality</span>
<Badge variant={getQualityVariant(marketQuality)} className="text-xs h-5 px-1.5 uppercase">{marketQuality}</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-1"><Zap className="h-3.5 w-3.5 text-apple-cyan" />Threshold</span>
<span className="text-sm font-bold font-number text-apple-cyan">{(dynamicThreshold * 100).toFixed(0)}%</span>
</div>
</div>
<div className="pt-1 border-t border-border space-y-0.5">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground flex items-center gap-1"><TrendingUp className="h-3.5 w-3.5 text-apple-blue" />Trades</span>
<span className="text-sm font-semibold font-number text-apple-blue">{performance?.totalSessionTrades ?? 0}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">P&L</span>
<span className={cn("text-sm font-semibold font-number", (performance?.totalSessionProfit ?? 0) >= 0 ? "text-apple-green" : "text-apple-red")}>
{formatUSD(performance?.totalSessionProfit ?? 0)}
</span>
</div>
{riskMode && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Lot</span>
<span className="text-sm font-semibold font-number text-apple-purple">{riskMode.recommendedLot}</span>
</div>
)}
</div>
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-apple-orange">
<BarChart3 className="h-5 w-5" />
Performance Detail
</DialogTitle>
<DialogDescription>Market quality scoring and session trading stats</DialogDescription>
</DialogHeader>
<div className="pt-4 space-y-4">
<div className="flex items-center gap-3">
<span className={cn("text-4xl font-bold font-number", getScoreColor(marketScore))}>{marketScore}</span>
<span className="text-muted-foreground text-lg">/100</span>
<Badge variant={getQualityVariant(marketQuality)} className="text-sm px-3 py-1 uppercase">{marketQuality}</Badge>
</div>
<div className="h-3 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all", getScoreBarClass(marketScore))} style={{ width: `${Math.min(marketScore, 100)}%` }} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Dynamic Threshold</span>
<p className="text-xl font-bold font-number text-apple-cyan">{(dynamicThreshold * 100).toFixed(1)}%</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Session Trades</span>
<p className="text-xl font-bold font-number text-apple-blue">{performance?.totalSessionTrades ?? 0}</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Session P/L</span>
<p className={cn("text-xl font-bold font-number", (performance?.totalSessionProfit ?? 0) >= 0 ? "text-apple-green" : "text-apple-red")}>
{formatUSD(performance?.totalSessionProfit ?? 0)}
</p>
</div>
{riskMode && (
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Recommended Lot</span>
<p className="text-xl font-bold font-number text-apple-purple">{riskMode.recommendedLot}</p>
</div>
)}
</div>
{performance && (
<div className="pt-3 border-t border-border space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Uptime</span>
<span className="font-semibold font-number text-apple-cyan">{performance.uptimeHours}h</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Avg Execution</span>
<span className={cn("font-semibold font-number", performance.avgExecutionMs > 50 ? "text-apple-orange" : "text-apple-green")}>
{performance.avgExecutionMs}ms
</span>
</div>
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -3,6 +3,7 @@
import { useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Layers, Inbox, ChevronDown, ChevronUp } from "lucide-react";
import { cn } from "@/lib/utils";
import type { Position, PositionDetail } from "@/types/trading";
@@ -14,111 +15,74 @@ interface PositionsCardProps {
export function PositionsCard({ positions, positionDetails }: PositionsCardProps) {
const [expandedTicket, setExpandedTicket] = useState<number | null>(null);
const getDetail = (ticket: number) =>
positionDetails?.find((d) => d.ticket === ticket);
const getDetail = (ticket: number) => positionDetails?.find((d) => d.ticket === ticket);
return (
<Card className="glass h-full flex flex-col">
<Card className="glass h-full overflow-hidden flex flex-col accent-top-cyan glass-cyan">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Layers className="h-3.5 w-3.5" />
<CardTitle className="text-sm font-medium text-apple-cyan flex items-center gap-1.5 uppercase tracking-wider">
<Layers className="h-4 w-4" />
Positions
{positions.length > 0 && (
<Badge variant="secondary" className="ml-auto text-[10px] h-4 px-1.5">
{positions.length}
</Badge>
<Badge variant="info" className="ml-auto text-xs h-5 px-1.5">{positions.length}</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 overflow-auto">
{positions.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-center">
<div className="flex flex-col items-center justify-center h-full">
<Inbox className="h-5 w-5 text-muted-foreground/30 mb-1" />
<p className="text-[11px] text-muted-foreground/60">No open positions</p>
<p className="text-sm text-muted-foreground/60">No open positions</p>
</div>
) : (
<div className="space-y-1">
{positions.map((pos) => {
const detail = getDetail(pos.ticket);
const isExpanded = expandedTicket === pos.ticket;
const hasDetail = !!detail;
return (
<div key={pos.ticket}>
<div
className={cn(
"flex items-center justify-between p-1.5 rounded-md bg-surface-light/50",
pos.type === "BUY" ? "border-l-2 border-l-success" : "border-l-2 border-l-danger",
hasDetail && "cursor-pointer hover:bg-surface-light/80"
"flex items-center justify-between p-1.5 rounded-md bg-black/[0.03] row-hover",
pos.type === "BUY" ? "border-l-2 border-l-apple-green" : "border-l-2 border-l-apple-red",
detail && "cursor-pointer"
)}
onClick={() => hasDetail && setExpandedTicket(isExpanded ? null : pos.ticket)}
onClick={() => detail && setExpandedTicket(isExpanded ? null : pos.ticket)}
>
<div className="flex items-center gap-1.5">
<Badge
variant={pos.type === "BUY" ? "success" : "danger"}
className="text-[10px] h-4 px-1"
>
{pos.type}
</Badge>
<span className="text-[11px] font-number">
{pos.volume} @ {pos.priceOpen.toFixed(2)}
</span>
<Badge variant={pos.type === "BUY" ? "success" : "danger"} className="text-xs h-5 px-1.5">{pos.type}</Badge>
<span className="text-sm font-number">{pos.volume} @ {pos.priceOpen.toFixed(2)}</span>
</div>
<div className="flex items-center gap-1">
<span className={cn(
"text-[11px] font-bold font-number",
pos.profit >= 0 ? "text-success" : "text-danger"
"text-base font-bold font-number",
pos.profit >= 0 ? "text-apple-green" : "text-apple-red"
)}>
{pos.profit >= 0 ? "+" : ""}${pos.profit.toFixed(2)}
</span>
{hasDetail && (
isExpanded
? <ChevronUp className="h-3 w-3 text-muted-foreground/40" />
: <ChevronDown className="h-3 w-3 text-muted-foreground/40" />
)}
{detail && (isExpanded ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground/40" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground/40" />)}
</div>
</div>
{/* Expandable Details */}
{isExpanded && detail && (
<div className="ml-2 mt-0.5 p-1.5 rounded bg-surface-light/30 space-y-0.5 text-[10px]">
<div className="ml-2 mt-0.5 p-1.5 rounded bg-black/[0.02] space-y-0.5 text-sm border-l border-l-apple-cyan/20">
<div className="flex justify-between">
<span className="text-muted-foreground">Peak Profit</span>
<span className="font-number text-success">${detail.peakProfit.toFixed(2)}</span>
<span className="text-muted-foreground">Peak</span>
<span className="font-number text-apple-green">${detail.peakProfit.toFixed(2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">DD from Peak</span>
<span className={cn("font-number", detail.drawdownFromPeak > 30 ? "text-danger" : "text-muted-foreground")}>
{detail.drawdownFromPeak.toFixed(1)}%
</span>
<span className="text-muted-foreground">DD</span>
<span className={cn("font-number", detail.drawdownFromPeak > 30 ? "text-apple-red" : "text-muted-foreground")}>{detail.drawdownFromPeak.toFixed(1)}%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Momentum</span>
<span className={cn("font-number", detail.momentum > 0 ? "text-success" : detail.momentum < 0 ? "text-danger" : "text-muted-foreground")}>
{detail.momentum > 0 ? "+" : ""}{detail.momentum}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">TP Probability</span>
<span className={cn("font-number", detail.tpProbability >= 50 ? "text-success" : "text-warning")}>
{detail.tpProbability}%
</span>
<span className="text-muted-foreground">TP Prob</span>
<span className={cn("font-number", detail.tpProbability >= 50 ? "text-apple-green" : "text-apple-orange")}>{detail.tpProbability}%</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Duration</span>
<span className="font-number text-muted-foreground">{detail.tradeHours}h</span>
<span className="font-number text-apple-purple">{detail.tradeHours}h</span>
</div>
{(detail.reversalWarnings > 0 || detail.stalls > 0) && (
<div className="flex gap-2 pt-0.5 border-t border-border/50">
{detail.reversalWarnings > 0 && (
<span className="text-warning">Rev: {detail.reversalWarnings}</span>
)}
{detail.stalls > 0 && (
<span className="text-muted-foreground">Stalls: {detail.stalls}</span>
)}
</div>
)}
</div>
)}
</div>
@@ -1,9 +1,12 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { TrendingUp, TrendingDown } from "lucide-react";
import { Sparkline } from "./sparkline";
import { cn, formatGoldPrice, getValueColor } from "@/lib/utils";
import { useAnimatedValue } from "@/hooks/use-animated-value";
interface PriceCardProps {
price: number;
@@ -14,47 +17,125 @@ interface PriceCardProps {
export function PriceCard({ price, spread, priceChange, priceHistory = [] }: PriceCardProps) {
const isUp = priceChange >= 0;
const animPrice = useAnimatedValue(price);
const animChange = useAnimatedValue(priceChange);
const high = priceHistory.length > 0 ? Math.max(...priceHistory) : price;
const low = priceHistory.length > 0 ? Math.min(...priceHistory) : price;
const range = high - low;
return (
<Card className="glass">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground uppercase tracking-wider">
XAUUSD
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-baseline gap-1.5">
<span className={cn("text-2xl font-bold font-number", getValueColor(priceChange))}>
${formatGoldPrice(price)}
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Card className={cn("glass h-full overflow-hidden flex flex-col accent-top-blue cursor-pointer", isUp ? "glass-green" : "glass-red")}>
<CardHeader>
<CardTitle className="text-sm font-medium text-apple-blue flex items-center gap-1.5 uppercase tracking-wider">
<TrendingUp className="h-4 w-4" />
XAUUSD
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 flex flex-col justify-between">
<Tooltip>
<TooltipTrigger asChild>
<span
key={animPrice.changeKey}
className={cn(
"text-3xl font-bold font-number leading-tight",
isUp ? "text-success" : "text-danger",
animPrice.direction === "up" && "flash-up",
animPrice.direction === "down" && "flash-down"
)}
>
${formatGoldPrice(animPrice.displayValue)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Current XAUUSD spot price click for detail</p>
</TooltipContent>
</Tooltip>
<div className="flex items-center justify-between mt-1">
<div className="flex items-center gap-1">
{isUp ? (
<TrendingUp className="h-3 w-3 text-success" />
) : (
<TrendingDown className="h-3 w-3 text-danger" />
<div className="flex items-center justify-between">
<div className="flex items-center gap-1">
{isUp ? <TrendingUp className="h-4 w-4 text-success" /> : <TrendingDown className="h-4 w-4 text-danger" />}
<span
key={animChange.changeKey}
className={cn(
"text-base font-semibold font-number",
getValueColor(priceChange),
animChange.direction === "up" && "flash-up",
animChange.direction === "down" && "flash-down"
)}
>
{animChange.displayValue >= 0 ? "+" : ""}{animChange.displayValue.toFixed(2)}
</span>
</div>
<span className="text-sm text-muted-foreground font-number">{spread.toFixed(1)}p</span>
</div>
{priceHistory.length > 2 && (
<div className="-mx-1 mt-auto">
<Sparkline data={priceHistory.slice(-30)} color={isUp ? "#34C759" : "#FF3B30"} height={20} />
</div>
)}
<span className={cn("text-xs font-medium font-number", getValueColor(priceChange))}>
{isUp ? "+" : ""}{priceChange.toFixed(2)}
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-apple-blue">
<TrendingUp className="h-5 w-5" />
XAUUSD Price Detail
</DialogTitle>
<DialogDescription>Live gold price and recent history</DialogDescription>
</DialogHeader>
<div className="pt-4 space-y-4">
<div className="flex items-baseline gap-3">
<span className={cn("text-4xl font-bold font-number", isUp ? "text-success" : "text-danger")}>
${formatGoldPrice(price)}
</span>
<span className={cn("text-xl font-semibold font-number", getValueColor(priceChange))}>
{priceChange >= 0 ? "+" : ""}{priceChange.toFixed(2)}
</span>
</div>
<span className="text-[11px] text-muted-foreground font-number">
{spread.toFixed(1)}p
</span>
</div>
{priceHistory.length > 2 && (
<div className="mt-1.5 -mx-1">
<Sparkline
data={priceHistory.slice(-30)}
color={isUp ? "#22c55e" : "#ef4444"}
height={24}
/>
<div className="grid grid-cols-3 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Spread</span>
<p className="text-lg font-semibold font-number">{spread.toFixed(1)} pts</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Session High</span>
<p className="text-lg font-semibold font-number text-apple-green">${formatGoldPrice(high)}</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Session Low</span>
<p className="text-lg font-semibold font-number text-apple-red">${formatGoldPrice(low)}</p>
</div>
</div>
)}
</CardContent>
</Card>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Range: {range.toFixed(2)} pts</span>
<div className="h-2 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div
className="h-full rounded-full bar-blue"
style={{ width: range > 0 ? `${Math.min(((price - low) / range) * 100, 100)}%` : "50%" }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground font-number">
<span>${formatGoldPrice(low)}</span>
<span>${formatGoldPrice(high)}</span>
</div>
</div>
{priceHistory.length > 2 && (
<div>
<span className="text-sm text-muted-foreground mb-1 block">Price History ({priceHistory.length} ticks)</span>
<Sparkline data={priceHistory} color={isUp ? "#34C759" : "#FF3B30"} height={80} />
</div>
)}
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,68 +1,125 @@
"use client";
import { useMemo } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, Tooltip } from "recharts";
import { AreaChart, Area, XAxis, YAxis, ResponsiveContainer, Tooltip, CartesianGrid } from "recharts";
import { TrendingUp } from "lucide-react";
interface PriceChartProps {
data: number[];
}
function formatTime(date: Date): string {
return date.toLocaleTimeString("en-US", {
timeZone: "Asia/Jakarta",
hour12: false,
hour: "2-digit",
minute: "2-digit",
});
}
export function PriceChart({ data }: PriceChartProps) {
const chartData = data.map((price, i) => ({ index: i, price }));
const chartData = useMemo(() => {
if (data.length === 0) return [];
const now = Date.now();
// Spread data points evenly over 2 hours (7200s)
const totalMs = 2 * 60 * 60 * 1000;
const step = data.length > 1 ? totalMs / (data.length - 1) : 0;
return data.map((price, i) => {
const ts = new Date(now - totalMs + i * step);
return {
time: formatTime(ts),
timestamp: ts.getTime(),
price,
};
});
}, [data]);
// Show ~6 evenly spaced tick labels on X axis
const tickIndices = useMemo(() => {
if (chartData.length <= 6) return chartData.map((d) => d.timestamp);
const step = Math.floor(chartData.length / 5);
const ticks: number[] = [];
for (let i = 0; i < chartData.length; i += step) {
ticks.push(chartData[i].timestamp);
}
// Always include last
if (ticks[ticks.length - 1] !== chartData[chartData.length - 1].timestamp) {
ticks.push(chartData[chartData.length - 1].timestamp);
}
return ticks;
}, [chartData]);
return (
<Card className="glass h-full flex flex-col">
<Card className="glass h-full overflow-hidden flex flex-col accent-top-blue">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<TrendingUp className="h-3.5 w-3.5" />
<CardTitle className="text-sm font-medium text-apple-blue flex items-center gap-1.5 uppercase tracking-wider">
<TrendingUp className="h-4 w-4" />
Price Chart (2H)
{data.length > 0 && (
<span className="ml-auto text-xs font-number text-foreground">
<span className="ml-auto text-base font-number text-apple-blue font-bold">
${data[data.length - 1]?.toFixed(2)}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0">
<CardContent className="flex-1 min-h-0 pb-1">
<div className="h-full w-full">
{data.length > 1 ? (
{chartData.length > 1 ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<XAxis dataKey="index" hide />
<YAxis domain={["auto", "auto"]} hide />
<AreaChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(0, 0, 0, 0.06)" />
<XAxis
dataKey="timestamp"
type="number"
domain={["dataMin", "dataMax"]}
ticks={tickIndices}
tickFormatter={(ts: number) => formatTime(new Date(ts))}
tick={{ fontSize: 11, fill: "#86868b", fontFamily: "var(--font-mono)" }}
axisLine={{ stroke: "rgba(0,0,0,0.08)" }}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis
domain={["auto", "auto"]}
tick={{ fontSize: 11, fill: "#86868b", fontFamily: "var(--font-mono)" }}
axisLine={false}
tickLine={false}
width={58}
tickFormatter={(v: number) => v.toFixed(0)}
/>
<Tooltip
contentStyle={{
backgroundColor: "var(--color-card)",
border: "1px solid var(--color-border)",
borderRadius: "6px",
fontSize: "11px",
backgroundColor: "rgba(255, 255, 255, 0.85)",
border: "1px solid rgba(0, 122, 255, 0.2)",
borderRadius: "12px",
fontSize: "13px",
fontFamily: "var(--font-mono)",
color: "#1d1d1f",
boxShadow: "0 4px 20px rgba(0, 0, 0, 0.08)",
backdropFilter: "blur(20px)",
padding: "6px 10px",
}}
labelStyle={{ display: "none" }}
labelFormatter={(ts: number) => formatTime(new Date(ts))}
formatter={(value: number) => [`$${value.toFixed(2)}`, "Price"]}
cursor={{ stroke: "#007AFF", strokeDasharray: "3 3" }}
/>
<defs>
<linearGradient id="priceGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.2} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
<stop offset="0%" stopColor="#007AFF" stopOpacity={0.25} />
<stop offset="40%" stopColor="#007AFF" stopOpacity={0.08} />
<stop offset="100%" stopColor="#007AFF" stopOpacity={0} />
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="price"
stroke="#3b82f6"
strokeWidth={1.5}
fill="url(#priceGradient)"
dot={false}
/>
<Area type="monotone" dataKey="price" stroke="#007AFF" strokeWidth={2.5} fill="url(#priceGradient)" dot={false} isAnimationActive={false} />
</AreaChart>
</ResponsiveContainer>
) : (
<div className="h-full flex items-center justify-center text-muted-foreground/50">
<div className="text-center space-y-1">
<TrendingUp className="h-5 w-5 mx-auto opacity-30" />
<p className="text-xs">Collecting data...</p>
<TrendingUp className="h-6 w-6 mx-auto opacity-30" />
<p className="text-base">Collecting data...</p>
</div>
</div>
)}
@@ -2,8 +2,11 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Activity, Clock } from "lucide-react";
import { cn, getConfidenceColor } from "@/lib/utils";
import { useAnimatedValue } from "@/hooks/use-animated-value";
interface RegimeCardProps {
name: string;
@@ -23,53 +26,113 @@ export function RegimeCard({ name, volatility, confidence, updatedAt, h1Bias }:
};
const confidencePercent = confidence * 100;
const animVol = useAnimatedValue(volatility);
const animConf = useAnimatedValue(confidencePercent);
return (
<Card className="glass">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Activity className="h-3.5 w-3.5" />
Market Regime
{updatedAt && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground/60 font-number normal-case tracking-normal">
<Clock className="h-2.5 w-2.5" />
{updatedAt}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Badge variant={getRegimeBadgeVariant(name) as any} className="text-xs font-bold">
{name || "Unknown"}
</Badge>
<Dialog>
<DialogTrigger asChild>
<Card className="glass h-full overflow-hidden flex flex-col accent-top-pink glass-pink cursor-pointer">
<CardHeader>
<CardTitle className="text-sm font-medium text-apple-pink flex items-center gap-1.5 uppercase tracking-wider">
<Activity className="h-4 w-4" />
Regime
{updatedAt && (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground/60 font-number normal-case tracking-normal">
<Clock className="h-3 w-3" />
{updatedAt}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 flex flex-col justify-between">
<Tooltip>
<TooltipTrigger asChild>
<div>
<Badge variant={getRegimeBadgeVariant(name) as any} className="text-base font-bold px-2 py-0.5">
{name || "Unknown"}
</Badge>
</div>
</TooltipTrigger>
<TooltipContent><p>Click for detail</p></TooltipContent>
</Tooltip>
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">Volatility</span>
<span className="text-sm font-semibold font-number">{volatility.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">Confidence</span>
<span className={cn(
"text-sm font-semibold font-number",
getConfidenceColor(confidencePercent)
)}>
{confidencePercent.toFixed(0)}%
</span>
</div>
{h1Bias && (
<div className="flex justify-between items-center pt-1 border-t border-border">
<span className="text-[11px] text-muted-foreground">H1 Bias</span>
<span className={cn(
"text-xs font-bold",
h1Bias === "BULLISH" ? "text-success" :
h1Bias === "BEARISH" ? "text-danger" :
"text-muted-foreground"
)}>
{h1Bias === "BULLISH" ? "↑ " : h1Bias === "BEARISH" ? "↓ " : ""}{h1Bias}
</span>
<div className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Volatility</span>
<span className="text-base font-semibold font-number text-apple-orange">{animVol.displayValue.toFixed(2)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Confidence</span>
<span className={cn("text-base font-semibold font-number", getConfidenceColor(animConf.displayValue))}>
{animConf.displayValue.toFixed(0)}%
</span>
</div>
{h1Bias && (
<div className="flex justify-between items-center pt-1 border-t border-border">
<span className="text-sm text-muted-foreground">H1 Bias</span>
<span className={cn(
"text-base font-bold",
h1Bias === "BULLISH" ? "text-apple-green" : h1Bias === "BEARISH" ? "text-apple-red" : "text-muted-foreground"
)}>
{h1Bias === "BULLISH" ? "^ " : h1Bias === "BEARISH" ? "v " : ""}{h1Bias}
</span>
</div>
)}
</div>
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-apple-pink">
<Activity className="h-5 w-5" />
Market Regime Detail
</DialogTitle>
<DialogDescription>HMM-detected market regime and volatility analysis</DialogDescription>
</DialogHeader>
<div className="pt-4 space-y-4">
<div className="flex items-center gap-3">
<Badge variant={getRegimeBadgeVariant(name) as any} className="text-lg font-bold px-3 py-1">
{name || "Unknown"}
</Badge>
{updatedAt && <span className="text-sm text-muted-foreground font-number">{updatedAt}</span>}
</div>
)}
</CardContent>
</Card>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Volatility</span>
<p className="text-2xl font-bold font-number text-apple-orange">{volatility.toFixed(4)}</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Confidence</span>
<p className={cn("text-2xl font-bold font-number", getConfidenceColor(confidencePercent))}>
{confidencePercent.toFixed(1)}%
</p>
<div className="h-2 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div className={cn("h-full rounded-full", confidencePercent >= 70 ? "bar-green" : confidencePercent >= 50 ? "bar-orange" : "bar-red")} style={{ width: `${confidencePercent}%` }} />
</div>
</div>
</div>
{h1Bias && (
<div className="pt-3 border-t border-border">
<span className="text-sm text-muted-foreground">H1 Timeframe Bias</span>
<p className={cn(
"text-xl font-bold mt-1",
h1Bias === "BULLISH" ? "text-apple-green" : h1Bias === "BEARISH" ? "text-apple-red" : "text-muted-foreground"
)}>
{h1Bias}
</p>
</div>
)}
<div className="pt-3 border-t border-border text-sm text-muted-foreground">
<p>The Hidden Markov Model classifies market states based on volatility patterns. Regime transitions affect position sizing, signal thresholds, and exit management.</p>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -1,9 +1,13 @@
"use client";
import { useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ShieldAlert, AlertTriangle } from "lucide-react";
import { cn, formatUSD } from "@/lib/utils";
import { useAnimatedValue } from "@/hooks/use-animated-value";
import type { RiskMode } from "@/types/trading";
interface RiskCardProps {
@@ -18,123 +22,152 @@ function getRiskModeVariant(mode: string): "success" | "warning" | "danger" | "s
switch (mode) {
case "normal": return "success";
case "recovery": return "warning";
case "protected": return "danger";
case "stopped": return "danger";
case "protected": case "stopped": return "danger";
default: return "secondary";
}
}
export function RiskCard({ dailyLoss, dailyProfit, consecutiveLosses, riskPercent, riskMode }: RiskCardProps) {
const isCritical = riskPercent >= 100;
const isHigh = riskPercent >= 80;
const isMedium = riskPercent >= 50;
const getRiskColor = () => {
if (isHigh) return "text-danger";
if (isMedium) return "text-warning";
return "text-success";
};
const getSegmentFill = () => {
if (isHigh) return "bg-danger";
if (isMedium) return "bg-warning";
return "bg-success";
};
const mode = riskMode?.mode || "unknown";
const firstMount = useRef(true);
const animRisk = useAnimatedValue(riskPercent);
const animLoss = useAnimatedValue(dailyLoss);
const animProfit = useAnimatedValue(dailyProfit);
const getRiskColor = () => isHigh ? "text-danger" : isMedium ? "text-warning" : "text-success";
const getBarClass = () => isHigh ? "bar-red" : isMedium ? "bar-orange" : "bar-green";
const getTopClass = () => isHigh ? "accent-top-red" : isMedium ? "accent-top-orange" : "accent-top-green";
const shouldAnimate = firstMount.current;
if (firstMount.current) firstMount.current = false;
const netPL = dailyProfit - dailyLoss;
return (
<Card className={cn(
"glass",
isCritical && "border-danger/50 ring-1 ring-danger/20",
isHigh && !isCritical && "border-danger/30"
)}>
<CardHeader>
<CardTitle className={cn(
"text-[11px] font-medium flex items-center gap-1.5 uppercase tracking-wider",
isHigh ? "text-danger" : "text-muted-foreground"
)}>
<ShieldAlert className="h-3.5 w-3.5" />
Risk
{/* Risk Mode Badge */}
<Badge
variant={getRiskModeVariant(mode)}
className={cn("ml-auto text-[9px] h-4 px-1 uppercase", mode === "stopped" && "animate-pulse")}
>
{mode}
</Badge>
{isCritical && (
<span className="flex items-center gap-1 text-[10px] bg-danger text-white px-1.5 py-0.5 rounded-full animate-pulse">
<AlertTriangle className="h-2.5 w-2.5" />
BREACHED
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1">
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">Daily Loss</span>
<span className="text-xs font-semibold font-number text-danger">{formatUSD(dailyLoss)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">Daily Profit</span>
<span className="text-xs font-semibold font-number text-success">{formatUSD(dailyProfit)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[11px] text-muted-foreground">Consec. Losses</span>
<span className={cn(
"text-xs font-semibold font-number",
consecutiveLosses >= 3 ? "text-warning" : "text-foreground"
)}>
{consecutiveLosses}
</span>
</div>
<Dialog>
<DialogTrigger asChild>
<Card className={cn("glass h-full overflow-hidden flex flex-col cursor-pointer", getTopClass(), isHigh ? "glass-red" : isMedium ? "glass-orange" : "glass-green")}>
<CardHeader>
<CardTitle className={cn(
"text-sm font-medium flex items-center gap-1.5 uppercase tracking-wider",
isHigh ? "text-apple-red" : isMedium ? "text-apple-orange" : "text-apple-green"
)}>
<ShieldAlert className="h-4 w-4" />
Risk
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={getRiskModeVariant(mode)} className={cn("ml-auto text-xs h-5 px-1.5 uppercase", mode === "stopped" && "animate-pulse")}>
{mode}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>{mode === "normal" ? "Full position sizing" : mode === "recovery" ? "Reduced lots after losses" : mode === "stopped" ? "Daily limit hit" : mode}</p>
</TooltipContent>
</Tooltip>
{riskPercent >= 100 && (
<span className="flex items-center gap-1 text-xs bg-danger text-white px-1.5 py-0.5 rounded-full animate-pulse">
<AlertTriangle className="h-3 w-3" /> BREACHED
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 flex flex-col justify-between">
<div className="space-y-0.5">
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Daily Loss</span>
<span className="text-base font-semibold font-number text-apple-red">{formatUSD(animLoss.displayValue)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Daily Profit</span>
<span className="text-base font-semibold font-number text-apple-green">{formatUSD(animProfit.displayValue)}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-sm text-muted-foreground">Consec. Loss</span>
<span className={cn("text-base font-semibold font-number", consecutiveLosses >= 3 ? "text-apple-orange" : "text-foreground")}>
{consecutiveLosses}
</span>
</div>
</div>
<div className="pt-1 border-t border-border">
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] text-muted-foreground">Risk Used</span>
<span className={cn("text-sm font-bold font-number", getRiskColor())}>
{riskPercent.toFixed(0)}%
</span>
<div className="pt-1 border-t border-border">
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-muted-foreground">Risk Used</span>
<span className={cn("text-lg font-bold font-number", getRiskColor())}>{animRisk.displayValue.toFixed(0)}%</span>
</div>
<div className="h-2.5 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
getBarClass(),
shouldAnimate && "bar-animate-in"
)}
style={{ width: `${Math.min(animRisk.displayValue, 100)}%` }}
/>
</div>
</div>
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className={cn("flex items-center gap-2", isHigh ? "text-apple-red" : isMedium ? "text-apple-orange" : "text-apple-green")}>
<ShieldAlert className="h-5 w-5" />
Risk Management Detail
</DialogTitle>
<DialogDescription>Daily risk metrics and mode status</DialogDescription>
</DialogHeader>
<div className="pt-4 space-y-4">
<div className="flex items-center gap-3">
<Badge variant={getRiskModeVariant(mode)} className="text-sm px-3 py-1 uppercase">{mode}</Badge>
<span className={cn("text-3xl font-bold font-number", getRiskColor())}>{riskPercent.toFixed(1)}%</span>
<span className="text-muted-foreground">risk used</span>
</div>
<div className="h-1.5 w-full bg-surface-light rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-500", getSegmentFill())}
style={{ width: `${Math.min(riskPercent, 100)}%` }}
/>
<div className="h-3 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all", getBarClass())} style={{ width: `${Math.min(riskPercent, 100)}%` }} />
</div>
{/* Remaining daily risk */}
{riskMode && riskMode.remainingDailyRisk > 0 && (
<div className="flex justify-between items-center mt-0.5">
<span className="text-[9px] text-muted-foreground/60">Remaining</span>
<span className="text-[9px] font-number text-muted-foreground/60">
{formatUSD(riskMode.remainingDailyRisk)}
</span>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Daily Loss</span>
<p className="text-xl font-bold font-number text-apple-red">{formatUSD(dailyLoss)}</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Daily Profit</span>
<p className="text-xl font-bold font-number text-apple-green">{formatUSD(dailyProfit)}</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Net P/L</span>
<p className={cn("text-xl font-bold font-number", netPL >= 0 ? "text-apple-green" : "text-apple-red")}>
{netPL >= 0 ? "+" : ""}{formatUSD(netPL)}
</p>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Consecutive Losses</span>
<p className={cn("text-xl font-bold font-number", consecutiveLosses >= 3 ? "text-apple-orange" : "text-foreground")}>
{consecutiveLosses}
</p>
</div>
</div>
{riskMode && (
<div className="pt-3 border-t border-border space-y-1">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Recommended Lot</span>
<span className="font-semibold font-number text-apple-purple">{riskMode.recommendedLot}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Max Allowed Lot</span>
<span className="font-semibold font-number">{riskMode.maxAllowedLot}</span>
</div>
</div>
)}
</div>
{/* Total Loss Progress */}
{riskMode && riskMode.maxTotalLoss > 0 && (
<div className="pt-0.5">
<div className="flex justify-between items-center mb-0.5">
<span className="text-[10px] text-muted-foreground">Total Loss</span>
<span className="text-[10px] font-number text-muted-foreground">
{formatUSD(riskMode.totalLoss)} / {formatUSD(riskMode.maxTotalLoss)}
</span>
</div>
<div className="h-1 w-full bg-surface-light rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-500",
(riskMode.totalLoss / riskMode.maxTotalLoss) >= 0.8 ? "bg-danger" : "bg-warning/60"
)}
style={{ width: `${riskMode.maxTotalLoss > 0 ? Math.min((riskMode.totalLoss / riskMode.maxTotalLoss) * 100, 100) : 0}%` }}
/>
</div>
</div>
)}
</CardContent>
</Card>
</DialogContent>
</Dialog>
);
}
@@ -2,6 +2,7 @@
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Clock, Sparkles, CheckCircle2, XCircle, Ban } from "lucide-react";
import { cn } from "@/lib/utils";
import type { TimeFilter } from "@/types/trading";
@@ -17,74 +18,66 @@ interface SessionCardProps {
export function SessionCard({ session, isGoldenTime, canTrade, sessionMultiplier, timeFilter }: SessionCardProps) {
const getSessionColor = (s: string) => {
const lower = s.toLowerCase();
if (lower.includes("london")) return "text-info";
if (lower.includes("new york") || lower.includes("ny")) return "text-success";
if (lower.includes("sydney") || lower.includes("asian")) return "text-accent";
return "text-warning";
if (lower.includes("london")) return "text-apple-blue";
if (lower.includes("new york") || lower.includes("ny")) return "text-apple-green";
if (lower.includes("sydney") || lower.includes("asian")) return "text-apple-purple";
return "text-apple-orange";
};
const mult = sessionMultiplier ?? 1.0;
const multLabel = `${mult}x`;
const multVariant = mult < 1 ? "warning" : mult > 1 ? "success" : "secondary";
const isDisabledSession = session.toLowerCase().includes("tokyo-london") || mult === 0;
return (
<Card className="glass">
<Card className="glass h-full overflow-hidden flex flex-col accent-top-purple glass-purple">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Clock className="h-3.5 w-3.5" />
<CardTitle className="text-sm font-medium text-apple-purple flex items-center gap-1.5 uppercase tracking-wider">
<Clock className="h-4 w-4" />
Session
{sessionMultiplier != null && (
<Badge variant={multVariant as "warning" | "success" | "secondary"} className="ml-auto text-[10px] h-4 px-1">
{multLabel}
<Badge variant={mult < 1 ? "warning" : mult > 1 ? "success" : "secondary"} className="ml-auto text-xs h-5 px-1.5">
{mult}x
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
<span className={cn("text-lg font-bold block", getSessionColor(session))}>
{session || "Closed"}
</span>
<div className="flex items-center gap-1.5">
<Sparkles className={cn(
"h-3 w-3",
isGoldenTime ? "text-warning" : "text-muted-foreground/40"
)} />
<span className={cn(
"text-[11px]",
isGoldenTime ? "text-warning font-semibold" : "text-muted-foreground"
)}>
{isGoldenTime ? "Golden Hour" : "Standard Hours"}
</span>
</div>
<div className="flex items-center gap-1.5">
{canTrade ? (
<CheckCircle2 className="h-3 w-3 text-success" />
) : (
<XCircle className="h-3 w-3 text-danger" />
)}
<Badge variant={canTrade ? "success" : "danger"} className="text-[10px] h-5">
{canTrade ? "CAN TRADE" : "NO TRADE"}
</Badge>
</div>
{/* Time Filter Status */}
{timeFilter && (
<div className="flex items-center gap-1.5 pt-0.5 border-t border-border">
{timeFilter.isBlocked ? (
<Ban className="h-3 w-3 text-danger" />
) : (
<Clock className="h-3 w-3 text-muted-foreground/40" />
)}
<CardContent className="flex-1 min-h-0 flex flex-col justify-between">
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"text-[10px]",
timeFilter.isBlocked ? "text-danger font-semibold" : "text-muted-foreground"
"text-xl font-bold block",
isDisabledSession ? "text-muted-foreground/50" : getSessionColor(session)
)}>
WIB {timeFilter.wibHour}:00{timeFilter.isBlocked ? " BLOCKED" : ""}
{session || "Closed"}
{isDisabledSession && <span className="text-sm font-normal text-danger ml-2">OFF</span>}
</span>
</TooltipTrigger>
<TooltipContent><p>Current forex trading session</p></TooltipContent>
</Tooltip>
<div className="space-y-1">
<div className="flex items-center gap-1.5">
<Sparkles className={cn("h-4 w-4", isGoldenTime ? "text-apple-orange" : "text-muted-foreground/40")} />
<span className={cn("text-sm", isGoldenTime ? "text-apple-orange font-semibold" : "text-muted-foreground")}>
{isGoldenTime ? "Golden Hour" : "Standard"}
</span>
</div>
)}
<div className="flex items-center gap-1.5">
{canTrade ? <CheckCircle2 className="h-4 w-4 text-success" /> : <XCircle className="h-4 w-4 text-danger" />}
<Badge variant={canTrade ? "success" : "danger"} className="text-xs h-5">
{canTrade ? "CAN TRADE" : "NO TRADE"}
</Badge>
</div>
{timeFilter && (
<div className="flex items-center gap-1.5 pt-1 border-t border-border">
{timeFilter.isBlocked ? <Ban className="h-4 w-4 text-danger" /> : <Clock className="h-4 w-4 text-muted-foreground/40" />}
<span className={cn("text-sm", timeFilter.isBlocked ? "text-danger font-semibold" : "text-muted-foreground")}>
WIB {timeFilter.wibHour}:00{timeFilter.isBlocked ? " BLOCKED" : ""}
</span>
</div>
)}
</div>
</CardContent>
</Card>
);
@@ -25,19 +25,19 @@ export function SettingsCard({ settings }: SettingsCardProps) {
];
return (
<Card className="glass">
<Card className="glass h-full overflow-hidden flex flex-col">
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Settings2 className="h-3.5 w-3.5" />
Bot Settings
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
<Settings2 className="h-4 w-4" />
Settings
</CardTitle>
</CardHeader>
<CardContent>
<CardContent className="flex-1 min-h-0 overflow-auto">
<div className="grid grid-cols-3 gap-x-3 gap-y-1">
{rows.map((row) => (
<div key={row.label} className="flex justify-between items-center gap-1">
<span className="text-[10px] text-muted-foreground truncate">{row.label}</span>
<span className="text-[10px] font-semibold font-number text-foreground shrink-0">{row.value}</span>
<div key={row.label} className="flex justify-between items-center gap-1 rounded px-1 -mx-1 row-hover">
<span className="text-sm text-muted-foreground truncate">{row.label}</span>
<span className="text-sm font-semibold font-number text-foreground shrink-0">{row.value}</span>
</div>
))}
</div>
@@ -1,8 +1,12 @@
"use client";
import { useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { Brain, BarChart3, Clock } from "lucide-react";
import { cn, getSignalColor, getConfidenceColor } from "@/lib/utils";
import { useAnimatedValue } from "@/hooks/use-animated-value";
interface SignalCardProps {
title: string;
@@ -17,21 +21,13 @@ interface SignalCardProps {
marketQuality?: string;
}
export function SignalCard({
title,
icon,
signal,
confidence,
detail,
buyProb,
sellProb,
updatedAt,
threshold,
marketQuality,
}: SignalCardProps) {
export function SignalCard({ title, icon, signal, confidence, detail, buyProb, sellProb, updatedAt, threshold, marketQuality }: SignalCardProps) {
const confidencePercent = confidence * 100;
const hasSignal = signal && signal.toUpperCase() !== "NO SIGNAL" && signal !== "";
const normalized = (signal || "").toUpperCase();
const firstMount = useRef(true);
const animConf = useAnimatedValue(confidencePercent);
const getBorderClass = () => {
if (normalized === "BUY") return "signal-buy";
@@ -40,94 +36,158 @@ export function SignalCard({
return "signal-none";
};
const getBarColor = () => {
if (normalized === "BUY") return "bg-success";
if (normalized === "SELL") return "bg-danger";
if (normalized === "HOLD") return "bg-warning";
const getBarClass = () => {
if (normalized === "BUY") return "bar-green";
if (normalized === "SELL") return "bar-red";
if (normalized === "HOLD") return "bar-orange";
return "bg-muted";
};
return (
<Card className={cn("glass", getBorderClass())}>
<CardHeader>
<CardTitle className="text-[11px] font-medium text-muted-foreground flex items-center gap-1.5 uppercase tracking-wider">
{icon === "smc" ? <BarChart3 className="h-3.5 w-3.5" /> : <Brain className="h-3.5 w-3.5" />}
{title}
{updatedAt && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground/60 font-number normal-case tracking-normal">
<Clock className="h-2.5 w-2.5" />
{updatedAt}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<span className={cn(
"text-xl font-bold block",
hasSignal ? getSignalColor(signal) : "text-muted-foreground/60"
)}>
{signal || "NO SIGNAL"}
</span>
const iconColor = icon === "smc" ? "text-apple-cyan" : "text-apple-purple";
const topClass = icon === "smc" ? "accent-top-cyan" : "accent-top-purple";
const hoverClass = icon === "smc" ? "glass-cyan" : "glass-purple";
{/* Confidence bar */}
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-[11px] text-muted-foreground">Confidence</span>
<span className={cn(
"text-[11px] font-semibold font-number",
getConfidenceColor(confidencePercent)
)}>
{confidencePercent.toFixed(0)}%
{threshold !== undefined && (
<span className="text-muted-foreground font-normal">
/{(threshold * 100).toFixed(0)}%
const shouldAnimate = firstMount.current;
if (firstMount.current) firstMount.current = false;
return (
<Dialog>
<DialogTrigger asChild>
<Card className={cn("glass h-full overflow-hidden flex flex-col cursor-pointer", topClass, hoverClass, getBorderClass())}>
<CardHeader>
<CardTitle className={cn("text-sm font-medium flex items-center gap-1.5 uppercase tracking-wider", iconColor)}>
{icon === "smc" ? <BarChart3 className="h-4 w-4" /> : <Brain className="h-4 w-4" />}
{title}
{updatedAt && (
<span className="ml-auto flex items-center gap-1 text-xs text-muted-foreground/60 font-number normal-case tracking-normal">
<Clock className="h-3 w-3" />
{updatedAt}
</span>
)}
</CardTitle>
</CardHeader>
<CardContent className="flex-1 min-h-0 flex flex-col justify-between">
<Tooltip>
<TooltipTrigger asChild>
<span className={cn(
"text-2xl font-bold block",
hasSignal ? getSignalColor(signal) : "text-muted-foreground/60"
)}>
{signal || "NO SIGNAL"}
</span>
</TooltipTrigger>
<TooltipContent>
<p>Click for detail</p>
</TooltipContent>
</Tooltip>
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-muted-foreground">Confidence</span>
<span className={cn("text-sm font-semibold font-number", getConfidenceColor(animConf.displayValue))}>
{animConf.displayValue.toFixed(0)}%
{threshold !== undefined && (
<span className="text-muted-foreground font-normal"> /{(threshold * 100).toFixed(0)}%</span>
)}
</span>
</div>
<div className="relative h-2 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div
className={cn(
"h-full rounded-full transition-all duration-300",
getBarClass(),
shouldAnimate && "bar-animate-in"
)}
style={{ width: `${animConf.displayValue}%` }}
/>
{threshold !== undefined && (
<div className="absolute top-0 h-full w-[2px] bg-foreground/30" style={{ left: `${threshold * 100}%` }} />
)}
</div>
</div>
{detail && <p className="text-sm text-muted-foreground line-clamp-1">{detail}</p>}
{buyProb !== undefined && sellProb !== undefined && (
<div className="flex justify-between gap-2 text-sm font-number">
<span><span className="text-muted-foreground">Buy </span><span className="text-apple-green font-semibold">{(buyProb * 100).toFixed(0)}%</span></span>
<span><span className="text-muted-foreground">Sell </span><span className="text-apple-red font-semibold">{(sellProb * 100).toFixed(0)}%</span></span>
</div>
)}
</CardContent>
</Card>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className={cn("flex items-center gap-2", iconColor)}>
{icon === "smc" ? <BarChart3 className="h-5 w-5" /> : <Brain className="h-5 w-5" />}
{title} Detail
</DialogTitle>
<DialogDescription>
{icon === "smc" ? "Smart Money Concepts analysis — Order Blocks, FVG, BOS/CHoCH" : "XGBoost ML model prediction probabilities"}
</DialogDescription>
</DialogHeader>
<div className="pt-4 space-y-4">
<div className="flex items-center gap-3">
<span className={cn("text-3xl font-bold", hasSignal ? getSignalColor(signal) : "text-muted-foreground/60")}>
{signal || "NO SIGNAL"}
</span>
{updatedAt && <span className="text-sm text-muted-foreground font-number">{updatedAt}</span>}
</div>
<div className="relative h-1.5 w-full bg-surface-light rounded-full overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-300", getBarColor())}
style={{ width: `${confidencePercent}%` }}
/>
<div>
<div className="flex justify-between items-center mb-2">
<span className="text-sm text-muted-foreground">Confidence</span>
<span className={cn("text-lg font-bold font-number", getConfidenceColor(confidencePercent))}>
{confidencePercent.toFixed(1)}%
</span>
</div>
<div className="relative h-3 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div className={cn("h-full rounded-full transition-all", getBarClass())} style={{ width: `${confidencePercent}%` }} />
{threshold !== undefined && (
<div className="absolute top-0 h-full w-[2px] bg-foreground/40" style={{ left: `${threshold * 100}%` }} />
)}
</div>
{threshold !== undefined && (
<div
className="absolute top-0 h-full w-[2px] bg-foreground/50"
style={{ left: `${threshold * 100}%` }}
/>
<p className="text-sm text-muted-foreground mt-1">Threshold: {(threshold * 100).toFixed(0)}% {confidencePercent >= threshold * 100 ? "ABOVE" : "BELOW"}</p>
)}
</div>
{threshold !== undefined && (
<div className="flex justify-between items-center mt-0.5">
<span className="text-[10px] text-muted-foreground/60">
{confidencePercent >= threshold * 100 ? "✓ Above" : "✗ Below"} threshold
</span>
{marketQuality && (
<span className="text-[10px] text-muted-foreground/60 font-number">
Mkt: {marketQuality}
</span>
)}
{buyProb !== undefined && sellProb !== undefined && (
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Buy Probability</span>
<p className="text-xl font-bold font-number text-apple-green">{(buyProb * 100).toFixed(1)}%</p>
<div className="h-2 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div className="h-full rounded-full bar-green" style={{ width: `${buyProb * 100}%` }} />
</div>
</div>
<div className="space-y-1">
<span className="text-sm text-muted-foreground">Sell Probability</span>
<p className="text-xl font-bold font-number text-apple-red">{(sellProb * 100).toFixed(1)}%</p>
<div className="h-2 w-full bg-black/[0.04] rounded-full overflow-hidden">
<div className="h-full rounded-full bar-red" style={{ width: `${sellProb * 100}%` }} />
</div>
</div>
</div>
)}
{detail && (
<div className="pt-3 border-t border-border">
<span className="text-sm text-muted-foreground">Detail</span>
<p className="text-sm mt-1">{detail}</p>
</div>
)}
{marketQuality && (
<div className="flex justify-between text-sm pt-2 border-t border-border">
<span className="text-muted-foreground">Market Quality</span>
<span className="font-semibold uppercase">{marketQuality}</span>
</div>
)}
</div>
{detail && (
<p className="text-[11px] text-muted-foreground line-clamp-1">{detail}</p>
)}
{buyProb !== undefined && sellProb !== undefined && (
<div className="flex justify-between gap-2 text-[11px] font-number">
<span>
<span className="text-muted-foreground">Buy </span>
<span className="text-success font-semibold">{(buyProb * 100).toFixed(0)}%</span>
</span>
<span>
<span className="text-muted-foreground">Sell </span>
<span className="text-danger font-semibold">{(sellProb * 100).toFixed(0)}%</span>
</span>
</div>
)}
</CardContent>
</Card>
</DialogContent>
</Dialog>
);
}
@@ -1,6 +1,6 @@
"use client";
import { LineChart, Line, ResponsiveContainer, YAxis } from "recharts";
import { LineChart, Line, ResponsiveContainer, YAxis, Tooltip as RechartsTooltip } from "recharts";
interface SparklineProps {
data: number[];
@@ -8,7 +8,7 @@ interface SparklineProps {
height?: number;
}
export function Sparkline({ data, color = "#22c55e", height = 28 }: SparklineProps) {
export function Sparkline({ data, color = "#34C759", height = 28 }: SparklineProps) {
if (data.length < 2) return null;
const chartData = data.map((v, i) => ({ i, v }));
@@ -18,12 +18,29 @@ export function Sparkline({ data, color = "#22c55e", height = 28 }: SparklinePro
<ResponsiveContainer width="100%" height="100%">
<LineChart data={chartData}>
<YAxis domain={["auto", "auto"]} hide />
<RechartsTooltip
contentStyle={{
background: "rgba(255,255,255,0.75)",
backdropFilter: "blur(12px)",
WebkitBackdropFilter: "blur(12px)",
border: "1px solid rgba(255,255,255,0.6)",
borderRadius: 8,
fontSize: 12,
fontFamily: "var(--font-mono)",
padding: "4px 8px",
boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
}}
labelStyle={{ display: "none" }}
formatter={(value: number) => [value.toFixed(2), ""]}
cursor={{ stroke: color, strokeDasharray: "3 3" }}
/>
<Line
type="monotone"
dataKey="v"
stroke={color}
strokeWidth={1.5}
dot={false}
activeDot={{ r: 3, fill: color, strokeWidth: 0 }}
isAnimationActive={false}
/>
</LineChart>
@@ -0,0 +1,22 @@
"use client";
import { Sun, Moon } from "lucide-react";
import { useTheme } from "@/hooks/use-theme";
export function ThemeToggle() {
const { theme, toggleTheme } = useTheme();
return (
<button
onClick={toggleTheme}
className="flex items-center justify-center w-7 h-7 rounded-md bg-white/40 dark:bg-white/[0.06] backdrop-blur-sm border border-white/30 dark:border-white/10 hover:border-primary/20 transition-colors text-muted-foreground hover:text-primary"
title={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
>
{theme === "dark" ? (
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</button>
);
}
+8 -8
View File
@@ -3,25 +3,25 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border border-transparent px-2.5 py-0.5 text-xs font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
"inline-flex items-center justify-center rounded-full border px-2.5 py-0.5 text-xs font-semibold backdrop-blur-sm transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
"border-primary/20 bg-primary/10 text-primary hover:bg-primary/15",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
"border-black/5 bg-black/[0.04] text-secondary-foreground hover:bg-black/[0.06]",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
"border-destructive/20 bg-destructive/10 text-destructive hover:bg-destructive/15",
outline: "text-foreground border-border",
success:
"border-transparent bg-success-bg text-success hover:bg-success-bg/80",
"border-success/20 bg-success-bg text-success",
warning:
"border-transparent bg-warning-bg text-warning hover:bg-warning-bg/80",
"border-warning/20 bg-warning-bg text-warning",
danger:
"border-transparent bg-danger-bg text-danger hover:bg-danger-bg/80",
"border-danger/20 bg-danger-bg text-danger",
info:
"border-transparent bg-info-bg text-info hover:bg-info-bg/80",
"border-info/20 bg-info-bg text-info",
},
},
defaultVariants: {
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-lg border border-border bg-card text-card-foreground shadow-sm transition-colors",
"rounded-xl border border-border bg-card text-card-foreground shadow-sm card-interactive",
className
)}
{...props}
@@ -0,0 +1,11 @@
"use client"
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
@@ -0,0 +1,95 @@
"use client";
import * as React from "react";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "@/lib/utils";
const Dialog = DialogPrimitive.Root;
const DialogTrigger = DialogPrimitive.Trigger;
const DialogPortal = DialogPrimitive.Portal;
const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/20 backdrop-blur-sm",
"data-[state=open]:animate-in data-[state=closed]:animate-out",
"data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0",
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
"w-[90vw] max-w-2xl max-h-[85vh] overflow-auto",
"glass rounded-2xl shadow-2xl p-6",
"focus:outline-none",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-full p-1.5 hover:bg-black/5 transition-colors">
<X className="h-4 w-4 text-muted-foreground" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div className={cn("flex flex-col space-y-1.5 pb-4 border-b border-border", className)} {...props} />
);
const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold leading-none tracking-tight", className)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
};
+1 -1
View File
@@ -4,7 +4,7 @@ function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
className={cn("skeleton-glass", className)}
{...props}
/>
)
+55
View File
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }
@@ -0,0 +1,30 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-xl border border-white/40 bg-white/80 backdrop-blur-2xl px-3 py-1.5 text-sm text-foreground shadow-lg animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+998
View File
@@ -0,0 +1,998 @@
// AUTO-GENERATED — do not edit manually.
// Run: node scripts/generate-backtests.js
export interface ExitReason {
reason: string;
count: number;
pct: number;
}
export interface DirectionBreakdown {
direction: string;
trades: number;
winRate: number;
pnl: number;
}
export interface SessionBreakdown {
session: string;
trades: number;
winRate: number;
pnl: number;
}
export interface BacktestResult {
id: number;
slug: string;
name: string;
logFile: string;
generatedAt: string | null;
period: string | null;
strategy: string | null;
totalTrades: number;
wins: number;
losses: number;
winRate: number;
totalProfit: number;
totalLoss: number;
netPnl: number;
profitFactor: number;
maxDrawdown: number;
maxDrawdownUsd: number;
avgWin: number;
avgLoss: number;
expectancy: number;
sharpeRatio: number;
exitReasons: ExitReason[];
directionBreakdown: DirectionBreakdown[];
sessionBreakdown: SessionBreakdown[];
tradeCount: number;
}
export const backtestResults: BacktestResult[] = [
{
"id": 1,
"slug": "01_smc_only",
"name": "Smc Only",
"logFile": "smc_only_synced_20260207_062658.log",
"generatedAt": "2026-02-07 06:26:58",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + SmartRiskManager + SmartPositionManager",
"totalTrades": 686,
"wins": 495,
"losses": 191,
"winRate": 72.2,
"totalProfit": 4908.74,
"totalLoss": 3458.88,
"netPnl": 1449.86,
"profitFactor": 1.42,
"maxDrawdown": 5.4,
"maxDrawdownUsd": 300.84,
"avgWin": 9.92,
"avgLoss": 18.11,
"expectancy": 2.11,
"sharpeRatio": 1.98,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 2,
"slug": "02_earlycut_improved",
"name": "Earlycut Improved",
"logFile": "earlycut_improved_20260207_080155.log",
"generatedAt": "2026-02-07 08:01:55",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + SmartRiskManager + SmartPositionManager",
"totalTrades": 665,
"wins": 490,
"losses": 175,
"winRate": 73.7,
"totalProfit": 4961.49,
"totalLoss": 3542.6,
"netPnl": 1418.89,
"profitFactor": 1.4,
"maxDrawdown": 7,
"maxDrawdownUsd": 415.72,
"avgWin": 10.13,
"avgLoss": 20.24,
"expectancy": 2.13,
"sharpeRatio": 1.85,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 3,
"slug": "03_sellfilter_pullback",
"name": "Sellfilter Pullback",
"logFile": "sellfilter_pullback_20260207_082203.log",
"generatedAt": "2026-02-07 08:22:03",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Sell Filter Strict + Pullback Filter",
"totalTrades": 452,
"wins": 318,
"losses": 134,
"winRate": 70.4,
"totalProfit": 3103.92,
"totalLoss": 2345.08,
"netPnl": 758.84,
"profitFactor": 1.32,
"maxDrawdown": 3.1,
"maxDrawdownUsd": 168.97,
"avgWin": 9.76,
"avgLoss": 17.5,
"expectancy": 1.68,
"sharpeRatio": 1.57,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 4,
"slug": "04_pullback_only",
"name": "Pullback Only",
"logFile": "pullback_only_20260207_083527.log",
"generatedAt": "2026-02-07 08:35:27",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Pullback Filter (no sell filter)",
"totalTrades": 635,
"wins": 438,
"losses": 197,
"winRate": 69,
"totalProfit": 4169.73,
"totalLoss": 3833.96,
"netPnl": 335.77,
"profitFactor": 1.09,
"maxDrawdown": 8.6,
"maxDrawdownUsd": 456.21,
"avgWin": 9.52,
"avgLoss": 19.46,
"expectancy": 0.53,
"sharpeRatio": 0.45,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 5,
"slug": "05_sellfilter_only",
"name": "Sellfilter Only",
"logFile": "sellfilter_only_20260207_085852.log",
"generatedAt": "2026-02-07 08:58:52",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Sell Filter Strict (no pullback)",
"totalTrades": 495,
"wins": 363,
"losses": 132,
"winRate": 73.3,
"totalProfit": 3615.15,
"totalLoss": 2370.3,
"netPnl": 1244.85,
"profitFactor": 1.53,
"maxDrawdown": 3.7,
"maxDrawdownUsd": 217.83,
"avgWin": 9.96,
"avgLoss": 17.96,
"expectancy": 2.51,
"sharpeRatio": 2.35,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 6,
"slug": "06_stochastic",
"name": "Stochastic",
"logFile": "stochastic_20260207_092515.log",
"generatedAt": "2026-02-07 09:25:15",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Stochastic Filter (K=14, OB=75, OS=25)",
"totalTrades": 580,
"wins": 440,
"losses": 140,
"winRate": 75.9,
"totalProfit": 3799.56,
"totalLoss": 2450.75,
"netPnl": 1348.81,
"profitFactor": 1.55,
"maxDrawdown": 3.8,
"maxDrawdownUsd": 253.54,
"avgWin": 8.64,
"avgLoss": 17.51,
"expectancy": 2.33,
"sharpeRatio": 2.44,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 7,
"slug": "07_ema_stack",
"name": "Ema Stack",
"logFile": "ema_stack_20260207_092409.log",
"generatedAt": "2026-02-07 09:24:09",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + EMA 50 Trend Filter + Stack Tracking",
"totalTrades": 640,
"wins": 445,
"losses": 195,
"winRate": 69.5,
"totalProfit": 4690.91,
"totalLoss": 3407.04,
"netPnl": 1283.87,
"profitFactor": 1.38,
"maxDrawdown": 4.5,
"maxDrawdownUsd": 231.44,
"avgWin": 10.54,
"avgLoss": 17.47,
"expectancy": 2.01,
"sharpeRatio": 1.75,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 8,
"slug": "08_stoch_sell",
"name": "Stoch Sell",
"logFile": "stoch_sell_20260207_094541.log",
"generatedAt": "2026-02-07 09:45:41",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Stochastic (K=14) + Sell Filter (ML >= 55%)",
"totalTrades": 416,
"wins": 319,
"losses": 97,
"winRate": 76.7,
"totalProfit": 3046.86,
"totalLoss": 1726.45,
"netPnl": 1320.41,
"profitFactor": 1.76,
"maxDrawdown": 2.8,
"maxDrawdownUsd": 169.3,
"avgWin": 9.55,
"avgLoss": 17.8,
"expectancy": 3.17,
"sharpeRatio": 3.17,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 9,
"slug": "09_h4_zone",
"name": "H4 Zone",
"logFile": "h4_zone_20260207_115528.log",
"generatedAt": "2026-02-07 11:55:28",
"period": "2025-08-01 to 2026-02-07",
"strategy": null,
"totalTrades": 22,
"wins": 18,
"losses": 4,
"winRate": 81.8,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 361.45,
"profitFactor": 4.6,
"maxDrawdown": 0.9,
"maxDrawdownUsd": 0,
"avgWin": 25.65,
"avgLoss": 25.08,
"expectancy": 0,
"sharpeRatio": 8.34,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 10,
"slug": "10_h4_zone_tight_sl",
"name": "H4 Zone Tight Sl",
"logFile": "h4_zone_tight_sl_20260207_115533.log",
"generatedAt": "2026-02-07 11:55:33",
"period": "2025-08-01 to 2026-02-07",
"strategy": null,
"totalTrades": 22,
"wins": 18,
"losses": 4,
"winRate": 81.8,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 403.11,
"profitFactor": 5.02,
"maxDrawdown": 0.9,
"maxDrawdownUsd": 0,
"avgWin": 27.97,
"avgLoss": 25.08,
"expectancy": 0,
"sharpeRatio": 7.6,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 11,
"slug": "11_broker_sl",
"name": "Broker Sl",
"logFile": "broker_sl_20260207_125534.log",
"generatedAt": "2026-02-07 12:55:34",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Broker SL Only Exit (simplified)",
"totalTrades": 421,
"wins": 274,
"losses": 147,
"winRate": 65.1,
"totalProfit": 5167.19,
"totalLoss": 3807.22,
"netPnl": 1359.97,
"profitFactor": 1.36,
"maxDrawdown": 6.7,
"maxDrawdownUsd": 379.43,
"avgWin": 18.86,
"avgLoss": 25.9,
"expectancy": 3.23,
"sharpeRatio": 1.73,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 12,
"slug": "12_stoch_sell_broker_sl",
"name": "Stoch Sell Broker Sl",
"logFile": "stoch_sell_broker_sl_20260207_130300.log",
"generatedAt": "2026-02-07 13:03:00",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Stochastic (K=14) + Sell Filter (ML >= 55%) + Broker SL Only Exit",
"totalTrades": 294,
"wins": 185,
"losses": 109,
"winRate": 62.9,
"totalProfit": 3109.56,
"totalLoss": 2601.98,
"netPnl": 507.59,
"profitFactor": 1.2,
"maxDrawdown": 4.1,
"maxDrawdownUsd": 214.76,
"avgWin": 16.81,
"avgLoss": 23.87,
"expectancy": 1.73,
"sharpeRatio": 1.03,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 13,
"slug": "13_patient_exit",
"name": "Patient Exit",
"logFile": "patient_exit_20260207_134440.log",
"generatedAt": "2026-02-07 13:44:40",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Patient Exit (BE=80, Trail=100/60, Timeout=6h/8h/12h)",
"totalTrades": 602,
"wins": 371,
"losses": 231,
"winRate": 61.6,
"totalProfit": 4736.93,
"totalLoss": 3764.01,
"netPnl": 972.92,
"profitFactor": 1.26,
"maxDrawdown": 6,
"maxDrawdownUsd": 352.41,
"avgWin": 12.77,
"avgLoss": 16.29,
"expectancy": 1.62,
"sharpeRatio": 1.3,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 14,
"slug": "14_stoch_sell_patient",
"name": "Stoch Sell Patient",
"logFile": "stoch_sell_patient_20260207_135336.log",
"generatedAt": "2026-02-07 13:53:36",
"period": "2025-08-01 to 2026-02-07",
"strategy": "SMC-Only v4 + Stochastic (K=14) + Sell Filter (ML >= 55%) + Patient Exit",
"totalTrades": 375,
"wins": 232,
"losses": 143,
"winRate": 61.9,
"totalProfit": 2728.58,
"totalLoss": 2158.28,
"netPnl": 570.3,
"profitFactor": 1.26,
"maxDrawdown": 5,
"maxDrawdownUsd": 270.07,
"avgWin": 11.76,
"avgLoss": 15.09,
"expectancy": 1.52,
"sharpeRatio": 1.33,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 15,
"slug": "15_compression",
"name": "Compression",
"logFile": "compression_20260207_143411.log",
"generatedAt": null,
"period": "2025-08-01 to 2026-02-07",
"strategy": null,
"totalTrades": 566,
"wins": 0,
"losses": 0,
"winRate": 71,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.32,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 9.33,
"avgLoss": 17.34,
"expectancy": 0,
"sharpeRatio": 1.46,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 16,
"slug": "16_quasimodo",
"name": "Quasimodo",
"logFile": "quasimodo_20260207_144520.log",
"generatedAt": null,
"period": "2025-08-01 to 2026-02-07",
"strategy": null,
"totalTrades": 693,
"wins": 0,
"losses": 0,
"winRate": 71.3,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.26,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 1.28,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 17,
"slug": "17_liquidity_sweep",
"name": "Liquidity Sweep",
"logFile": "liq_sweep_20260207_173839.log",
"generatedAt": "2026-02-07 17:38:39",
"period": "2025-08-01 to 2026-02-07",
"strategy": null,
"totalTrades": 649,
"wins": 462,
"losses": 187,
"winRate": 71.2,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 1251.66,
"profitFactor": 1.39,
"maxDrawdown": 5.4,
"maxDrawdownUsd": 300.84,
"avgWin": 9.67,
"avgLoss": 17.19,
"expectancy": 1.93,
"sharpeRatio": 1.83,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 18,
"slug": "18_multi_confirm",
"name": "Multi Confirm",
"logFile": "multi_confirm_20260207_180210.log",
"generatedAt": null,
"period": "2025-08-01 to 2026-02-07",
"strategy": null,
"totalTrades": 581,
"wins": 0,
"losses": 0,
"winRate": 69,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 800.21,
"profitFactor": 1.22,
"maxDrawdown": 6.6,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 1.15,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 19,
"slug": "19_session_optimize",
"name": "Session Optimize",
"logFile": "session_opt_20260207_173705.log",
"generatedAt": null,
"period": "2025-08-01 to 2026-02-07",
"strategy": null,
"totalTrades": 683,
"wins": 0,
"losses": 0,
"winRate": 73.4,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 1794.94,
"profitFactor": 1.54,
"maxDrawdown": 5.2,
"maxDrawdownUsd": 0,
"avgWin": 10.22,
"avgLoss": 18.26,
"expectancy": 0,
"sharpeRatio": 2.41,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 20,
"slug": "20_early_cut",
"name": "Early Cut",
"logFile": "early_cut_20260207_192032.log",
"generatedAt": "2026-02-07 19:20:32.439516",
"period": null,
"strategy": null,
"totalTrades": 685,
"wins": 0,
"losses": 0,
"winRate": 72.4,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.42,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 1.97,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 21,
"slug": "21_combined",
"name": "Combined",
"logFile": "combined_20260207_211844.log",
"generatedAt": "2026-02-07 21:18:44.623588",
"period": null,
"strategy": null,
"totalTrades": 679,
"wins": 0,
"losses": 0,
"winRate": 74.4,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.56,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 2.46,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 22,
"slug": "22_atr_adaptive",
"name": "Atr Adaptive",
"logFile": "atr_adaptive_20260207_220928.log",
"generatedAt": "2026-02-07 22:09:28.783855",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 0,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 0,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 23,
"slug": "23_confidence_weight",
"name": "Confidence Weight",
"logFile": "conf_weight_20260207_220909.log",
"generatedAt": "2026-02-07 22:09:09.940414",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 0,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 0,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 24,
"slug": "24_final_combined",
"name": "Final Combined",
"logFile": "final_combined_20260207_222406.log",
"generatedAt": "2026-02-07 22:24:06.260094",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.63,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 2.56,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 26,
"slug": "26_sell_improvement",
"name": "Sell Improvement",
"logFile": "sell_improve_20260207_233806.log",
"generatedAt": "2026-02-07 23:38:06.417970",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.77,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 2.87,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 27,
"slug": "27_regime_aware",
"name": "Regime Aware",
"logFile": "regime_aware_20260208_053630.log",
"generatedAt": "2026-02-08 05:36:30.925686",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.74,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 2.75,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 28,
"slug": "28_smart_breakeven",
"name": "Smart Breakeven",
"logFile": "smart_be_20260208_060756.log",
"generatedAt": "2026-02-08 06:07:56.973899",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.7,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 2.69,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 29,
"slug": "29_confluence_scoring",
"name": "Confluence Scoring",
"logFile": "confluence_20260208_064829.log",
"generatedAt": "2026-02-08 06:48:29.232714",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.49,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 2.21,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 30,
"slug": "30_dynamic_rr",
"name": "Dynamic Rr",
"logFile": "dynamic_rr_20260208_071602.log",
"generatedAt": "2026-02-08 07:16:02.652327",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.8,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 3.17,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 31,
"slug": "31_multi_tf_h1",
"name": "Multi Tf H1",
"logFile": "multi_tf_20260208_091856.log",
"generatedAt": "2026-02-08 09:18:56.106348",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 1.64,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 2.49,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 32,
"slug": "32_ml_exit",
"name": "Ml Exit",
"logFile": "ml_exit_20260208_102500.log",
"generatedAt": "2026-02-08 10:25:00.005539",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 2.19,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 3.97,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 33,
"slug": "33_impulse_trail",
"name": "Impulse Trail",
"logFile": "impulse_trail_20260208_114314.log",
"generatedAt": "2026-02-08 11:43:14.406433",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 2.21,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 4.02,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 34,
"slug": "34_ml_v2d",
"name": "Ml V2d",
"logFile": "ml_v2d_time_filter_20260208_221541.log",
"generatedAt": "2026-02-08 22:15:41.592688",
"period": null,
"strategy": null,
"totalTrades": 625,
"wins": 0,
"losses": 0,
"winRate": 81.9,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 2.22,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 4.04,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 34,
"slug": "34_time_filter",
"name": "Time Filter",
"logFile": "time_filter_20260208_124324.log",
"generatedAt": "2026-02-08 12:43:24.700789",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 2.43,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 4.41,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
},
{
"id": 35,
"slug": "35_fix_sl_bug",
"name": "Fix Sl Bug",
"logFile": "fix_sl_bug_20260208_184506.log",
"generatedAt": "2026-02-08 18:45:06.641730",
"period": null,
"strategy": null,
"totalTrades": 0,
"wins": 0,
"losses": 0,
"winRate": 0,
"totalProfit": 0,
"totalLoss": 0,
"netPnl": 0,
"profitFactor": 2.43,
"maxDrawdown": 0,
"maxDrawdownUsd": 0,
"avgWin": 0,
"avgLoss": 0,
"expectancy": 0,
"sharpeRatio": 4.41,
"exitReasons": [],
"directionBreakdown": [],
"sessionBreakdown": [],
"tradeCount": 0
}
];
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
"use client";
import { useState, useRef, useCallback, useEffect } from "react";
import { usePrevious } from "./use-previous";
interface AnimatedValueResult {
displayValue: number;
direction: "up" | "down" | null;
changeKey: number;
}
/**
* Smoothly lerps a numeric value over `duration` ms using requestAnimationFrame.
* Returns direction ("up"/"down"/null) and a changeKey that increments on each
* value change use it as a React `key` to replay CSS flash animations.
*/
export function useAnimatedValue(value: number, duration = 300): AnimatedValueResult {
const prev = usePrevious(value);
const [displayValue, setDisplayValue] = useState(value);
const [direction, setDirection] = useState<"up" | "down" | null>(null);
const [changeKey, setChangeKey] = useState(0);
const rafRef = useRef<number>(0);
const startRef = useRef(0);
const fromRef = useRef(value);
const animate = useCallback(
(from: number, to: number) => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
fromRef.current = from;
startRef.current = performance.now();
const step = (now: number) => {
const elapsed = now - startRef.current;
const t = Math.min(elapsed / duration, 1);
// ease-out cubic
const eased = 1 - Math.pow(1 - t, 3);
const current = fromRef.current + (to - fromRef.current) * eased;
setDisplayValue(current);
if (t < 1) {
rafRef.current = requestAnimationFrame(step);
}
};
rafRef.current = requestAnimationFrame(step);
},
[duration]
);
useEffect(() => {
if (prev !== undefined && prev !== value) {
setDirection(value > prev ? "up" : "down");
setChangeKey((k) => k + 1);
animate(displayValue, value);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [value]);
// Clear direction after flash duration
useEffect(() => {
if (direction === null) return;
const timer = setTimeout(() => setDirection(null), 600);
return () => clearTimeout(timer);
}, [direction, changeKey]);
// Cleanup raf on unmount
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, []);
return { displayValue, direction, changeKey };
}
@@ -0,0 +1,79 @@
"use client";
import { useState, useEffect } from "react";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
export interface FeatureImportance {
name: string;
importance: number;
}
export interface ModelMetrics {
featureImportance: FeatureImportance[];
trainAuc: number;
testAuc: number;
sampleCount: number;
updatedAt: string | null;
}
export interface TrainingRun {
id: number;
started_at: string;
completed_at: string;
train_auc: number;
test_auc: number;
sample_count: number;
features_used: number;
trigger_reason: string;
}
export interface RegimeDistribution {
regime: string;
count: number;
}
export function useModelMetrics() {
const [metrics, setMetrics] = useState<ModelMetrics | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/api/model/metrics`)
.then((r) => r.json())
.then(setMetrics)
.catch(() => setMetrics(null))
.finally(() => setLoading(false));
}, []);
return { metrics, loading };
}
export function useTrainingHistory() {
const [runs, setRuns] = useState<TrainingRun[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/api/model/training-history`)
.then((r) => r.json())
.then((data) => setRuns(data.runs || []))
.catch(() => setRuns([]))
.finally(() => setLoading(false));
}, []);
return { runs, loading };
}
export function useRegimeDistribution() {
const [distribution, setDistribution] = useState<RegimeDistribution[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/api/model/regime-distribution`)
.then((r) => r.json())
.then((data) => setDistribution(data.distribution || []))
.catch(() => setDistribution([]))
.finally(() => setLoading(false));
}, []);
return { distribution, loading };
}
+11
View File
@@ -0,0 +1,11 @@
"use client";
import { useRef, useEffect } from "react";
export function usePrevious<T>(value: T): T | undefined {
const ref = useRef<T | undefined>(undefined);
useEffect(() => {
ref.current = value;
});
return ref.current;
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { Signal, SignalStats } from "@/types/signals";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
interface SignalFilters {
page: number;
limit: number;
type: string;
executed: string;
startDate: string;
endDate: string;
}
export function useSignals(filters: SignalFilters) {
const [signals, setSignals] = useState<Signal[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const fetchSignals = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(filters.page),
limit: String(filters.limit),
type: filters.type,
executed: filters.executed,
});
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
const res = await fetch(`${API_URL}/api/signals?${params}`);
const json = await res.json();
setSignals(json.signals || []);
setTotal(json.total || 0);
} catch {
setSignals([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [filters.page, filters.limit, filters.type, filters.executed, filters.startDate, filters.endDate]);
useEffect(() => {
fetchSignals();
}, [fetchSignals]);
return { signals, total, loading, refetch: fetchSignals };
}
export function useSignalStats(hours: number = 24) {
const [stats, setStats] = useState<SignalStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(`${API_URL}/api/signals/stats?hours=${hours}`)
.then((r) => r.json())
.then(setStats)
.catch(() => setStats(null))
.finally(() => setLoading(false));
}, [hours]);
return { stats, loading };
}
@@ -0,0 +1,30 @@
"use client";
import { useState, useEffect } from "react";
/**
* Returns a boolean[] of length `count`. Each item becomes `true` after
* `index * delayMs` milliseconds. Runs once on mount only.
*/
export function useStaggerEntry(count: number, delayMs = 40): boolean[] {
const [visible, setVisible] = useState<boolean[]>(() => Array(count).fill(false));
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
for (let i = 0; i < count; i++) {
timers.push(
setTimeout(() => {
setVisible((prev) => {
const next = [...prev];
next[i] = true;
return next;
});
}, i * delayMs)
);
}
return () => timers.forEach(clearTimeout);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return visible;
}
+35
View File
@@ -0,0 +1,35 @@
"use client";
import { useEffect, useState, useCallback } from "react";
type Theme = "light" | "dark";
export function useTheme() {
const [theme, setThemeState] = useState<Theme>("light");
useEffect(() => {
// Read from localStorage or system preference
const stored = localStorage.getItem("theme") as Theme | null;
if (stored === "dark" || stored === "light") {
setThemeState(stored);
} else if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
setThemeState("dark");
}
}, []);
useEffect(() => {
const root = document.documentElement;
if (theme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === "dark" ? "light" : "dark"));
}, []);
return { theme, toggleTheme, setTheme: setThemeState };
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { Trade, TradeStats, EquityCurvePoint } from "@/types/trades";
const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000";
interface TradeFilters {
page: number;
limit: number;
direction: string;
startDate: string;
endDate: string;
}
export function useTrades(filters: TradeFilters) {
const [trades, setTrades] = useState<Trade[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const fetchTrades = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams({
page: String(filters.page),
limit: String(filters.limit),
direction: filters.direction,
});
if (filters.startDate) params.set("start_date", filters.startDate);
if (filters.endDate) params.set("end_date", filters.endDate);
const res = await fetch(`${API_URL}/api/trades?${params}`);
const json = await res.json();
setTrades(json.trades || []);
setTotal(json.total || 0);
} catch {
setTrades([]);
setTotal(0);
} finally {
setLoading(false);
}
}, [filters.page, filters.limit, filters.direction, filters.startDate, filters.endDate]);
useEffect(() => {
fetchTrades();
}, [fetchTrades]);
return { trades, total, loading, refetch: fetchTrades };
}
export function useTradeStats(startDate: string, endDate: string) {
const [stats, setStats] = useState<TradeStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams();
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);
fetch(`${API_URL}/api/trades/stats?${params}`)
.then((r) => r.json())
.then(setStats)
.catch(() => setStats(null))
.finally(() => setLoading(false));
}, [startDate, endDate]);
return { stats, loading };
}
export function useEquityCurve(startDate: string, endDate: string) {
const [points, setPoints] = useState<EquityCurvePoint[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const params = new URLSearchParams();
if (startDate) params.set("start_date", startDate);
if (endDate) params.set("end_date", endDate);
fetch(`${API_URL}/api/trades/equity-curve?${params}`)
.then((r) => r.json())
.then((data) => setPoints(data.points || []))
.catch(() => setPoints([]))
.finally(() => setLoading(false));
}, [startDate, endDate]);
return { points, loading };
}
+30
View File
@@ -0,0 +1,30 @@
export interface Signal {
id: number;
signal_time: string;
signal_type: "BUY" | "SELL" | "HOLD";
confidence: number;
executed: boolean;
execution_reason: string;
regime: string;
session: string;
smc_signal: string;
ml_signal: string;
entry_price: number;
sl_price: number;
tp_price: number;
}
export interface SignalStats {
total: number;
executed: number;
executionRate: number;
avgConfidence: number;
byType: Record<string, number>;
}
export interface SignalsResponse {
signals: Signal[];
total: number;
page: number;
limit: number;
}
+43
View File
@@ -0,0 +1,43 @@
export interface Trade {
id: number;
ticket: number;
direction: "BUY" | "SELL";
entry_price: number;
exit_price: number;
lot_size: number;
profit_usd: number;
profit_pips: number;
sl_price: number;
tp_price: number;
opened_at: string;
closed_at: string;
exit_reason: string;
confidence: number;
regime: string;
session: string;
duration_minutes: number;
}
export interface TradeStats {
totalTrades: number;
winRate: number;
netProfit: number;
profitFactor: number;
avgWin: number;
avgLoss: number;
bestTrade: number;
worstTrade: number;
}
export interface EquityCurvePoint {
time: string;
profit: number;
cumulative: number;
}
export interface TradesResponse {
trades: Trade[];
total: number;
page: number;
limit: number;
}
+77 -47
View File
@@ -1,7 +1,7 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: ['class', '.dark'],
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
@@ -10,95 +10,113 @@ const config: Config = {
theme: {
extend: {
colors: {
// Dark theme colors (nof1.ai / SURGE-AI inspired)
background: 'hsl(var(--background))',
foreground: 'hsl(var(--foreground))',
surface: 'hsl(var(--surface))',
'surface-light': 'hsl(var(--surface-light))',
'surface-hover': 'hsl(var(--surface-hover))',
// Apple Liquid Glass theme colors
background: 'var(--color-background)',
foreground: 'var(--color-foreground)',
surface: 'var(--color-surface)',
'surface-light': 'var(--color-surface-light)',
'surface-hover': 'var(--color-surface-hover)',
card: {
DEFAULT: 'hsl(var(--card))',
foreground: 'hsl(var(--card-foreground))',
DEFAULT: 'var(--color-card)',
foreground: 'var(--color-card-foreground)',
},
popover: {
DEFAULT: 'hsl(var(--popover))',
foreground: 'hsl(var(--popover-foreground))',
DEFAULT: 'var(--color-popover)',
foreground: 'var(--color-popover-foreground)',
},
primary: {
DEFAULT: 'hsl(var(--primary))',
foreground: 'hsl(var(--primary-foreground))',
dark: 'hsl(var(--primary-dark))',
DEFAULT: 'var(--color-primary)',
foreground: 'var(--color-primary-foreground)',
dark: 'var(--color-primary-dark)',
},
secondary: {
DEFAULT: 'hsl(var(--secondary))',
foreground: 'hsl(var(--secondary-foreground))',
DEFAULT: 'var(--color-secondary)',
foreground: 'var(--color-secondary-foreground)',
},
muted: {
DEFAULT: 'hsl(var(--muted))',
foreground: 'hsl(var(--muted-foreground))',
DEFAULT: 'var(--color-muted)',
foreground: 'var(--color-muted-foreground)',
},
accent: {
DEFAULT: 'hsl(var(--accent))',
foreground: 'hsl(var(--accent-foreground))',
DEFAULT: 'var(--color-accent)',
foreground: 'var(--color-accent-foreground)',
},
destructive: {
DEFAULT: 'hsl(var(--destructive))',
foreground: 'hsl(var(--destructive-foreground))',
DEFAULT: 'var(--color-destructive)',
foreground: 'var(--color-destructive-foreground)',
},
border: {
DEFAULT: 'hsl(var(--border))',
light: 'hsl(var(--border-light))',
DEFAULT: 'var(--color-border)',
light: 'var(--color-border-light)',
},
input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))',
input: 'var(--color-input)',
ring: 'var(--color-ring)',
// Semantic colors
success: {
DEFAULT: 'hsl(var(--success))',
bg: 'hsl(var(--success-bg))',
DEFAULT: 'var(--color-success)',
bg: 'var(--color-success-bg)',
},
warning: {
DEFAULT: 'hsl(var(--warning))',
bg: 'hsl(var(--warning-bg))',
DEFAULT: 'var(--color-warning)',
bg: 'var(--color-warning-bg)',
},
danger: {
DEFAULT: 'hsl(var(--danger))',
bg: 'hsl(var(--danger-bg))',
DEFAULT: 'var(--color-danger)',
bg: 'var(--color-danger-bg)',
},
info: {
DEFAULT: 'hsl(var(--info))',
bg: 'hsl(var(--info-bg))',
DEFAULT: 'var(--color-info)',
bg: 'var(--color-info-bg)',
},
// Apple system colors for direct use
apple: {
green: 'var(--apple-green)',
blue: 'var(--apple-blue)',
red: 'var(--apple-red)',
orange: 'var(--apple-orange)',
purple: 'var(--apple-purple)',
cyan: 'var(--apple-cyan)',
pink: 'var(--apple-pink)',
indigo: 'var(--apple-indigo)',
teal: 'var(--apple-teal)',
mint: 'var(--apple-mint)',
},
// Chart colors
chart: {
'1': 'hsl(var(--chart-1))',
'2': 'hsl(var(--chart-2))',
'3': 'hsl(var(--chart-3))',
'4': 'hsl(var(--chart-4))',
'5': 'hsl(var(--chart-5))',
'1': 'var(--color-chart-1)',
'2': 'var(--color-chart-2)',
'3': 'var(--color-chart-3)',
'4': 'var(--color-chart-4)',
'5': 'var(--color-chart-5)',
},
},
borderRadius: {
lg: 'var(--radius)',
md: 'calc(var(--radius) - 2px)',
sm: 'calc(var(--radius) - 4px)',
lg: 'var(--radius-lg)',
md: 'var(--radius-md)',
sm: 'var(--radius-sm)',
xl: 'var(--radius-xl)',
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
sans: ['var(--font-ibm-plex-sans)', 'IBM Plex Sans', 'system-ui', 'sans-serif'],
mono: ['var(--font-ibm-plex-mono)', 'IBM Plex Mono', 'monospace'],
},
animation: {
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'fade-in': 'fadeIn 0.3s ease-in-out',
'fade-in': 'fadeIn 0.3s ease-out',
'slide-up': 'slideUp 0.3s ease-out',
'shimmer': 'shimmer 1.5s infinite',
'flash-green': 'flashGreen 0.6s ease-out',
'flash-red': 'flashRed 0.6s ease-out',
'bar-slide-in': 'barSlideIn 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
'0%': { opacity: '0', transform: 'translateY(6px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
@@ -108,6 +126,18 @@ const config: Config = {
'0%': { backgroundPosition: '-200% 0' },
'100%': { backgroundPosition: '200% 0' },
},
flashGreen: {
'0%': { backgroundColor: 'rgba(52, 199, 89, 0.25)' },
'100%': { backgroundColor: 'transparent' },
},
flashRed: {
'0%': { backgroundColor: 'rgba(255, 59, 48, 0.25)' },
'100%': { backgroundColor: 'transparent' },
},
barSlideIn: {
'0%': { transform: 'scaleX(0)' },
'100%': { transform: 'scaleX(1)' },
},
},
},
},