fix: dashboard live price + 24h % + chart tick + null-pnl safety

Five bugs in the same blast radius (places we render a price/percent).

DashboardClient.tsx
  • Hero price read lastCandle.close (REST candle, frozen until user
    changes asset/tf). WebSocket livePrices was set but never read.
    Now: livePrice ?? lastCandle.close.
  • "+21.84% · 4H" was (last_close - first_open) / first_open over
    the entire candle window. limit=200 + tf=4H = 33 days shown as
    "4H". Find candle closest to now-24h, label "24h" consistently.

ChartPanel.tsx
  • Chart only re-rendered on candles prop change. Live WS ticks
    ignored. Added useEffect calling series.update() with
    livePrices[asset] on every tick.

analytics/page.tsx
  • reduce(sum + trade.pnl_usd) when pnl_usd is null → NaN poison.
  • Math.max/min over null cast to 0 → bogus "best/worst trade".
  • Win-rate denominator counted null-pnl trades as losses.
  • calcDrawdownPct had the same null poisoning.
  Fix: gate aggregations on priced subset only.

trades/page.tsx
  • ROI could divide by 0/null; entry/exit crashed on null.toLocaleString().
  Added guards.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
k
2026-05-08 19:57:28 +08:00
parent 040e1df685
commit 01be8e790b
4 changed files with 76 additions and 23 deletions
+26 -8
View File
@@ -132,7 +132,7 @@ function SelectHint() {
// ── Main dashboard ───────────────────────────────────────────────────────────── // ── Main dashboard ─────────────────────────────────────────────────────────────
export default function DashboardClient({ initialPosts, initialPerformance }: Props) { export default function DashboardClient({ initialPosts, initialPerformance }: Props) {
const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness } = useDashboardStore() const { asset, setAsset, timeframe, setTimeframe: _setTimeframe, setLivePrice, setSubscribed, setBotReadiness, setHlApiKeySet, isSubscribed, hlApiKeySet, botReadiness, livePrices } = useDashboardStore()
function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') } function setTimeframe(tf: string) { _setTimeframe(tf as '5m' | '15m' | '1H' | '4H' | '1D' | '1W') }
const { address, isConnected } = useAccount() const { address, isConnected } = useAccount()
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts) const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
@@ -178,9 +178,27 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
const recentPosts = posts.slice(0, 8) const recentPosts = posts.slice(0, 8)
const lastCandle = candles[candles.length - 1] const lastCandle = candles[candles.length - 1]
const firstCandle = candles[0] // Live price beats the most recent candle close — the candle is only
const priceChange = lastCandle && firstCandle // refreshed when the user changes asset/timeframe, so without WS the number
? ((lastCandle.close - firstCandle.open) / firstCandle.open) * 100 // is frozen at page load.
const livePrice = livePrices[asset]
const displayPrice = livePrice ?? lastCandle?.close ?? null
// 24-hour change. Picking by candle index is wrong (200×4H = 33 days).
// Find the candle whose `time` is closest to (now 24h) and use its open.
const now = lastCandle ? lastCandle.time : Math.floor(Date.now() / 1000)
const target24h = now - 24 * 3600
let baseline24h: typeof lastCandle | undefined
if (candles.length) {
baseline24h = candles[0]
for (const c of candles) {
if (c.time <= target24h) baseline24h = c
else break
}
}
const priceChange =
displayPrice != null && baseline24h
? ((displayPrice - baseline24h.open) / baseline24h.open) * 100
: 0 : 0
const totalPosts = posts.length const totalPosts = posts.length
@@ -211,10 +229,10 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div className="kpi-row"> <div className="kpi-row">
<div className="kpi"> <div className="kpi">
<div className="label"><span className="asset-dot btc" style={{ width: 10, height: 10 }} /> BTC</div> <div className="label"><span className="asset-dot btc" style={{ width: 10, height: 10 }} /> BTC</div>
<div className="value">{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}</div> <div className="value">{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}</div>
<div className="foot"> <div className="foot">
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'}</span> <span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'}</span>
<span>{timeframe}</span> <span>24h</span>
</div> </div>
</div> </div>
<div className="kpi"> <div className="kpi">
@@ -243,9 +261,9 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
<div className="tiny">Price · {asset}</div> <div className="tiny">Price · {asset}</div>
<div className="row gap-m" style={{ marginTop: 6 }}> <div className="row gap-m" style={{ marginTop: 6 }}>
<div className="hero-value mono" style={{ fontSize: 32 }}> <div className="hero-value mono" style={{ fontSize: 32 }}>
{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'} {displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
</div> </div>
<span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · {timeframe}</span> <span className={`chip ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'} · 24h</span>
</div> </div>
</div> </div>
<div className="stack gap-s" style={{ alignItems: 'flex-end' }}> <div className="stack gap-s" style={{ alignItems: 'flex-end' }}>
+19 -10
View File
@@ -31,15 +31,17 @@ function inPeriod(iso: string, period: Period) {
} }
function calcDrawdownPct(trades: BotTrade[]) { function calcDrawdownPct(trades: BotTrade[]) {
const ordered = [...trades].sort( // Skip externally-closed trades (pnl_usd null) — including them as `+ null`
(a, b) => new Date(a.closed_at).getTime() - new Date(b.closed_at).getTime() // turns equity into NaN and the whole drawdown chart goes blank.
) 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 equity = 0
let peak = 0 let peak = 0
let maxDrawdownPct = 0 let maxDrawdownPct = 0
for (const trade of ordered) { for (const trade of ordered) {
equity += trade.pnl_usd equity += trade.pnl_usd as number
peak = Math.max(peak, equity) peak = Math.max(peak, equity)
if (peak > 0) { if (peak > 0) {
maxDrawdownPct = Math.max(maxDrawdownPct, ((peak - equity) / peak) * 100) maxDrawdownPct = Math.max(maxDrawdownPct, ((peak - equity) / peak) * 100)
@@ -65,12 +67,19 @@ export default function AnalyticsPage() {
}, []) }, [])
const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period)) const filteredTrades = trades.filter((trade) => inPeriod(trade.closed_at, period))
const totalPnl = filteredTrades.reduce((sum, trade) => sum + trade.pnl_usd, 0) // Trades closed externally on Hyperliquid have pnl_usd === null. Including
const wins = filteredTrades.filter((trade) => trade.pnl_usd > 0).length // them silently in reduce/Math.max/Math.min produces NaN ("$NaN P&L") and
const winRate = filteredTrades.length ? (wins / filteredTrades.length) * 100 : 0 // also pollutes win_rate. Aggregate only on the priced subset.
const bestTrade = filteredTrades.length ? Math.max(...filteredTrades.map(t => t.pnl_usd)) : 0 const pricedTrades = filteredTrades.filter(
const worstTrade = filteredTrades.length ? Math.min(...filteredTrades.map(t => t.pnl_usd)) : 0 (t) => t.pnl_usd !== null && t.pnl_usd !== undefined,
const avgTrade = filteredTrades.length ? filteredTrades.reduce((s, t) => s + t.pnl_usd, 0) / filteredTrades.length : 0 )
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 const avgHold = filteredTrades.length ? filteredTrades.reduce((sum, trade) => sum + trade.hold_seconds, 0) / filteredTrades.length : 0
const maxDrawdown = period === '30d' && perf ? perf.max_drawdown_pct : calcDrawdownPct(filteredTrades) const maxDrawdown = period === '30d' && perf ? perf.max_drawdown_pct : calcDrawdownPct(filteredTrades)
const summaryPnl = period === '30d' && perf && trades.length >= filteredTrades.length const summaryPnl = period === '30d' && perf && trades.length >= filteredTrades.length
+8 -3
View File
@@ -738,7 +738,12 @@ export default function TradesPage() {
)} )}
{filtered.map(t => { {filtered.map(t => {
const tp = posts.find(p => p.id === t.trigger_post_id) const tp = posts.find(p => p.id === t.trigger_post_id)
const roi = ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1) // Guard: entry_price may be 0 on a corrupt row; exit_price may
// be null for externally-closed trades. Both → NaN otherwise.
const roi =
t.entry_price && t.exit_price != null
? ((t.exit_price - t.entry_price) / t.entry_price) * 100 * (t.side === 'long' ? 1 : -1)
: 0
return ( return (
<tr key={t.id}> <tr key={t.id}>
<td> <td>
@@ -748,8 +753,8 @@ export default function TradesPage() {
</div> </div>
</td> </td>
<td><span className={`side-pill ${t.side}`}>{t.side==='long'?' LONG':' SHORT'}</span></td> <td><span className={`side-pill ${t.side}`}>{t.side==='long'?' LONG':' SHORT'}</span></td>
<td className="mono">${t.entry_price.toLocaleString()}</td> <td className="mono">{t.entry_price ? '$' + t.entry_price.toLocaleString() : ''}</td>
<td className="mono">${t.exit_price.toLocaleString()}</td> <td className="mono">{t.exit_price != null ? '$' + t.exit_price.toLocaleString() : ''}</td>
<td className="mono" style={{ color:'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td> <td className="mono" style={{ color:'var(--ink-2)' }}>{fmtHold(t.hold_seconds)}</td>
<td style={{ maxWidth:260 }}> <td style={{ maxWidth:260 }}>
{tp ? ( {tp ? (
+22 -1
View File
@@ -13,7 +13,7 @@ interface ChartPanelProps {
} }
export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost, onSelectDayPosts }: ChartPanelProps) { export default function ChartPanel({ posts = [], candles = [], externalSelectedId, onSelectPost, onSelectDayPosts }: ChartPanelProps) {
const { timeframe } = useDashboardStore() const { timeframe, asset, livePrices } = useDashboardStore()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
const chartRef = useRef<unknown>(null) const chartRef = useRef<unknown>(null)
const seriesRef = useRef<unknown>(null) const seriesRef = useRef<unknown>(null)
@@ -243,6 +243,27 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
useEffect(() => { fittedRef.current = false }, [timeframe]) useEffect(() => { fittedRef.current = false }, [timeframe])
// Live-tick the rightmost candle so the chart feels alive between REST polls.
// lightweight-charts' `series.update()` either appends a new bar (newer time)
// or in-place mutates the bar at that timestamp. We always keep `time` ==
// the last candle's bucket so the bar grows in place; high/low expand if
// the live tick exceeds them.
useEffect(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const series = seriesRef.current as any
if (!series) return
const live = livePrices[asset]
if (live == null || !candles.length) return
const last = candles[candles.length - 1]
series.update({
time: last.time as number,
open: last.open,
high: Math.max(last.high, live),
low: Math.min(last.low, live),
close: live,
})
}, [livePrices, asset, candles])
return ( return (
<div <div
ref={containerRef} ref={containerRef}