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 ─────────────────────────────────────────────────────────────
|
// ── 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,10 +178,28 @@ 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.
|
||||||
: 0
|
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
|
const totalPosts = posts.length
|
||||||
const todayKey = new Date().toISOString().slice(0, 10)
|
const todayKey = new Date().toISOString().slice(0, 10)
|
||||||
@@ -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' }}>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
Reference in New Issue
Block a user