Files
trumpsignal-frontend/components/positions/OpenPositions.tsx
T
k d72323b1c6 Production polish: i18n shelved, PWA icons, SEO/GEO cleanup, UX fixes
Big-picture changes since 01be8e7:

New routes — /trump /btc /kol /trades /analytics /archive plus four
SEO landing pages (/methodology /glossary /case-studies /contact),
all served under /[locale]. Each has dedicated metadata + JSON-LD.

KOL page (new) — DigestWidget + OnchainWidget + TalkVsTradesWidget,
filter by handle/ticker, click-through to per-post detail with the
original AI-extracted ticker/conviction/quote.

BTC page (new) — tabbed: macro-bottom (AHR999 + 200WMA + Pi Cycle)
and funding-rate reversal, with live sparkline + threshold bands.

Telegram card on Settings — wallet-link code generation, status,
disconnect. Preferences moved into the bot itself (/trump /btc etc.)
so the card stays minimal.

SignalMonitor (new) — ETH/LINK Bollinger breakout monitor in its own
component, shares the singleton WsProvider so no second WS opens.

WS singleton refactor (lib/wsContext) — shared WsProvider + useWsSubscribe
hook. Cleanup now actively closes the socket on unmount; previously the
local `ws` couldn't be reached from cleanup and leaked one connection
per StrictMode remount.

OpenPositions polling no longer pops MetaMask in the background —
splits into load('first') for user-initiated and load('poll') that
uses getCachedViewEnvelope without signing.

i18n shelved — proxy.ts (Next 16 middleware rename) wires next-intl
but only Navbar + layout footer have translations. Rest of UI used
isZh ternary scattered across 28 files. All `const isZh = locale === 'zh'`
flipped to `const isZh = false` so every site renders English; Chinese
branches kept as dead code so revival is one regex away.
LanguageSwitch hidden but file kept. zh-CN hreflang removed from
metadata + sitemap to avoid duplicate-content penalties.

Wallet error handling — lib/walletError.ts: isUserRejection walks EIP-1193
code 4001 + .cause chain; previous string-match for "reject"/"denied"
broke for users running MetaMask in non-English UIs. 12 call sites
migrated across 5 components.

PWA icons — app/icon.tsx + app/apple-icon.tsx render the brand "α" via
Next's ImageResponse so no static PNG asset is required. manifest.ts
references /icon and /apple-icon dynamic routes.

OG image + sitemap + robots — dynamic 1200×630 OG card; robots blocks
both /en/settings and /zh/settings; sitemap only emits /en routes;
JSON-LD covers SoftwareApplication + Organization + 10-Q FAQPage.

Landing page polish — Launch Dashboard button stripped of magnetic
hover + shimmer + lift + glow expansion (multiple users found it busy);
hero scramble alphabet swapped to alphanumeric (was block characters);
"15 KOL feeds" copy updated to 19 in 5 places.

PostCards source icons — new entries for btc_bottom_reversal,
funding_reversal, kol_divergence so they no longer fall through to
the generic "first letter" fallback.

Archive page filter — excludes funding_reversal + kol_divergence
(previously only excluded truth + btc_bottom_reversal so new live
signals leaked into the legacy archive).

Cache + skeleton loading — lib/cache.ts SWR module with per-key TTL,
applied across the major pages so navigation feels instant.

SignConfirmSheet — 4 hardcoded Chinese strings translated to English
(English users were seeing zh-only "需要钱包签名" etc).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 00:53:27 +08:00

474 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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 (
<div style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12, alignItems: 'center',
padding: '10px 14px',
borderTop: '1px solid var(--line)',
fontSize: 13,
}}>
{/* Asset + paper tag */}
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<span className={`asset-dot ${p.asset.toLowerCase()}`} />
<span style={{ fontWeight: 600 }}>{p.asset}</span>
{p.is_paper && (
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: 'rgba(245,158,11,0.15)', color: '#f59e0b',
}}>P</span>
)}
</div>
{/* Side */}
<span className={`side-pill ${p.side}`}>
{p.side === 'long'
? '↗ LONG'
: '↘ SHORT'}
</span>
{/* Entry → Current */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>entry mark</div>
<div>
{fmtMoney(p.entry_price)}
<span style={{ color: 'var(--ink-4)' }}> </span>
{p.current_price != null ? fmtMoney(p.current_price) : <span style={{ color: 'var(--ink-4)' }}>n/a</span>}
</div>
</div>
{/* Size + leverage (size = OPEN notional after de-risk) */}
<div className="mono" style={{ fontSize: 12 }}>
<div style={{ color: 'var(--ink-3)', fontSize: 10 }}>open size · lev</div>
<div>
{p.size_usd != null ? '$' + p.size_usd.toFixed(0) : '—'}
{p.leverage != null && <span style={{ color: 'var(--ink-4)' }}> · {p.leverage}×</span>}
</div>
{((p.derisk_steps ?? 0) > 0 || (p.addon_steps ?? 0) > 0) && (
<div style={{ fontSize: 9, marginTop: 2 }}>
{(p.addon_steps ?? 0) > 0 && (
<span style={{ color: 'var(--up)' }}>{`⬆ pyramided ×${p.addon_steps} `}</span>
)}
{(p.derisk_steps ?? 0) > 0 && (
<span style={{ color: 'var(--down)' }}>{`⬇ de-risked ×${p.derisk_steps}`}</span>
)}
</div>
)}
{/* Per-trade Grow (pyramiding) switch */}
<button
onClick={() => onToggleGrow(p)}
disabled={growBusy}
title={p.grow_mode
? (isZh ? 'Grow 已开启:趋势确认后会继续顺势加仓。点击关闭。' : 'Grow ON — scales INTO this winner on confirmed trend. Click to turn off.')
: (isZh ? 'Grow 已关闭:仅持有并做保护性降风险。点击后允许顺势加仓。' : 'Grow OFF — hold + protective de-risk only. Click to let it pyramid.')}
style={{
marginTop: 4, fontSize: 9, fontWeight: 700, padding: '2px 7px',
borderRadius: 999, cursor: growBusy ? 'wait' : 'pointer',
border: '1px solid var(--line)',
background: p.grow_mode ? 'var(--up, #16a34a)' : 'transparent',
color: p.grow_mode ? '#fff' : 'var(--ink-3)',
}}
>
{p.grow_mode ? '⬆ Grow ON' : 'Grow OFF'}
</button>
</div>
{/* Hold time */}
<div className="mono" style={{ fontSize: 12, color: 'var(--ink-2)' }}>
{fmtHold(p.hold_minutes)}
</div>
{/* Unrealized PnL */}
<div style={{ textAlign: 'right' }}>
<div style={{ fontSize: 14, fontWeight: 600, color: pnlColor, fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(p.unrealized_usd, { sign: true })}
</div>
<div style={{ fontSize: 11, color: pnlColor, opacity: 0.85 }}>
{fmtPct(p.unrealized_pct)}
</div>
{p.realized_usd != null && p.realized_usd !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
banked {fmtMoney(p.realized_usd, { sign: true })}
</div>
)}
</div>
{/* Emergency close — bypasses TP/SL/schedule. Always available. */}
<button
onClick={() => onClose(p)}
className="btn ghost"
style={{
padding: '6px 10px', fontSize: 11, fontWeight: 600,
color: 'var(--down)',
border: '1px solid color-mix(in oklab, var(--down) 25%, var(--line))',
}}
title={isZh ? '立即平掉这个仓位' : 'Close this position immediately'}
>
{isZh ? '立即平仓' : 'Close now'}
</button>
</div>
)
}
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<OpenPosition[] | null>(null)
const [today, setToday] = useState<TodayStats | null>(null)
const [err, setErr] = useState<string | null>(null)
// Close-confirmation modal state
const [closing, setClosing] = useState<OpenPosition | null>(null)
const [closeState, setCloseState] = useState<'idle'|'signing'|'closing'|'err'>('idle')
useEffect(() => { setMounted(true) }, [])
const [closeErr, setCloseErr] = useState('')
const [growId, setGrowId] = useState<number | null>(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 (
<div className="card" style={{ padding: 0, marginBottom: 16, overflow: 'hidden' }}>
{/* Header strip — at-a-glance status */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',
padding: '14px 18px', flexWrap: 'wrap', gap: 12,
}}>
<div style={{ display: 'flex', alignItems: 'center', gap: 18, flexWrap: 'wrap' }}>
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Open
</div>
<div style={{ fontSize: 18, fontWeight: 700 }}>
{today?.open_count ?? 0}
<span style={{ fontSize: 12, color: 'var(--ink-3)', marginLeft: 6, fontWeight: 400 }}>
{`position${(today?.open_count ?? 0) === 1 ? '' : 's'}`}
</span>
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Unrealized
</div>
<div style={{ fontSize: 18, fontWeight: 700,
color: totalUnrealized > 0 ? 'var(--up)' :
totalUnrealized < 0 ? 'var(--down)' : 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(totalUnrealized, { sign: true })}
</div>
</div>
<div style={{ width: 1, height: 32, background: 'var(--line)' }} />
<div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', letterSpacing: '0.06em',
textTransform: 'uppercase', fontWeight: 600, marginBottom: 2 }}>
Today realised
</div>
<div style={{ fontSize: 18, fontWeight: 700, color: todayColor,
fontVariantNumeric: 'tabular-nums' }}>
{fmtMoney(today?.realized_pnl_usd ?? 0, { sign: true })}
</div>
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{today ? `${today.trades_closed} closed · ${today.wins}W · ${today.losses}L` : '—'}
</div>
{today != null && (today.open_realized_usd ?? 0) !== 0 && (
<div style={{ fontSize: 10, color: 'var(--ink-3)', marginTop: 1 }}>
{`incl. ${fmtMoney(today.open_realized_usd, { sign: true })} banked on open`}
</div>
)}
</div>
</div>
{err && (
<span style={{ fontSize: 11, color: 'var(--down)' }}> {err}</span>
)}
</div>
{/* Position list (or empty state) */}
{positions && positions.length > 0 ? (
<div>
<div style={{
display: 'grid',
gridTemplateColumns: '70px 60px 1fr 1fr 80px 1fr 90px',
gap: 12,
padding: '8px 14px',
background: 'var(--bg-sunk)',
fontSize: 10, fontWeight: 600, letterSpacing: '0.05em',
color: 'var(--ink-3)', textTransform: 'uppercase',
}}>
<span>Asset</span><span>Side</span><span>Prices</span>
<span>Size</span><span>Hold</span>
<span style={{ textAlign: 'right' }}>Unrealized</span>
<span style={{ textAlign: 'right' }}>Action</span>
</div>
{positions.map(p => (
<PositionRow
key={p.trade_id}
p={p}
onClose={setClosing}
onToggleGrow={toggleGrow}
growBusy={growId === p.trade_id}
isZh={isZh}
/>
))}
</div>
) : (
<div style={{
borderTop: '1px solid var(--line)',
padding: '20px 18px',
fontSize: 13, color: 'var(--ink-3)', textAlign: 'center',
}}>
No open positions. The bot will appear here as soon as a signal triggers a trade.
</div>
)}
{/* ── 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 && (
<div
onClick={() => 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,
}}
>
<div
onClick={e => e.stopPropagation()}
className="card"
style={{ padding: 24, maxWidth: 440, width: '100%' }}
>
<div style={{ fontSize: 16, fontWeight: 700, marginBottom: 4 }}>
{isZh
? `现在平掉 ${closing.asset} ${closing.side === 'long' ? '多单' : '空单'}`
: `Close ${closing.side === 'long' ? 'long' : 'short'} ${closing.asset} now?`}
</div>
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginBottom: 16 }}>
{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.')}
</div>
<div style={{
background: 'var(--bg-sunk)', borderRadius: 8, padding: 14, marginBottom: 16,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '开仓价' : 'Entry'}</span>
<span className="mono">{fmtMoney(closing.entry_price)}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6 }}>
<span style={{ color: 'var(--ink-3)' }}>{isZh ? '现价' : 'Mark'}</span>
<span className="mono">{closing.current_price != null ? fmtMoney(closing.current_price) : (isZh ? '暂无' : 'n/a')}</span>
</div>
{closing.realized_usd != null && closing.realized_usd !== 0 && (
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 6, color: 'var(--ink-3)' }}>
<span>{isZh ? '已锁定(降风险)' : 'Already banked (de-risk)'}</span>
<span className="mono">{fmtMoney(closing.realized_usd, { sign: true })}</span>
</div>
)}
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, fontWeight: 600, marginTop: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
<span>{isZh ? '当前未平部分预估盈亏' : 'Est. PnL on open portion'}</span>
<span style={{
color: (closing.unrealized_usd ?? 0) > 0 ? 'var(--up)'
: (closing.unrealized_usd ?? 0) < 0 ? 'var(--down)' : 'var(--ink-2)',
fontVariantNumeric: 'tabular-nums',
}}>
{fmtMoney(closing.unrealized_usd, { sign: true })}
<span style={{ marginLeft: 6, opacity: 0.7, fontSize: 11 }}>
({fmtPct(closing.unrealized_pct)})
</span>
</span>
</div>
</div>
{closeState === 'err' && (
<div style={{ fontSize: 12, color: 'var(--down)', marginBottom: 12 }}>{closeErr}</div>
)}
<div style={{ display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button
onClick={() => setClosing(null)}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn ghost"
style={{ padding: '8px 16px', fontSize: 13 }}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
onClick={confirmCloseTrade}
disabled={closeState === 'signing' || closeState === 'closing'}
className="btn"
style={{
padding: '8px 18px', fontSize: 13, fontWeight: 600,
background: 'var(--down)', color: '#fff', border: 'none',
}}
>
{closeState === 'signing' ? (isZh ? '钱包签名中…' : 'Sign in wallet…')
: closeState === 'closing' ? (isZh ? '平仓中…' : 'Closing…')
: (isZh ? `立即平掉 ${closing.asset}` : `Close ${closing.asset} now`)}
</button>
</div>
</div>
</div>
)}
</div>
)
}