fix(analytics): never mix paper + live P&L; settings live-switch copy

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>
This commit is contained in:
k
2026-05-30 01:03:15 +08:00
parent e78a61bd6e
commit 594d9817ba
8 changed files with 72 additions and 25 deletions
+46 -6
View File
@@ -1,6 +1,6 @@
'use client'
import { useState, useEffect, useRef } from 'react'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useLocale } from 'next-intl'
import { useAccount, useSignMessage } from 'wagmi'
import { getTrades, getSignalAccuracy } from '@/lib/api'
@@ -67,6 +67,7 @@ export default function AnalyticsPageClient() {
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('')
@@ -131,7 +132,21 @@ export default function AnalyticsPageClient() {
}
}
const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period))
// 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,
)
@@ -172,13 +187,38 @@ export default function AnalyticsPageClient() {
and AI signal accuracy across the time window you pick on the right.
</PageHint>
</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 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>