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 }) => (
);
/* ── 탭 정의 ──────────────────────────────────────────────────── */
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 연결 대기 중…"}
);
}