feat(dashboard): show unrealized PnL on position cards (5min update)

Parse position monitor logs (5min interval) to update current_price,
unrealized_pnl and unrealized_pnl_pct in bot_status. Position cards
now display USDT amount and percentage, colored green/red. Falls back
to entry/current price calculation if monitor data unavailable.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
21in7
2026-03-09 20:55:53 +09:00
parent c6c60b274c
commit af91b36467
4 changed files with 170 additions and 4 deletions

File diff suppressed because one or more lines are too long

20
dashboard/ui/dist/index.html vendored Normal file
View File

@@ -0,0 +1,20 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Trading Dashboard</title>
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:opsz,wght@9..40,300;9..40,400;9..40,500;9..40,700&family=JetBrains+Mono:wght@300;400;500;600&display=swap" rel="stylesheet" />
<link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700&display=swap" rel="stylesheet" />
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #08080f; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 3px; }
</style>
<script type="module" crossorigin src="/assets/index-50uRhrJe.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -403,7 +403,20 @@ export default function App() {
{/* 오픈 포지션 — 복수 표시 */}
{positions.length > 0 && (
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{positions.map((pos) => (
{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 pnlColor = 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,
@@ -414,17 +427,24 @@ export default function App() {
</div>
<div style={{ display: "flex", gap: 12, alignItems: "center" }}>
<Badge
bg={pos.direction === "SHORT" ? "rgba(239,68,68,0.12)" : "rgba(52,211,153,0.12)"}
color={pos.direction === "SHORT" ? S.red : S.green}
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: pnlColor }}>
{pnlUsdt != null ? `${pnlUsdt > 0 ? "+" : ""}${pnlUsdt.toFixed(4)}` : ""}
{` (${pnlPct > 0 ? "+" : ""}${pnlPct.toFixed(2)}%)`}
</span>
)}
</div>
</div>
))}
);
})}
</div>
)}
</div>