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