'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, getOrCreateViewEnvelope, 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 && (
banked {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) // 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) return } let cancelled = false // First load uses getOrCreateViewEnvelope — may pop MetaMask exactly ONCE // when the page is opened (user action). All subsequent polls reuse the // cached envelope via getCachedViewEnvelope (no popup). When the cache // expires (20 min), the polling tick silently skips until the user // refocuses the tab or navigates back in — preventing the wallet from // popping in the background while the user is doing something else. async function load(envSource: 'first' | 'poll') { const env = envSource === 'first' ? await getOrCreateViewEnvelope({ action: 'view_positions', wallet: address!, signMessageAsync, }) : getCachedViewEnvelope('view_positions', address!) if (!env) return // cache expired during polling — wait for user re-entry try { const [p, t] = await Promise.all([ getOpenPositions(address!, env), getTodayStats(address!, env), ]) if (!cancelled) { setPositions(p.positions); setToday(t); setErr(null) } } catch (e: unknown) { if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error')) } } load('first').catch(e => { // Initial sign-rejection lands here. Show a soft error so the polling // loop still starts (a subsequent in-cache sign elsewhere will revive it). if (!cancelled) setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '加载失败' : 'fetch error')) }) const id = setInterval(() => { void load('poll') }, 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 (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 realised
{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 })} banked on open`}
)}
{err && ( ● {err} )}
{/* Position list (or empty state) */} {positions && positions.length > 0 ? (
AssetSidePrices SizeHold Unrealized Action
{positions.map(p => ( ))}
) : (
No open positions. The bot will appear here as soon as a signal triggers a trade.
)} {/* ── 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 ? '已锁定(降风险)' : 'Already banked (de-risk)'} {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}
)}
)}
) }