594d9817ba
Money-safety/accuracy bug: analytics computed total P&L, win rate, drawdown, and every metric over ALL trades regardless of is_paper. A user who tried paper mode then went live saw simulated and real P&L summed into one number on the page whose entire purpose is 'did the bot make REAL money'. Now: split trades by is_paper. Default to LIVE. A Live/Paper toggle appears only when the wallet has both; otherwise auto-pick whichever exists. Paper view shows a prominent 'SIMULATED — not real-money results' banner. All metrics derive from the selected mode's trades only. Also: BotConfigPanel handleUpgradeToLive copy now states Auto-Trade is forced OFF on the paper→live switch (matches backend subscribe.py safety reset). tsc + next build clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
331 lines
16 KiB
TypeScript
331 lines
16 KiB
TypeScript
'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<BotTrade[]>([])
|
|
const [accuracy, setAccuracy] = useState<SignalAccuracy | null>(null)
|
|
const [period, setPeriod] = useState<Period>('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 (
|
|
<div className="page">
|
|
<div className="page-head">
|
|
<div>
|
|
<h1 className="page-title">{isZh ? '分析面板' : 'Analytics'}</h1>
|
|
<PageHint>
|
|
Did the bot actually make money? Win rate, drawdown, average trade,
|
|
and AI signal accuracy across the time window you pick on the right.
|
|
</PageHint>
|
|
</div>
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-end' }}>
|
|
{hasBoth && (
|
|
<div className="nav-tabs">
|
|
{(['live', 'paper'] as const).map(m => (
|
|
<button key={m}
|
|
className={`nav-tab ${tradeMode === m ? 'active' : ''}`}
|
|
onClick={() => setTradeMode(m)}>
|
|
{m === 'live' ? '💰 Live' : '📝 Paper'}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="nav-tabs">
|
|
{(['7d', '30d', '90d', 'All'] as const).map(p => (
|
|
<button key={p} className={`nav-tab ${period === p ? 'active' : ''}`} onClick={() => setPeriod(p)}>{p}</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{isPaperView && filteredTrades.length > 0 && (
|
|
<div className="card" style={{
|
|
padding: '10px 16px', marginBottom: 16, fontSize: 12, fontWeight: 600,
|
|
background: 'var(--amber-soft)',
|
|
borderColor: 'color-mix(in oklab, var(--amber) 30%, var(--line))',
|
|
color: 'var(--amber-ink, var(--ink))',
|
|
}}>
|
|
📝 Showing SIMULATED (paper) performance — these are not real-money results.
|
|
{hasBoth && ' Switch to 💰 Live above for actual P&L.'}
|
|
</div>
|
|
)}
|
|
|
|
{privateLocked && (
|
|
<div className="card" style={{ padding: 16, marginBottom: 20 }}>
|
|
<div style={{ fontSize: 13, fontWeight: 600 }}>Private performance is locked.</div>
|
|
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4, lineHeight: 1.6 }}>
|
|
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.
|
|
</div>
|
|
<button className="btn amber" style={{ fontSize: 13, padding: '8px 16px', marginTop: 12 }}
|
|
disabled={unlocking} onClick={handleUnlock}>
|
|
{unlocking ? 'Waiting for signature…' : 'Unlock performance'}
|
|
</button>
|
|
{unlockErr && (
|
|
<div style={{ fontSize: 11, color: 'var(--down)', marginTop: 8 }}>● {unlockErr}</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<div className="card" style={{ padding: 28, marginBottom: 20 }}>
|
|
<div className="row between" style={{ alignItems: 'flex-start', marginBottom: 20 }}>
|
|
<div>
|
|
<div className="tiny">{isZh ? '表现' : 'Performance'} · {period}</div>
|
|
<div className="hero-value mono" style={{ marginTop: 6, fontSize: 40 }}>
|
|
{filteredTrades.length ? fmtMoney(summaryPnl) : '—'}
|
|
</div>
|
|
<div className="row gap-s" style={{ marginTop: 8 }}>
|
|
<span className={`chip ${winRate >= 50 ? 'up' : 'down'}`}>{winRate.toFixed(1)}% {isZh ? '胜率' : 'win rate'}</span>
|
|
<span className="chip">{isZh ? `${filteredTrades.length} 笔交易` : `${filteredTrades.length} trades`}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="metric-grid" style={{ marginBottom: 20 }}>
|
|
{metrics.map(m => (
|
|
<div key={m.k} className="card" style={{ padding: 20, overflow: 'visible' }}>
|
|
<div className="tiny" style={{ marginBottom: 8, display: 'flex', alignItems: 'center' }}>
|
|
{m.k}
|
|
{m.tip && <InfoTip text={m.tip} placement="top" width={240} />}
|
|
</div>
|
|
<div className={`mono ${m.up ? 'delta up' : m.down ? 'delta down' : ''}`} style={{ fontSize: 24, fontWeight: 500, letterSpacing: '-0.01em' }}>{m.v}</div>
|
|
<div style={{ fontSize: 12, color: 'var(--ink-3)', marginTop: 4 }}>{m.sub}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{accuracy && (
|
|
<div className="card" style={{ padding: 24, marginBottom: 20 }}>
|
|
<div className="tiny" style={{ marginBottom: 16 }}>{isZh ? `AI 信号准确率 · ${accuracy.total_directional_signals} 条方向性信号` : `AI Signal Accuracy · ${accuracy.total_directional_signals} directional signals`}</div>
|
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 12 }}>
|
|
<div style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
|
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{isZh ? '整体' : 'Overall'}</div>
|
|
{(['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 (
|
|
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
|
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w.replace('m','').replace('1h','1h')}</span>
|
|
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6 }}>{isZh ? `${accuracy.overall.m5.checked} 条已测` : `${accuracy.overall.m5.checked} measured`}</div>
|
|
</div>
|
|
{Object.entries(accuracy.by_signal).map(([sig, data]) => {
|
|
const label = sig === 'buy' ? (isZh ? '🟢 做多' : '🟢 Buy') : sig === 'short' ? (isZh ? '🔴 做空' : '🔴 Short') : (isZh ? '🟡 卖出' : '🟡 Sell')
|
|
return (
|
|
<div key={sig} style={{ background: 'var(--surface-2)', borderRadius: 10, padding: '14px 16px' }}>
|
|
<div style={{ fontSize: 11, fontWeight: 600, color: 'var(--ink-3)', marginBottom: 8, textTransform: 'uppercase', letterSpacing: '0.06em' }}>{label} <span style={{ fontWeight: 400 }}>({data.count})</span></div>
|
|
{(['m5','m15','m1h'] as const).map(w => {
|
|
const d = data[w]
|
|
if (d.checked < 2) return (
|
|
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
|
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w}</span>
|
|
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>—</span>
|
|
</div>
|
|
)
|
|
const pct = d.accuracy_pct
|
|
const color = pct >= 55 ? 'var(--up)' : pct >= 45 ? 'var(--ink-2)' : 'var(--down)'
|
|
return (
|
|
<div key={w} className="row between" style={{ marginBottom: 4 }}>
|
|
<span style={{ fontSize: 12, color: 'var(--ink-3)' }}>{w}</span>
|
|
<span style={{ fontSize: 13, fontWeight: 600, color, fontVariantNumeric: 'tabular-nums' }}>{fmtAccuracyPct(pct)}</span>
|
|
</div>
|
|
)
|
|
})}
|
|
<div style={{ fontSize: 11, color: 'var(--ink-3)', marginTop: 6 }}>{isZh ? `${data.m5.checked} 条已测` : `${data.m5.checked} measured`}</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!filteredTrades.length && (
|
|
<div className="card" style={{ padding: 60, textAlign: 'center', color: 'var(--ink-3)' }}>
|
|
<p style={{ fontSize: 14 }}>
|
|
{!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.')}
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|