feat: add 5 dashboard features — dark mode, trade history, backtests, model insights, alerts
- Dark mode: class-based theme toggle with localStorage persistence and flash prevention - Trade History (/trades): paginated table, stats cards, equity curve chart with DB API endpoints - Backtest Viewer (/backtests): log parser for 35 backtest results, sidebar + detail + comparison tabs - Model Insights: dashboard card + dialog showing feature importance, regime distribution, training history - Alert/Signal Log (/alerts): signal stats, filterable table with execution tracking - API: 8 new endpoints with psycopg2 DB connection pool - Dark mode sweep across books page, about dialog, and all dashboard components - Architecture docs rewritten with Mermaid diagrams (23 docs) - README and FEATURES.md rewritten bilingual (Indonesian + English) - main_live.py: write model_metrics.json on startup and retrain Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
f7ca8003ce
commit
d93d790428
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user