Files
cointrader/dashboard/ui/src/App.jsx
21in7 e3623293f7 fix(dashboard): trades pagination + reproducible Docker build
- Add pagination controls to Trades tab (prev/next, offset support)
- Reset page on symbol change
- Use package-lock.json + npm ci for reproducible UI builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:15:48 +09:00

771 lines
34 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useEffect, useCallback, useRef } from "react";
import {
BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer,
AreaChart, Area, LineChart, Line, CartesianGrid, Cell,
} from "recharts";
/* ── API ──────────────────────────────────────────────────────── */
const api = async (path) => {
try {
const r = await fetch(`/api${path}`);
if (!r.ok) throw new Error(r.statusText);
return await r.json();
} catch (e) {
console.error(`API [${path}]:`, e);
return null;
}
};
/* ── 유틸 ─────────────────────────────────────────────────────── */
const fmt = (n, d = 4) => (n != null ? Number(n).toFixed(d) : "—");
const fmtTime = (iso) => {
if (!iso) return "—";
const d = new Date(iso);
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
};
const fmtDate = (s) => (s ? s.slice(5, 10).replace("-", "/") : "—");
const pnlColor = (v) => (v > 0 ? "#34d399" : v < 0 ? "#f87171" : "rgba(255,255,255,0.5)");
const pnlSign = (v) => (v > 0 ? `+${fmt(v)}` : fmt(v));
/* ── 스타일 변수 ──────────────────────────────────────────────── */
const S = {
sans: "'Satoshi','DM Sans',system-ui,sans-serif",
mono: "'JetBrains Mono','Fira Code',monospace",
bg: "#08080f",
surface: "rgba(255,255,255,0.015)",
surface2: "rgba(255,255,255,0.03)",
border: "rgba(255,255,255,0.06)",
text3: "rgba(255,255,255,0.35)",
text4: "rgba(255,255,255,0.2)",
green: "#34d399",
red: "#f87171",
indigo: "#818cf8",
amber: "#f59e0b",
};
/* ── Badge ────────────────────────────────────────────────────── */
const Badge = ({ children, bg = "rgba(255,255,255,0.06)", color = "rgba(255,255,255,0.5)" }) => (
<span style={{
display: "inline-block", fontSize: 10, fontWeight: 600, padding: "2px 8px",
borderRadius: 6, background: bg, color, fontFamily: S.mono,
letterSpacing: 0.5, marginLeft: 4,
}}>{children}</span>
);
/* ── StatCard ─────────────────────────────────────────────────── */
const StatCard = ({ icon, label, value, sub, accent }) => (
<div style={{
background: `linear-gradient(135deg, ${S.surface2} 0%, rgba(255,255,255,0.008) 100%)`,
border: `1px solid ${S.border}`, borderRadius: 14,
padding: "18px 20px", position: "relative", overflow: "hidden",
}}>
<div style={{
position: "absolute", top: -20, right: -20, width: 70, height: 70,
borderRadius: "50%", background: accent, filter: "blur(28px)",
}} />
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.5,
textTransform: "uppercase", fontFamily: S.mono, marginBottom: 6,
}}>
{icon && <span style={{ marginRight: 5 }}>{icon}</span>}{label}
</div>
<div style={{ fontSize: 26, fontWeight: 700, color: "#fff", fontFamily: S.sans, letterSpacing: -0.5 }}>
{value}
</div>
{sub && (
<div style={{ fontSize: 11, color: accent, fontFamily: S.mono, marginTop: 2 }}>{sub}</div>
)}
</div>
);
/* ── ChartTooltip ─────────────────────────────────────────────── */
const ChartTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
return (
<div style={{
background: "rgba(10,10,18,0.95)", border: "1px solid rgba(255,255,255,0.08)",
borderRadius: 10, padding: "10px 14px", fontSize: 11, fontFamily: S.mono,
}}>
<div style={{ color: "rgba(255,255,255,0.4)", marginBottom: 4 }}>{label}</div>
{payload.filter(p => p.name !== "과매수" && p.name !== "과매도" && p.name !== "임계값").map((p, i) => (
<div key={i} style={{ color: p.color || "#fff", marginBottom: 1 }}>
{p.name}: {typeof p.value === "number" ? p.value.toFixed(4) : p.value}
</div>
))}
</div>
);
};
/* ── TradeRow ──────────────────────────────────────────────────── */
const TradeRow = ({ trade, isExpanded, onToggle }) => {
const pnl = trade.net_pnl || 0;
const isShort = trade.direction === "SHORT";
const priceDiff = trade.entry_price && trade.exit_price
? ((trade.entry_price - trade.exit_price) / trade.entry_price * 100 * (isShort ? 1 : -1)).toFixed(2)
: "—";
const sections = [
{
title: "리스크 관리",
items: [
["손절가 (SL)", trade.sl, S.red],
["익절가 (TP)", trade.tp, S.green],
["수량", trade.quantity, "rgba(255,255,255,0.6)"],
],
},
{
title: "기술 지표",
items: [
["RSI", trade.rsi, trade.rsi > 70 ? S.amber : S.indigo],
["MACD Hist", trade.macd_hist, trade.macd_hist >= 0 ? S.green : S.red],
["ATR", trade.atr, "rgba(255,255,255,0.6)"],
],
},
{
title: "손익 상세",
items: [
["예상 수익", trade.expected_pnl, S.green],
["순수익", trade.net_pnl, pnlColor(trade.net_pnl)],
["수수료", trade.commission ? -trade.commission : null, S.red],
],
},
];
return (
<div style={{ marginBottom: 6 }}>
<div
onClick={onToggle}
style={{
background: isExpanded ? "rgba(99,102,241,0.06)" : S.surface,
border: `1px solid ${isExpanded ? "rgba(99,102,241,0.15)" : "rgba(255,255,255,0.04)"}`,
borderRadius: isExpanded ? "14px 14px 0 0" : 14,
padding: "14px 18px", cursor: "pointer",
display: "grid",
gridTemplateColumns: "36px 1.5fr 0.8fr 0.8fr 0.8fr 32px",
alignItems: "center", gap: 10, transition: "all 0.15s ease",
}}
>
<div style={{
width: 30, height: 30, borderRadius: 8,
background: isShort ? "rgba(239,68,68,0.1)" : "rgba(52,211,153,0.1)",
display: "flex", alignItems: "center", justifyContent: "center",
fontSize: 12, fontWeight: 700,
color: isShort ? S.red : S.green, fontFamily: S.mono,
}}>
{isShort ? "S" : "L"}
</div>
<div>
<div style={{ fontSize: 13, fontWeight: 600, color: "#fff", fontFamily: S.sans }}>
{(trade.symbol || "XRPUSDT").replace("USDT", "/USDT")}
<Badge
bg={isShort ? "rgba(239,68,68,0.1)" : "rgba(52,211,153,0.1)"}
color={isShort ? S.red : S.green}
>
{trade.direction}
</Badge>
<Badge>{trade.leverage || 10}x</Badge>
</div>
<div style={{ fontSize: 10, color: S.text3, marginTop: 2, fontFamily: S.mono }}>
{fmtDate(trade.entry_time)} {fmtTime(trade.entry_time)} {fmtTime(trade.exit_time)}
{trade.close_reason && (
<span style={{ marginLeft: 6, color: S.text4 }}>({trade.close_reason})</span>
)}
</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.6)", fontFamily: S.mono }}>
{fmt(trade.entry_price)}
</div>
<div style={{ fontSize: 9, color: S.text4 }}>진입가</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 12, color: "rgba(255,255,255,0.6)", fontFamily: S.mono }}>
{fmt(trade.exit_price)}
</div>
<div style={{ fontSize: 9, color: S.text4 }}>청산가</div>
</div>
<div style={{ textAlign: "right" }}>
<div style={{ fontSize: 14, fontWeight: 700, color: pnlColor(pnl), fontFamily: S.mono }}>
{pnlSign(pnl)}
</div>
<div style={{ fontSize: 9, color: pnlColor(pnl), opacity: 0.7 }}>{priceDiff}%</div>
</div>
<div style={{
textAlign: "center", color: S.text4, fontSize: 12,
transition: "transform 0.15s",
transform: isExpanded ? "rotate(180deg)" : "",
}}></div>
</div>
{isExpanded && (
<div style={{
background: "rgba(99,102,241,0.025)",
border: "1px solid rgba(99,102,241,0.15)",
borderTop: "none", borderRadius: "0 0 14px 14px",
padding: "18px 22px",
display: "grid", gridTemplateColumns: "1fr 1fr 1fr", gap: 14,
}}>
{sections.map((sec, si) => (
<div key={si}>
<div style={{
fontSize: 9, color: S.text4, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 10,
}}>
{sec.title}
</div>
{sec.items.map(([label, val, color], ii) => (
<div key={ii} style={{ display: "flex", justifyContent: "space-between", marginBottom: 5 }}>
<span style={{ fontSize: 11, color: "rgba(255,255,255,0.4)" }}>{label}</span>
<span style={{ fontSize: 11, color, fontFamily: S.mono }}>
{val != null ? fmt(val) : "—"}
</span>
</div>
))}
</div>
))}
</div>
)}
</div>
);
};
/* ── 차트 컨테이너 ────────────────────────────────────────────── */
const ChartBox = ({ title, children }) => (
<div style={{
background: S.surface, border: `1px solid rgba(255,255,255,0.05)`,
borderRadius: 14, padding: 18,
}}>
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 14,
}}>
{title}
</div>
{children}
</div>
);
/* ── 탭 정의 ──────────────────────────────────────────────────── */
const TABS = [
{ id: "overview", label: "Overview", icon: "◆" },
{ id: "trades", label: "Trades", icon: "◈" },
{ id: "chart", label: "Chart", icon: "◇" },
];
/* ═══════════════════════════════════════════════════════════════ */
/* 메인 대시보드 */
/* ═══════════════════════════════════════════════════════════════ */
export default function App() {
const [tab, setTab] = useState("overview");
const [expanded, setExpanded] = useState(null);
const [isLive, setIsLive] = useState(false);
const [lastUpdate, setLastUpdate] = useState(null);
const [symbols, setSymbols] = useState([]);
const symbolsRef = useRef([]);
const [selectedSymbol, setSelectedSymbol] = useState(null); // null = ALL
const [stats, setStats] = useState({
total_trades: 0, wins: 0, losses: 0,
total_pnl: 0, total_fees: 0, avg_pnl: 0,
best_trade: 0, worst_trade: 0,
});
const [positions, setPositions] = useState([]);
const [botStatus, setBotStatus] = useState({});
const [trades, setTrades] = useState([]);
const [tradesTotal, setTradesTotal] = useState(0);
const [tradesPage, setTradesPage] = useState(0);
const [daily, setDaily] = useState([]);
const [candles, setCandles] = useState([]);
/* ── 데이터 폴링 ─────────────────────────────────────────── */
const fetchAll = useCallback(async () => {
const sym = selectedSymbol ? `?symbol=${selectedSymbol}` : "";
const symRequired = selectedSymbol || symbolsRef.current[0] || "XRPUSDT";
const [symRes, sRes, pRes, tRes, dRes, cRes] = await Promise.all([
api("/symbols"),
api(`/stats${sym}`),
api(`/position${sym}`),
api(`/trades${sym}${sym ? "&" : "?"}limit=50&offset=${tradesPage * 50}`),
api(`/daily${sym}`),
api(`/candles?symbol=${symRequired}&limit=96`),
]);
if (symRes?.symbols) {
symbolsRef.current = symRes.symbols;
setSymbols(symRes.symbols);
}
if (sRes && sRes.total_trades !== undefined) {
setStats(sRes);
setIsLive(true);
setLastUpdate(new Date());
}
if (pRes) {
setPositions(pRes.positions || []);
if (pRes.bot) setBotStatus(pRes.bot);
}
if (tRes?.trades) {
setTrades(tRes.trades);
setTradesTotal(tRes.total || tRes.trades.length);
}
if (dRes?.daily) setDaily(dRes.daily);
if (cRes?.candles) setCandles(cRes.candles);
}, [selectedSymbol, tradesPage]);
useEffect(() => {
fetchAll();
const iv = setInterval(fetchAll, 15000);
return () => clearInterval(iv);
}, [fetchAll]);
/* ── 파생 데이터 ─────────────────────────────────────────── */
const winRate = stats.total_trades > 0
? ((stats.wins / stats.total_trades) * 100).toFixed(0) : "0";
// 일별 → 날짜순 정렬 (오래된 순)
const dailyAsc = [...daily].reverse();
const dailyLabels = dailyAsc.map((d) => fmtDate(d.date));
const dailyPnls = dailyAsc.map((d) => d.net_pnl || 0);
// 누적 수익
const cumData = [];
let cum = 0;
dailyAsc.forEach((d) => {
cum += d.net_pnl || 0;
cumData.push({ date: fmtDate(d.date), cumPnl: +cum.toFixed(4) });
});
// 캔들 차트용
const candleLabels = candles.map((c) => fmtTime(c.ts));
/* ── 현재 가격 (봇 상태 또는 마지막 캔들) ──────────────────── */
const currentPrice = selectedSymbol
? (botStatus[`${selectedSymbol}:current_price`] || (candles.length ? candles[candles.length - 1].price : null))
: (candles.length ? candles[candles.length - 1].price : null);
/* ── 공통 차트 축 스타일 ─────────────────────────────────── */
const axisStyle = {
tick: { fill: "rgba(255,255,255,0.25)", fontSize: 10, fontFamily: "JetBrains Mono" },
axisLine: false, tickLine: false,
};
return (
<div style={{
minHeight: "100vh", background: S.bg, color: "#fff",
fontFamily: S.sans, padding: "28px 20px",
position: "relative", overflow: "hidden",
}}>
{/* BG glow */}
<div style={{
position: "fixed", inset: 0, pointerEvents: "none",
background: "radial-gradient(ellipse 50% 35% at 15% 5%,rgba(99,102,241,0.05) 0%,transparent 70%),radial-gradient(ellipse 40% 40% at 85% 90%,rgba(52,211,153,0.03) 0%,transparent 70%)",
}} />
<div style={{ maxWidth: 960, margin: "0 auto", position: "relative" }}>
{/* ═══ 헤더 ═══════════════════════════════════════════ */}
<div style={{
display: "flex", justifyContent: "space-between",
alignItems: "flex-start", marginBottom: 28, flexWrap: "wrap", gap: 16,
}}>
<div>
<div style={{
display: "flex", alignItems: "center", gap: 10, marginBottom: 6,
}}>
<div style={{
width: 8, height: 8, borderRadius: "50%",
background: isLive ? S.green : S.amber,
boxShadow: isLive
? "0 0 10px rgba(52,211,153,0.5)"
: "0 0 10px rgba(245,158,11,0.5)",
animation: "pulse 2s infinite",
}} />
<span style={{
fontSize: 10, color: S.text3, letterSpacing: 2,
textTransform: "uppercase", fontFamily: S.mono,
}}>
{isLive ? "Live" : "Connecting…"}
{selectedSymbol
? ` · ${selectedSymbol.replace("USDT", "/USDT")}`
: ` · ${symbols.length} symbols`}
{currentPrice && (
<span style={{ color: "rgba(255,255,255,0.5)", marginLeft: 8 }}>
{fmt(currentPrice)}
</span>
)}
</span>
</div>
<h1 style={{ fontSize: 28, fontWeight: 700, margin: 0, letterSpacing: -0.8 }}>
Trading Dashboard
</h1>
</div>
{/* 오픈 포지션 — 복수 표시 */}
{positions.length > 0 && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{positions.map((pos) => {
const curP = parseFloat(botStatus[`${pos.symbol}:current_price`] || 0);
const entP = parseFloat(pos.entry_price || 0);
const isShort = pos.direction === "SHORT";
const uPnl = botStatus[`${pos.symbol}:unrealized_pnl`];
const uPnlPct = botStatus[`${pos.symbol}:unrealized_pnl_pct`];
const pnlPct = uPnlPct != null
? parseFloat(uPnlPct)
: (entP > 0 && curP > 0
? ((isShort ? entP - curP : curP - entP) / entP * 100 * (pos.leverage || 10))
: null);
const pnlUsdt = uPnl != null ? parseFloat(uPnl) : null;
const posPnlColor = pnlPct > 0 ? S.green : pnlPct < 0 ? S.red : S.text3;
return (
<div key={pos.id} style={{
background: "linear-gradient(135deg,rgba(99,102,241,0.08) 0%,rgba(99,102,241,0.02) 100%)",
border: "1px solid rgba(99,102,241,0.15)", borderRadius: 14,
padding: "12px 18px",
}}>
<div style={{ fontSize: 9, color: S.text3, letterSpacing: 1.2, fontFamily: S.mono, marginBottom: 4 }}>
{(pos.symbol || "").replace("USDT", "/USDT")}
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Badge
bg={isShort ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
color={isShort ? S.red : S.green}
>
{pos.direction} {pos.leverage || 10}x
</Badge>
<span style={{ fontSize: 14, fontWeight: 700, fontFamily: S.mono }}>
{fmt(pos.entry_price)}
</span>
{pnlPct !== null && (
<span style={{ fontSize: 13, fontWeight: 700, fontFamily: S.mono, color: posPnlColor }}>
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
</span>
)}
</div>
</div>
);
})}
</div>
)}
</div>
{/* ═══ 심볼 필터 ═══════════════════════════════════════ */}
<div style={{
display: "flex", gap: 4, marginBottom: 12,
background: "rgba(255,255,255,0.02)", borderRadius: 12,
padding: 4, width: "fit-content",
}}>
<button
onClick={() => { setSelectedSymbol(null); setTradesPage(0); }}
style={{
background: selectedSymbol === null ? "rgba(99,102,241,0.15)" : "transparent",
border: "none",
color: selectedSymbol === null ? S.indigo : S.text3,
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
}}
>ALL</button>
{symbols.map((sym) => (
<button
key={sym}
onClick={() => { setSelectedSymbol(sym); setTradesPage(0); }}
style={{
background: selectedSymbol === sym ? "rgba(99,102,241,0.15)" : "transparent",
border: "none",
color: selectedSymbol === sym ? S.indigo : S.text3,
padding: "6px 14px", borderRadius: 8, cursor: "pointer",
fontSize: 11, fontWeight: 600, fontFamily: S.mono,
}}
>{sym.replace("USDT", "")}</button>
))}
</div>
{/* ═══ 탭 ═════════════════════════════════════════════ */}
<div style={{
display: "flex", gap: 4, marginBottom: 24,
background: "rgba(255,255,255,0.02)", borderRadius: 12,
padding: 4, width: "fit-content",
}}>
{TABS.map((t) => (
<button
key={t.id}
onClick={() => setTab(t.id)}
style={{
background: tab === t.id ? "rgba(255,255,255,0.08)" : "transparent",
border: "none",
color: tab === t.id ? "#fff" : S.text3,
padding: "8px 18px", borderRadius: 9, cursor: "pointer",
fontSize: 12, fontWeight: 500, fontFamily: S.sans,
transition: "all 0.15s",
}}
>
<span style={{ marginRight: 6, fontSize: 10 }}>{t.icon}</span>
{t.label}
</button>
))}
</div>
{/* ═══ OVERVIEW ═══════════════════════════════════════ */}
{tab === "overview" && (
<div>
{/* Stats */}
<div style={{
display: "grid", gridTemplateColumns: "repeat(4,1fr)",
gap: 10, marginBottom: 24,
}}>
<StatCard icon="💰" label="총 수익" value={pnlSign(stats.total_pnl)} sub="USDT" accent="rgba(52,211,153,0.4)" />
<StatCard icon="📊" label="승률" value={`${winRate}%`} sub={`${stats.wins}W / ${stats.losses}L`} accent="rgba(129,140,248,0.4)" />
<StatCard icon="⚡" label="총 거래" value={stats.total_trades} sub={`평균 ${fmt(stats.avg_pnl)} USDT`} accent="rgba(251,191,36,0.3)" />
<StatCard icon="🎯" label="베스트" value={`+${fmt(stats.best_trade)}`} sub={`최저 ${fmt(stats.worst_trade)}`} accent="rgba(99,102,241,0.3)" />
</div>
{/* 차트 */}
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr",
gap: 10, marginBottom: 24,
}}>
<ChartBox title="일별 손익">
<ResponsiveContainer width="100%" height={180}>
<BarChart data={dailyAsc.map((d) => ({ date: fmtDate(d.date), pnl: d.net_pnl || 0 }))}>
<XAxis dataKey="date" {...axisStyle} />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Bar dataKey="pnl" name="순수익" radius={[5, 5, 0, 0]}>
{dailyAsc.map((d, i) => (
<Cell key={i} fill={(d.net_pnl || 0) >= 0 ? S.green : S.red} fillOpacity={0.75} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</ChartBox>
<ChartBox title="누적 수익 곡선">
<ResponsiveContainer width="100%" height={180}>
<AreaChart data={cumData}>
<defs>
<linearGradient id="gCum" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.indigo} stopOpacity={0.25} />
<stop offset="100%" stopColor={S.indigo} stopOpacity={0} />
</linearGradient>
</defs>
<XAxis dataKey="date" {...axisStyle} />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone" dataKey="cumPnl" name="누적"
stroke={S.indigo} strokeWidth={2} fill="url(#gCum)"
dot={{ fill: S.indigo, r: 3.5, strokeWidth: 0 }}
/>
</AreaChart>
</ResponsiveContainer>
</ChartBox>
</div>
{/* 최근 거래 */}
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 10,
}}>
최근 거래
</div>
{trades.length === 0 && (
<div style={{
textAlign: "center", color: S.text3, padding: 40,
fontFamily: S.mono, fontSize: 12,
}}>
거래 내역 없음 로그 파싱 대기
</div>
)}
{trades.slice(0, 3).map((t) => (
<TradeRow
key={t.id}
trade={t}
isExpanded={expanded === t.id}
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
/>
))}
{trades.length > 3 && (
<div
onClick={() => setTab("trades")}
style={{
textAlign: "center", padding: 12, color: S.indigo,
fontSize: 12, cursor: "pointer", fontFamily: S.mono,
background: "rgba(99,102,241,0.04)", borderRadius: 10,
marginTop: 6,
}}
>
전체 {tradesTotal} 보기
</div>
)}
</div>
)}
{/* ═══ TRADES ═════════════════════════════════════════ */}
{tab === "trades" && (
<div>
<div style={{
fontSize: 10, color: S.text3, letterSpacing: 1.2,
fontFamily: S.mono, textTransform: "uppercase", marginBottom: 12,
}}>
전체 거래 내역 ({tradesTotal})
</div>
{trades.map((t) => (
<TradeRow
key={t.id}
trade={t}
isExpanded={expanded === t.id}
onToggle={() => setExpanded(expanded === t.id ? null : t.id)}
/>
))}
{tradesTotal > 50 && (
<div style={{
display: "flex", justifyContent: "center", alignItems: "center",
gap: 12, marginTop: 14,
}}>
<button
disabled={tradesPage === 0}
onClick={() => setTradesPage((p) => Math.max(0, p - 1))}
style={{
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
background: tradesPage === 0 ? "transparent" : "rgba(99,102,241,0.1)",
color: tradesPage === 0 ? S.text4 : S.indigo,
border: `1px solid ${tradesPage === 0 ? S.border : "rgba(99,102,241,0.2)"}`,
borderRadius: 8, cursor: tradesPage === 0 ? "default" : "pointer",
}}
> 이전</button>
<span style={{ fontSize: 11, color: S.text3, fontFamily: S.mono }}>
{tradesPage * 50 + 1}{Math.min((tradesPage + 1) * 50, tradesTotal)} / {tradesTotal}
</span>
<button
disabled={(tradesPage + 1) * 50 >= tradesTotal}
onClick={() => setTradesPage((p) => p + 1)}
style={{
fontSize: 11, fontFamily: S.mono, padding: "6px 14px",
background: (tradesPage + 1) * 50 >= tradesTotal ? "transparent" : "rgba(99,102,241,0.1)",
color: (tradesPage + 1) * 50 >= tradesTotal ? S.text4 : S.indigo,
border: `1px solid ${(tradesPage + 1) * 50 >= tradesTotal ? S.border : "rgba(99,102,241,0.2)"}`,
borderRadius: 8, cursor: (tradesPage + 1) * 50 >= tradesTotal ? "default" : "pointer",
}}
>다음 </button>
</div>
)}
</div>
)}
{/* ═══ CHART ══════════════════════════════════════════ */}
{tab === "chart" && (
<div>
<ChartBox title={`${(selectedSymbol || symbols[0] || "XRP").replace("USDT", "")}/USDT 15m 가격`}>
<ResponsiveContainer width="100%" height={240}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), price: c.price || c.close }))}>
<defs>
<linearGradient id="gP" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#6366f1" stopOpacity={0.15} />
<stop offset="100%" stopColor="#6366f1" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis domain={["auto", "auto"]} {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Area
type="monotone" dataKey="price" name="가격"
stroke="#6366f1" strokeWidth={1.5} fill="url(#gP)" dot={false}
/>
</AreaChart>
</ResponsiveContainer>
</ChartBox>
<div style={{
display: "grid", gridTemplateColumns: "1fr 1fr",
gap: 10, marginTop: 12,
}}>
<ChartBox title="OI 변화율">
<ResponsiveContainer width="100%" height={150}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), oi_change: c.oi_change }))}>
<defs>
<linearGradient id="gOI" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.amber} stopOpacity={0.15} />
<stop offset="100%" stopColor={S.amber} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey={() => 0} stroke="rgba(255,255,255,0.1)" strokeDasharray="4 4" dot={false} name="기준선" />
<Area type="monotone" dataKey="oi_change" name="OI변화율" stroke={S.amber} strokeWidth={1.5} fill="url(#gOI)" dot={false} />
</AreaChart>
</ResponsiveContainer>
</ChartBox>
<ChartBox title="ADX">
<ResponsiveContainer width="100%" height={150}>
<AreaChart data={candles.map((c) => ({ ts: fmtTime(c.ts), adx: c.adx }))}>
<defs>
<linearGradient id="gA" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={S.green} stopOpacity={0.15} />
<stop offset="100%" stopColor={S.green} stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.03)" />
<XAxis dataKey="ts" {...axisStyle} interval="preserveStartEnd" />
<YAxis {...axisStyle} />
<Tooltip content={<ChartTooltip />} />
<Line type="monotone" dataKey={() => 25} stroke="rgba(52,211,153,0.3)" strokeDasharray="4 4" dot={false} name="임계값" />
<Area type="monotone" dataKey="adx" name="ADX" stroke={S.green} strokeWidth={1.5} fill="url(#gA)" dot={false} />
</AreaChart>
</ResponsiveContainer>
</ChartBox>
</div>
</div>
)}
{/* ═══ 푸터 ═══════════════════════════════════════════ */}
<div style={{
textAlign: "center", padding: "24px 0 8px", marginTop: 24,
borderTop: "1px solid rgba(255,255,255,0.03)",
display: "flex", justifyContent: "center", alignItems: "center", gap: 16,
}}>
<span style={{ fontSize: 10, color: "rgba(255,255,255,0.12)", fontFamily: S.mono }}>
{lastUpdate
? `Synced: ${lastUpdate.toLocaleTimeString("ko-KR")} · 15s polling`
: "API 연결 대기 중…"}
</span>
<button
onClick={async () => {
const key = prompt("Reset API Key를 입력하세요:");
if (!key) return;
if (!confirm("DB를 초기화하고 로그를 처음부터 다시 파싱합니다. 계속할까요?")) return;
try {
const r = await fetch("/api/reset", {
method: "POST",
headers: { "X-API-Key": key },
});
if (r.ok) { alert("초기화 완료. 잠시 후 데이터가 다시 채워집니다."); location.reload(); }
else if (r.status === 403) alert("API Key가 올바르지 않습니다.");
else alert("초기화 실패: " + r.statusText);
} catch (e) { alert("초기화 실패: " + e.message); }
}}
style={{
fontSize: 10, fontFamily: S.mono, padding: "3px 10px",
background: "rgba(255,255,255,0.04)", color: "rgba(255,255,255,0.2)",
border: "1px solid rgba(255,255,255,0.06)", borderRadius: 6, cursor: "pointer",
}}
>Reset DB</button>
</div>
</div>
<style>{`
@keyframes pulse { 0%,100% { opacity:1; } 50% { opacity:0.4; } }
button:hover { filter: brightness(1.1); }
`}</style>
</div>
);
}