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
+19 -10
View File
@@ -31,15 +31,17 @@ function inPeriod(iso: string, period: Period) {
}
function calcDrawdownPct(trades: BotTrade[]) {
const ordered = [...trades].sort(
(a, b) => new Date(a.closed_at).getTime() - new Date(b.closed_at).getTime()
)
// Skip externally-closed trades (pnl_usd null) — including them as `+ null`
// 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 peak = 0
let maxDrawdownPct = 0
for (const trade of ordered) {
equity += trade.pnl_usd
equity += trade.pnl_usd as number
peak = Math.max(peak, equity)
if (peak > 0) {
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 totalPnl = filteredTrades.reduce((sum, trade) => sum + trade.pnl_usd, 0)
const wins = filteredTrades.filter((trade) => trade.pnl_usd > 0).length
const winRate = filteredTrades.length ? (wins / filteredTrades.length) * 100 : 0
const bestTrade = filteredTrades.length ? Math.max(...filteredTrades.map(t => t.pnl_usd)) : 0
const worstTrade = filteredTrades.length ? Math.min(...filteredTrades.map(t => t.pnl_usd)) : 0
const avgTrade = filteredTrades.length ? filteredTrades.reduce((s, t) => s + t.pnl_usd, 0) / filteredTrades.length : 0
// Trades closed externally on Hyperliquid have pnl_usd === null. Including
// them silently in reduce/Math.max/Math.min produces NaN ("$NaN P&L") and
// also pollutes win_rate. Aggregate only on the priced subset.
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
const maxDrawdown = period === '30d' && perf ? perf.max_drawdown_pct : calcDrawdownPct(filteredTrades)
const summaryPnl = period === '30d' && perf && trades.length >= filteredTrades.length