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:
@@ -132,7 +132,7 @@ function SelectHint() {
|
||||
|
||||
// ── Main dashboard ─────────────────────────────────────────────────────────────
|
||||
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') }
|
||||
const { address, isConnected } = useAccount()
|
||||
const [posts, setPosts] = useState<TrumpPost[]>(initialPosts)
|
||||
@@ -178,9 +178,27 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
const recentPosts = posts.slice(0, 8)
|
||||
|
||||
const lastCandle = candles[candles.length - 1]
|
||||
const firstCandle = candles[0]
|
||||
const priceChange = lastCandle && firstCandle
|
||||
? ((lastCandle.close - firstCandle.open) / firstCandle.open) * 100
|
||||
// Live price beats the most recent candle close — the candle is only
|
||||
// refreshed when the user changes asset/timeframe, so without WS the number
|
||||
// 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
|
||||
|
||||
const totalPosts = posts.length
|
||||
@@ -211,10 +229,10 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
<div className="kpi-row">
|
||||
<div className="kpi">
|
||||
<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">
|
||||
<span className={`delta ${priceChange >= 0 ? 'up' : 'down'}`}>{hasPriceData ? fmtPct(priceChange) : 'Feed pending'}</span>
|
||||
<span>{timeframe}</span>
|
||||
<span>24h</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="kpi">
|
||||
@@ -243,9 +261,9 @@ export default function DashboardClient({ initialPosts, initialPerformance }: Pr
|
||||
<div className="tiny">Price · {asset}</div>
|
||||
<div className="row gap-m" style={{ marginTop: 6 }}>
|
||||
<div className="hero-value mono" style={{ fontSize: 32 }}>
|
||||
{lastCandle ? '$' + Math.round(lastCandle.close).toLocaleString() : '—'}
|
||||
{displayPrice != null ? '$' + Math.round(displayPrice).toLocaleString() : '—'}
|
||||
</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 className="stack gap-s" style={{ alignItems: 'flex-end' }}>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -738,7 +738,12 @@ export default function TradesPage() {
|
||||
)}
|
||||
{filtered.map(t => {
|
||||
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 (
|
||||
<tr key={t.id}>
|
||||
<td>
|
||||
@@ -748,8 +753,8 @@ export default function TradesPage() {
|
||||
</div>
|
||||
</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.exit_price.toLocaleString()}</td>
|
||||
<td className="mono">{t.entry_price ? '$' + t.entry_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 style={{ maxWidth:260 }}>
|
||||
{tp ? (
|
||||
|
||||
@@ -13,7 +13,7 @@ interface 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 chartRef = useRef<unknown>(null)
|
||||
const seriesRef = useRef<unknown>(null)
|
||||
@@ -243,6 +243,27 @@ export default function ChartPanel({ posts = [], candles = [], externalSelectedI
|
||||
|
||||
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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
Reference in New Issue
Block a user