'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 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, period: Period) { 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) .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 } }, []) // `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) { if (!address || !isConnected) { setTrades([]); setAccuracy(null); setPrivateLocked(false) return } const tradesEnv = getCachedViewEnvelope('view_trades', address) ?? (forcedEnv ?? getCachedViewEnvelope('view_user', address)) if (aliveRef.current) setPrivateLocked(!tradesEnv) try { const [t, a] = await Promise.all([ tradesEnv ? getTrades(address, tradesEnv, 500, 1).catch(() => []) : Promise.resolve([]), getSignalAccuracy().catch(() => null), ]) if (aliveRef.current) { setTrades(t); setAccuracy(a) } } catch { if (aliveRef.current) setTrades([]) } } useEffect(() => { // 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 avgHold = filteredTrades.length ? filteredTrades.reduce((sum, trade) => sum + trade.hold_seconds, 0) / filteredTrades.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: 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: fmtMoney(bestTrade), sub: isZh ? '单笔最大盈利' : 'Largest single win', up: true, tip: 'Biggest realized gain on one trade in this window.' }, { k: isZh ? '最差单笔' : 'Worst trade', v: 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'}

Did the bot actually make money? Win rate, drawdown, average trade, and AI signal accuracy across the time window you pick on the right.
{hasBoth && (
{(['live', 'paper'] as const).map(m => ( ))}
)}
{(['7d', '30d', '90d', 'All'] as const).map(p => ( ))}
{isPaperView && filteredTrades.length > 0 && (
📝 Showing SIMULATED (paper) performance — these are not real-money results. {hasBoth && ' Switch to 💰 Live above for actual P&L.'}
)} {privateLocked && (
Private performance is locked.
Sign once to unlock your wallet-specific performance and trade history on this device (valid a few minutes, no transaction, no gas). Public signal-accuracy data below is always available.
{unlockErr && (
● {unlockErr}
)}
)}
{isZh ? '表现' : 'Performance'} · {period}
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'} {isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}
{metrics.map(m => (
{m.k} {m.tip && }
{m.v}
{m.sub}
))}
{accuracy && (
{isZh ? `AI 信号准确率 · ${accuracy.total_directional_signals} 条方向性信号` : `AI Signal Accuracy · ${accuracy.total_directional_signals} directional signals`}
{isZh ? '整体' : 'Overall'}
{(['m5','m15','m1h'] as const).map(w => { const d = accuracy.overall[w] const pct = d.accuracy_pct const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' return (
{w.replace('m','').replace('1h','1h')} {fmtAccuracyPct(pct)}
) })}
{isZh ? `${accuracy.overall.m5.checked} 条已测` : `${accuracy.overall.m5.checked} measured`}
{Object.entries(accuracy.by_signal).map(([sig, data]) => { const label = sig === 'buy' ? (isZh ? '🟢 做多' : '🟢 Buy') : sig === 'short' ? (isZh ? '🔴 做空' : '🔴 Short') : (isZh ? '🟡 卖出' : '🟡 Sell') return (
{label} ({data.count})
{(['m5','m15','m1h'] as const).map(w => { const d = data[w] if (d.checked < 2) return (
{w}
) const pct = d.accuracy_pct const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)' return (
{w} {fmtAccuracyPct(pct)}
) })}
{isZh ? `${data.m5.checked} 条已测` : `${data.m5.checked} measured`}
) })}
)} {!filteredTrades.length && (

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

)}
) }