'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 Link from 'next/link' 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, type SignedEnvelope } 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 / current price
{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 && ( {`↑ scaled in ×${p.addon_steps} `} )} {(p.derisk_steps ?? 0) > 0 && ( {`↓ trimmed ×${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 })} from partial close
)}
{/* 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) const [unlocking, setUnlocking] = 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) // Separate error slot for the Grow toggle so a transient toggle failure // doesn't squat in the panel-header `err` banner (which is otherwise // owned by the 15s poll). Auto-clears after 4s. const [growErr, setGrowErr] = useState('') useEffect(() => { if (!growErr) return const t = setTimeout(() => setGrowErr(''), 4000) return () => clearTimeout(t) }, [growErr]) async function toggleGrow(p: OpenPosition) { if (!address || growId !== null) return const next = !p.grow_mode setGrowId(p.trade_id); setGrowErr('') 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)) { setGrowErr(isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled') } else { setGrowErr(walletErrorLabel(e, isZh ? 'Grow 切换已取消' : 'Grow toggle cancelled', 90) || (isZh ? 'Grow 切换失败' : 'grow toggle failed')) } } finally { setGrowId(null) } } useEffect(() => { // B39: wipe stale data from the previous wallet immediately — don't wait // for the async fetch to complete before the UI shows a clean slate. setPositions(null); setToday(null); setNeedsUnlock(false); setErr(null) if (!isConnected || !address) 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) } // signMessageAsync and isZh are intentionally excluded: signMessageAsync is // never called inside this effect (load() uses cached envelopes only), and // isZh is a compile-time constant (always false). Including either would // restart the 15-second poll timer on every wagmi reference change. // eslint-disable-next-line react-hooks/exhaustive-deps }, [address, isConnected]) // In-page unlock: mint a view_user envelope (one signature) right here and // load positions immediately — no detour to the Settings page. view_user is // a superset accepted by /positions/open and /positions/today. async function handleUnlock() { if (!address || unlocking) return setUnlocking(true); setErr(null) try { const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync, }) const [p, t] = await Promise.all([ getOpenPositions(address, env), getTodayStats(address, env), ]) setPositions(p.positions); setToday(t); setNeedsUnlock(false); setErr(null) } catch (e: unknown) { // User-cancelled signature is benign — keep the unlock CTA visible. if (!isUserRejection(e)) { setErr(e instanceof Error ? e.message.slice(0, 80) : (isZh ? '解锁失败' : 'unlock failed')) } } finally { setUnlocking(false) } } // 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) { // Distinguish a user-cancelled signature (benign, close the modal) from // a real failure (keep modal open in 'err' state so user can retry or // read the error). if (isUserRejection(e)) { setClosing(null); setCloseState('idle'); setCloseErr('') } else { 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 to see open positions
Sign once on this device to unlock your positions (valid for a few minutes, no transaction, no gas).
or go to Settings →
{err && (
⚠️ {err}
)}
) } // When the first load fails, positions and today are still null but err is set. // Without this guard the component returns null (blank), swallowing the error. if (positions === null && today === null) { if (err) { return (
⚠️ Could not load positions — {err}
) } 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 })} banked from partial closes`}
)}
{err && ( ● {err} )} {growErr && !err && ( ● {growErr} )}
{/* 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 && (
{ // Backdrop dismisses in both idle (never started) and err // (failed, user wants out) states. NOT during signing/closing — // the wallet popup or in-flight POST shouldn't be orphaned. if (closeState === 'idle' || closeState === 'err') { setClosing(null); setCloseState('idle'); setCloseErr('') } }} 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 ? '这是模拟交易,平仓只做本地记录,不会动用真实资金。' : 'Paper trade — simulated close, no real funds involved.') : (isZh ? '立即按市价在 Hyperliquid 上平仓。盈亏将立即结算,无法撤销。' : 'Closes at market price on Hyperliquid right now. The profit or loss becomes final and cannot be undone.')}
{isZh ? '开仓价' : 'Entry'} {fmtMoney(closing.entry_price)}
{isZh ? '现价' : 'Current price'} {closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}
{closing.realized_usd != null && closing.realized_usd !== 0 && (
{isZh ? '已落袋(之前减仓)' : 'Already banked'} {fmtMoney(closing.realized_usd, { sign: true })}
)}
{isZh ? '平仓后将结算' : 'You\'ll get if you close now'} 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}
)}
)}
) }