'use client' import { useState, useEffect, useRef, useMemo } from 'react' import { useLocale } from 'next-intl' import { useAccount, useSignMessage } from 'wagmi' import { getTrades, getSignalAccuracy } from '@/lib/api' import type { BotTrade } from '@/types' import type { SignalAccuracy } from '@/lib/api' import { getCachedViewEnvelope, getOrCreateViewEnvelope, type SignedEnvelope } from '@/lib/signedRequest' import { swrFetch } from '@/lib/cache' import PageHint from '@/components/ui/PageHint' import InfoTip from '@/components/ui/InfoTip' type Period = '7d' | '30d' | '90d' | 'All' function fmtMoney(n: number) { if (n == null || isNaN(n)) return '—' const abs = Math.abs(n) const s = abs.toLocaleString('en-US', { maximumFractionDigits: 0 }) if (n < 0) return '-$' + s if (n > 0) return '+$' + s return '$' + s } function fmtHold(s: number) { if (s < 60) return s + 's' const m = Math.floor(s / 60) if (m < 60) return m + 'm' return Math.floor(m / 60) + 'h ' + (m % 60) + 'm' } function fmtAccuracyPct(pct: number | null | undefined) { return pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%` } function inPeriod(iso: string | null, period: Period) { if (!iso) return false // trades without closed_at are excluded from all windows if (period === 'All') return true const days = Number.parseInt(period, 10) if (Number.isNaN(days)) return true const time = new Date(iso).getTime() return time >= Date.now() - days * 24 * 60 * 60 * 1000 } function calcDrawdownPct(trades: BotTrade[]) { const ordered = [...trades] .filter((t) => t.pnl_usd !== null && t.pnl_usd !== undefined && t.closed_at !== null) .sort((a, b) => new Date(a.closed_at!).getTime() - new Date(b.closed_at!).getTime()) let equity = 0 let peak = 0 let maxDrawdownPct = 0 for (const trade of ordered) { equity += trade.pnl_usd as number peak = Math.max(peak, equity) if (peak > 0) { maxDrawdownPct = Math.max(maxDrawdownPct, ((peak - equity) / peak) * 100) } } return maxDrawdownPct } export default function AnalyticsPageClient() { 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 [trades, setTrades] = useState([]) const [accuracy, setAccuracy] = useState(null) const [period, setPeriod] = useState('30d') const [tradeMode, setTradeMode] = useState<'live' | 'paper'>('live') const [privateLocked, setPrivateLocked] = useState(false) const [unlocking, setUnlocking] = useState(false) const [unlockErr, setUnlockErr] = useState('') const aliveRef = useRef(true) useEffect(() => { aliveRef.current = true return () => { aliveRef.current = false } }, []) // genRef guards loadAll against stale-closure wallet-switch races. // snapAddr === address inside an async function is a stale-closure trap: // both refer to the same closed-over value at render time, so the check // is always true even when the wallet has changed. genRef is a mutable // ref that any closure can read to detect it has been superseded. const genRef = useRef(0) useEffect(() => { genRef.current++ }, [address]) // `forcedEnv` (a freshly-minted view_user) lets the in-page Unlock button // load private data without a detour through the Settings page. Public // signal-accuracy always loads regardless. // // All displayed stats are computed locally from this single trades list on // ONE basis (closed_at, see inPeriod) for EVERY time window — we no longer // mix in the backend /performance aggregate for 30d, which used a different // basis (it filtered opened_at and capped at 30d) and produced numbers that // disagreed with the closed_at-based metric grid on the same screen. Limit // raised to 500 so the local computation covers ample history. async function loadAll(forcedEnv?: SignedEnvelope) { const accuracyPromise = swrFetch('signal-accuracy', 10 * 60_000, () => getSignalAccuracy()).catch(() => null) if (!address || !isConnected) { setTrades([]); setPrivateLocked(false) const a = await accuracyPromise if (aliveRef.current) setAccuracy(a) return } const snapAddr = address const gen = genRef.current const tradesEnv = getCachedViewEnvelope('view_trades', snapAddr) ?? (forcedEnv ?? getCachedViewEnvelope('view_user', snapAddr)) if (aliveRef.current) setPrivateLocked(!tradesEnv) try { const [t, a] = await Promise.all([ tradesEnv ? getTrades(snapAddr, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]), accuracyPromise, ]) // Discard if unmounted or if a newer loadAll call has started (wallet/tab change). if (aliveRef.current && gen === genRef.current) { setTrades(t); setAccuracy(a) } } catch { if (aliveRef.current && gen === genRef.current) setTrades([]) } } useEffect(() => { // B28/B36: clear stale previous-wallet data immediately before the async // fetch so the UI never shows another wallet's private P&L. setTrades([]) setPrivateLocked(false) // Navigation only uses a cached envelope — never auto-popup the wallet. void loadAll() // eslint-disable-next-line react-hooks/exhaustive-deps }, [address, isConnected]) // Mint a view_user envelope (one signature) — unlocks both /performance and // /trades (each accepts view_user as fallback) — then reload. async function handleUnlock() { if (!address) return setUnlocking(true) setUnlockErr('') try { const env = await getOrCreateViewEnvelope({ action: 'view_user', wallet: address, signMessageAsync, }) await loadAll(env) } catch (e) { const msg = e instanceof Error ? e.message : 'Signature cancelled' if (aliveRef.current) setUnlockErr(/reject|denied|cancel/i.test(msg) ? '' : msg) } finally { if (aliveRef.current) setUnlocking(false) } } // CRITICAL: never mix paper (simulated) and live (real-money) P&L into one // number. is_paper comes from the backend (hl_order_id == "paper"). Default // to LIVE — this page is the "did the bot make REAL money" view. A Paper/Live // toggle appears only when the wallet has both kinds of trades; otherwise we // auto-pick whichever set exists so a paper-only user still sees their data // (clearly labelled SIMULATED). const liveTrades = useMemo(() => trades.filter(t => !t.is_paper), [trades]) const paperTrades = useMemo(() => trades.filter(t => !!t.is_paper), [trades]) const hasBoth = liveTrades.length > 0 && paperTrades.length > 0 const effectiveMode: 'live' | 'paper' = hasBoth ? tradeMode : (liveTrades.length > 0 ? 'live' : (paperTrades.length > 0 ? 'paper' : 'live')) const modeTrades = effectiveMode === 'paper' ? paperTrades : liveTrades const isPaperView = effectiveMode === 'paper' const filteredTrades = modeTrades.filter((trade) => inPeriod(trade.closed_at, period)) const pricedTrades = filteredTrades.filter( (t) => t.pnl_usd !== null && t.pnl_usd !== undefined, ) const pnls = pricedTrades.map((t) => t.pnl_usd as number) const totalPnl = pnls.reduce((s, p) => s + p, 0) const wins = pnls.filter((p) => p > 0).length const winRate = pricedTrades.length ? (wins / pricedTrades.length) * 100 : 0 const bestTrade = pnls.length ? Math.max(...pnls) : 0 const worstTrade = pnls.length ? Math.min(...pnls) : 0 const avgTrade = pnls.length ? totalPnl / pnls.length : 0 const heldTrades = filteredTrades.filter(t => t.hold_seconds !== null) const avgHold = heldTrades.length ? heldTrades.reduce((sum, trade) => sum + (trade.hold_seconds ?? 0), 0) / heldTrades.length : 0 // One basis for every window: derive from the closed_at-filtered trades. const maxDrawdown = calcDrawdownPct(filteredTrades) const summaryPnl = totalPnl const metrics = [ { k: isZh ? '最大回撤' : 'Max drawdown', v: filteredTrades.length ? maxDrawdown.toFixed(1) + '%' : '—', sub: isZh ? '从高点到低点的最大跌幅' : 'Worst peak-to-trough', down: true, tip: 'Largest equity drop from a previous high. Tells you the worst losing streak you would have lived through.' }, { k: isZh ? '总交易数' : 'Total trades', v: String(filteredTrades.length || '—'), sub: isZh ? '已平仓机器人交易' : 'Closed bot executions', tip: 'How many closed positions in this window. Open trades not counted.' }, { k: isZh ? '平均持仓时间' : 'Avg hold time', v: filteredTrades.length ? fmtHold(Math.round(avgHold)) : '—', sub: isZh ? '按单笔计算' : 'Per trade', tip: 'Entry → exit duration averaged across every closed trade.' }, { k: isZh ? '平均单笔盈亏' : 'Avg trade P&L', v: pnls.length ? fmtMoney(avgTrade) : '—', sub: isZh ? '每笔交易均值' : 'Mean per trade', up: avgTrade > 0, tip: 'Total P&L ÷ number of trades. Positive = the strategy has edge per trade.' }, { k: isZh ? '最佳单笔' : 'Best trade', v: pnls.length ? fmtMoney(bestTrade) : '—', sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true, tip: 'Biggest realized gain on one trade in this window.' }, { k: isZh ? '最差单笔' : 'Worst trade', v: pnls.length ? fmtMoney(worstTrade) : '—', sub: isZh ? '单笔最大亏损' : 'Largest single loss', down: true, tip: 'Biggest realized loss on one trade. Should be bounded by your stop-loss setting.' }, ] return (

{isZh ? '分析面板' : 'Analytics'}

P&L · win rate · drawdown · signal accuracy — pick a time window on the right.
{hasBoth && (
{(['live', 'paper'] as const).map(m => ( ))}
)}
{(['7d', '30d', '90d', 'All'] as const).map(p => ( ))}
{/* Signal accuracy — always public, shown first so non-connected visitors can immediately see proof of signal quality without needing to log in. */} {accuracy && (
AI Signal Accuracy
{accuracy.total_directional_signals} directional signals measured Public · no login needed
{(() => { // One full-width block per measurement window. Big overall number // + a hit-rate bar (50% tick = coin-flip baseline) + per-direction // breakdown. `repeat(3, 1fr)` stretches across the card so no // whitespace is stranded on wide screens. const WINDOWS = ['m5', 'm15', 'm1h'] as const const WINDOW_LABEL = { m5: 'After 5 min', m15: 'After 15 min', m1h: 'After 1 hour' } as const const pctColor = (pct: number) => pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' const fmtBreakdown = (pct: number | null, sparse: boolean) => sparse || pct == null || Number.isNaN(pct) ? '—' : `${pct.toFixed(0)}%` // auto-fit, not repeat(3,1fr) — a hard 3-col grid overflowed the // viewport on phones and forced the whole page to scroll sideways. return (
{WINDOWS.map(w => { const pct = accuracy.overall[w].accuracy_pct const has = pct != null && !Number.isNaN(pct) return (
{WINDOW_LABEL[w]}
{has ? `${pct.toFixed(0)}%` : '—'}
{/* hit-rate bar with a 50% coin-flip tick */}
{has && (
)}
{Object.entries(accuracy.by_signal).map(([sig, d]) => { const sparse = d[w].checked < 2 const v = fmtBreakdown(d[w].accuracy_pct, sparse) const label = sig === 'buy' ? '🟢 Buy' : sig === 'short' ? '🔴 Short' : '🟡 Sell' return ( {label}{' '} {v} ({d.count}) ) })}
) })}
) })()}
)} {isPaperView && filteredTrades.length > 0 && (
📝 Showing SIMULATED (paper) performance — these are not real-money results. {hasBoth && ' Switch to 💰 Live above for actual P&L.'}
)} {privateLocked && (
Your performance data is private.
Sign once to unlock your P&L and trade history on this device (valid a few minutes — no transaction, no gas). Signal-accuracy stats below are always public.
{unlockErr && (
● {unlockErr}
)}
)}
{isZh ? '表现' : 'Performance'} · {period}
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
{/* Win-rate chip only when there are trades to rate — a red "0.0% win rate" next to an em-dash P&L misreads as a losing record when the wallet simply has no data. */} {pricedTrades.length > 0 && (
= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}
)} {filteredTrades.length === 0 && (
No closed bot trades in this window. P&L, win rate and the per-trade metrics below fill in once auto-trade executions close — or pick a longer window on the top right.
)}
{metrics.map(m => (
{m.k} {m.tip && }
{m.v}
{m.sub}
))}
{/* Signal accuracy is now shown at the top of the page (public, no login needed). Removed duplicate block here to avoid showing the same data twice. */} {/* Empty-state card renders only for states no other element explains: - "window empty but trades exist" → Performance hero's inline note - privateLocked → the unlock card at the top of the page */} {!filteredTrades.length && !privateLocked && (!isConnected || !address || !trades.length) && (

{!isConnected || !address ? (isZh ? '连接钱包以加载你的分析数据。' : 'Connect your wallet to load your analytics.') : (isZh ? '还没有交易数据。机器人开始执行后,这里会自动出现统计。' : 'No trade data yet. The bot will populate analytics once it starts executing.')}

)}
) }