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)" }) => ( {children} ); /* ── StatCard ─────────────────────────────────────────────────── */ const StatCard = ({ icon, label, value, sub, accent }) => (
{icon && {icon}}{label}
{value}
{sub && (
{sub}
)}
); /* ── ChartTooltip ─────────────────────────────────────────────── */ const ChartTooltip = ({ active, payload, label }) => { if (!active || !payload?.length) return null; return (
{label}
{payload.filter(p => p.name !== "과매수" && p.name !== "과매도" && p.name !== "임계값").map((p, i) => (
{p.name}: {typeof p.value === "number" ? p.value.toFixed(4) : p.value}
))}
); }; /* ── 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 (
{isShort ? "S" : "L"}
{(trade.symbol || "XRPUSDT").replace("USDT", "/USDT")} {trade.direction} {trade.leverage || 10}x
{fmtDate(trade.entry_time)} {fmtTime(trade.entry_time)} → {fmtTime(trade.exit_time)} {trade.close_reason && ( ({trade.close_reason}) )}
{fmt(trade.entry_price)}
진입가
{fmt(trade.exit_price)}
청산가
{pnlSign(pnl)}
{priceDiff}%
{isExpanded && (
{sections.map((sec, si) => (
{sec.title}
{sec.items.map(([label, val, color], ii) => (
{label} {val != null ? fmt(val) : "—"}
))}
))}
)}
); }; /* ── 차트 컨테이너 ────────────────────────────────────────────── */ const ChartBox = ({ title, children }) => (
{title}
{children}
); /* ── 탭 정의 ──────────────────────────────────────────────────── */ 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 [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 [position, setPosition] = useState(null); const [botStatus, setBotStatus] = useState({}); const [trades, setTrades] = useState([]); const [daily, setDaily] = useState([]); const [candles, setCandles] = useState([]); /* ── 데이터 폴링 ─────────────────────────────────────────── */ const fetchAll = useCallback(async () => { const [sRes, pRes, tRes, dRes, cRes] = await Promise.all([ api("/stats"), api("/position"), api("/trades?limit=50"), api("/daily?days=30"), api("/candles?limit=96"), ]); if (sRes && sRes.total_trades !== undefined) { setStats(sRes); setIsLive(true); setLastUpdate(new Date()); } if (pRes) { setPosition(pRes.position); if (pRes.bot) setBotStatus(pRes.bot); } if (tRes?.trades) setTrades(tRes.trades); if (dRes?.daily) setDaily(dRes.daily); if (cRes?.candles) setCandles(cRes.candles); }, []); 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 = botStatus.current_price || (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 (
{/* BG glow */}
{/* ═══ 헤더 ═══════════════════════════════════════════ */}
{isLive ? "Live" : "Connecting…"} · XRP/USDT {currentPrice && ( {fmt(currentPrice)} )}

Trading Dashboard

{/* 오픈 포지션 */} {position && (
OPEN POSITION
{position.direction} {position.leverage || 10}x {fmt(position.entry_price)} SL {fmt(position.sl)} · TP {fmt(position.tp)}
)}
{/* ═══ 탭 ═════════════════════════════════════════════ */}
{TABS.map((t) => ( ))}
{/* ═══ OVERVIEW ═══════════════════════════════════════ */} {tab === "overview" && (
{/* Stats */}
{/* 차트 */}
({ date: fmtDate(d.date), pnl: d.net_pnl || 0 }))}> } /> {dailyAsc.map((d, i) => ( = 0 ? S.green : S.red} fillOpacity={0.75} /> ))} } />
{/* 최근 거래 */}
최근 거래
{trades.length === 0 && (
거래 내역 없음 — 로그 파싱 대기 중
)} {trades.slice(0, 3).map((t) => ( setExpanded(expanded === t.id ? null : t.id)} /> ))} {trades.length > 3 && (
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, }} > 전체 {trades.length}건 보기 →
)}
)} {/* ═══ TRADES ═════════════════════════════════════════ */} {tab === "trades" && (
전체 거래 내역 ({trades.length}건)
{trades.map((t) => ( setExpanded(expanded === t.id ? null : t.id)} /> ))}
)} {/* ═══ CHART ══════════════════════════════════════════ */} {tab === "chart" && (
({ ts: fmtTime(c.ts), price: c.price || c.close }))}> } />
({ ts: fmtTime(c.ts), rsi: c.rsi }))}> } /> 70} stroke="rgba(248,113,113,0.2)" strokeDasharray="4 4" dot={false} name="과매수" /> 30} stroke="rgba(139,92,246,0.2)" strokeDasharray="4 4" dot={false} name="과매도" /> ({ ts: fmtTime(c.ts), adx: c.adx }))}> } /> 25} stroke="rgba(52,211,153,0.3)" strokeDasharray="4 4" dot={false} name="임계값" />
)} {/* ═══ 푸터 ═══════════════════════════════════════════ */}
{lastUpdate ? `Synced: ${lastUpdate.toLocaleTimeString("ko-KR")} · 15s polling` : "API 연결 대기 중…"}
); }