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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user