'use client' /** * Open positions panel — what's currently on the book. * * The single most-asked question on any trading dashboard: "what do I hold * right now?" Polls /api/positions/open + /api/positions/today every 15s. * Compact enough to embed at the top of Dashboard AND Trades. * * Empty state is intentional: explicitly says "0 open" rather than hiding, * so the user can confirm the bot isn't doing anything weird off-screen. */ import { useEffect, useState } from 'react' import { useLocale } from 'next-intl' import { useAccount, useSignMessage } from 'wagmi' import { getOpenPositions, getTodayStats, manualCloseTrade, setTradeGrow, type OpenPosition, type TodayStats, } from '@/lib/api' import { getCachedViewEnvelope, signRequest } from '@/lib/signedRequest' import { isUserRejection, walletErrorLabel } from '@/lib/walletError' const POLL_MS = 15_000 function fmtMoney(n: number | null | undefined, opts: { sign?: boolean } = {}) { if (n == null || isNaN(n)) return '—' const sign = opts.sign === true const abs = Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) if (n < 0) return '-$' + abs if (sign && n > 0) return '+$' + abs return '$' + abs } function fmtPct(n: number | null | undefined) { if (n == null || isNaN(n)) return '—' return (n >= 0 ? '+' : '') + n.toFixed(2) + '%' } function fmtHold(min: number) { if (min < 60) return `${min}m` const h = Math.floor(min / 60), m = min % 60 if (h < 24) return `${h}h ${m}m` return `${Math.floor(h / 24)}d ${h % 24}h` } function PositionRow({ p, onClose, onToggleGrow, growBusy, isZh }: { p: OpenPosition onClose: (p: OpenPosition) => void onToggleGrow: (p: OpenPosition) => void growBusy: boolean isZh: boolean }) { const pnlTone = (p.unrealized_pct ?? 0) > 0 ? 'up' : (p.unrealized_pct ?? 0) < 0 ? 'down' : 'idle' const pnlColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-3)' return (
{/* Asset + paper tag */}
{p.asset} {p.is_paper && ( P )}
{/* Side */} {p.side === 'long' ? '↗ LONG' : '↘ SHORT'} {/* Entry → Current */}
entry → mark
{fmtMoney(p.entry_price)} {p.current_price != null ? fmtMoney(p.current_price) : n/a}
{/* Size + leverage (size = OPEN notional after de-risk) */}
open size · lev
{p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'} {p.leverage != null && · {p.leverage}×}
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
{(p.addon_steps ?? 0) > 0 && ( {`⬆ pyramided ×${p.addon_steps} `} )} {(p.derisk_steps ?? 0) > 0 && ( {`⬇ de-risked ×${p.derisk_steps}`} )}
)} {/* Per-trade Grow (pyramiding) switch */}
{/* Hold time */}
{fmtHold(p.hold_minutes)}
{/* Unrealized PnL */}
{fmtMoney(p.unrealized_usd, { sign: true })}
{fmtPct(p.unrealized_pct)}
{p.realized_usd != null && p.realized_usd !== 0 && (
locked in {fmtMoney(p.realized_usd, { sign: true })}
)}
{/* Emergency close — bypasses TP/SL/schedule. Always available. */}
) } export default function OpenPositions() { const locale = useLocale() const isZh = false // i18n shelved — Chinese branches kept as dead code for future revival; see messages/zh.json const { address, isConnected } = useAccount() const { signMessageAsync } = useSignMessage() const [mounted, setMounted] = useState(false) const [positions, setPositions] = useState(null) const [today, setToday] = useState(null) const [err, setErr] = useState(null) const [needsUnlock, setNeedsUnlock] = useState(false) // Close-confirmation modal state const [closing, setClosing] = useState(null) const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle') useEffect(() => { setMounted(true) }, []) const [closeErr, setCloseErr] = useState('') const [growId, setGrowId] = useState(null) async function toggleGrow(p: OpenPosition) { if (!address || growId !== null) return const next = !p.grow_mode setGrowId(p.trade_id) try { const env = await signRequest({ action: 'set_trade_grow', wallet: address, body: { trade_id: p.trade_id, enabled: next }, signMessageAsync, }) const r = await setTradeGrow(env, p.trade_id, next) setPositions(prev => (prev ?? []).map(x => x.trade_id === p.trade_id ? { ...x, grow_mode: r.grow_mode } : x)) } catch (e) { if (isUserRejection(e)) { setErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled') } else { setErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90) || (isZh ? 'Grow 切换失败' : 'grow toggle failed')) } } finally { setGrowId(null) } } useEffect(() => { if (!isConnected || !address) { setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null) return } let cancelled = false // Only reuse a cached envelope here. Open positions should never trigger // a fresh signature popup on their own while the user is browsing. async function load() { const env = getCachedViewEnvelope('view_positions', address!) ?? getCachedViewEnvelope('view_user', address!) if (!env) { if (!cancelled) { setNeedsUnlock(true) setErr(null) } return } try { const [p, t] = await Promise.all([ getOpenPositions(address!, env), getTodayStats(address!, env), ]) if (!cancelled) { setPositions(p.positions); setToday(t); setErr(null); setNeedsUnlock(false) } } catch (e: unknown) { if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error')) } } void load() const id = setInterval(() => { void load() }, POLL_MS) return () => { cancelled = true; clearInterval(id) } }, [address, isConnected, signMessageAsync, isZh]) // Trigger close. Two-step: opens modal, user confirms, we sign + POST. async function confirmCloseTrade() { if (!address || !closing) return setCloseErr(''); setCloseState('signing') try { const env = await signRequest({ action: 'close_trade', wallet: address, body: { trade_id: closing.trade_id }, signMessageAsync, }) setCloseState('closing') await manualCloseTrade(env, closing.trade_id) // Optimistically drop the row from the list; the next poll will confirm. setPositions(prev => (prev ?? []).filter(x => x.trade_id !== closing.trade_id)) setClosing(null) setCloseState('idle') } catch (e: unknown) { setCloseErr(walletErrorLabel(e, isZh ? '已取消' : 'Cancelled', 120)) setCloseState('err') } } // Return null both before mount (SSR) and when not connected — same output, // no hydration mismatch. if (!mounted || !isConnected) return null if (needsUnlock && positions === null && today === null) { return (
Open positions
Sign in once to view open positions
Go to the Settings page and click Sign in to view your settings. After signing, your open positions will appear here automatically.
) } if (positions === null && today === null) return null const totalUnrealized = (positions ?? []).reduce( (s, p) => s + (p.unrealized_usd ?? 0), 0, ) const pnlTone = today ? (today.realized_pnl_usd > 0 ? 'up' : today.realized_pnl_usd < 0 ? 'down' : 'idle') : 'idle' const todayColor = pnlTone === 'up' ? 'var(--up)' : pnlTone === 'down' ? 'var(--down)' : 'var(--ink-2)' return (
{/* Header strip — at-a-glance status */}
Open
{today?.open_count ?? 0} {`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`}
Unrealized
0 ? 'var(--up)' : totalUnrealized < 0 ? 'var(--down)' : 'var(--ink-2)', fontVariantNumeric: 'tabular-nums' }}> {fmtMoney(totalUnrealized, { sign: true })}
Today's realized
{fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })}
{today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'}
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} locked in on open trades`}
)}
{err && ( ● {err} )}
{/* Position list (or empty state) */} {positions && positions.length > 0 ? (
AssetSidePrices SizeHold Unrealized Action
{positions.map(p => ( ))}
) : (
No open positions. When the bot opens a trade, it will show up here.
)} {/* ── Confirmation modal (P0.1 safety valve) ─────────────────── Two-step UX so the wallet popup isn't surprising. The user presses "Close now", reads the impact summary, then confirms. */} {closing && (
closeState === 'idle' && setClosing(null)} style={{ position: 'fixed', inset: 0, zIndex: 1000, background: 'rgba(0,0,0,0.55)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, }} >
e.stopPropagation()} className="card" style={{ padding: 24, maxWidth: 440, width: '100%' }} >
{isZh ? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}?` : `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`}
{closing.is_paper ? (isZh ? '这是模拟交易,平仓只做本地记录,不会调用 Hyperliquid。' : 'Paper trade — synthetic close, no Hyperliquid call.') : (isZh ? '会向 Hyperliquid 发送 IOC 市价单。已实现盈亏将立即确认。' : 'Sends an IOC market order to Hyperliquid. Realised PnL is permanent.')}
{isZh ? '开仓价' : 'Entry'} {fmtMoney(closing.entry_price)}
{isZh ? '现价' : 'Mark'} {closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}
{closing.realized_usd != null && closing.realized_usd !== 0 && (
{isZh ? '已锁定(降风险)' : 'Locked in (de-risked)'} {fmtMoney(closing.realized_usd, { sign: true })}
)}
{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'} 0 ? 'var(--up)' : (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)', fontVariantNumeric: 'tabular-nums', }}> {fmtMoney(closing.unrealized_usd, { sign: true })} ({fmtPct(closing.unrealized_pct)})
{closeState === 'err' && (
{closeErr}
)}
)}
) }