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>
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user