← back to Bertha
kalshi-dash/src/pages/Heatmap.jsx
987 lines
import React, { useState, useEffect, useMemo } from 'react';
import { api } from '../api';
import RingGauge from '../components/RingGauge';
import {
PieChart, Pie, Cell, Tooltip as RechartsTooltip, ResponsiveContainer,
BarChart, Bar, XAxis, YAxis, CartesianGrid,
} from 'recharts';
import { COLOR_CYCLE } from '../utils/chartTheme';
/* ── Chart 4: Category P&L Donut ── */
function CategoryDonutChart({ categories }) {
const entries = Object.entries(categories || {});
if (!entries.length) return null;
const pieData = entries
.map(([cat, markets], i) => {
const arr = Array.isArray(markets) ? markets : [];
const rawPnl = arr.reduce((s, m) => s + (m.pnl || 0), 0);
return {
name: cat,
value: Math.max(Math.abs(rawPnl), 1),
rawPnl,
color: COLOR_CYCLE[i % COLOR_CYCLE.length],
};
})
.filter(d => Math.abs(d.rawPnl) > 0)
.sort((a, b) => b.value - a.value);
const totalPnl = entries.reduce((s, [, markets]) => {
const arr = Array.isArray(markets) ? markets : [];
return s + arr.reduce((ss, m) => ss + (m.pnl || 0), 0);
}, 0);
const CustomTooltip = ({ active, payload }) => {
if (!active || !payload?.length) return null;
const d = payload[0]?.payload || {};
return (
<div style={{
background: '#1C1C24', border: '1px solid #2A2A35', borderRadius: 8,
padding: '8px 12px', fontSize: 12, fontFamily: "'Inter', sans-serif",
}}>
<p style={{ color: d.color, fontWeight: 700, marginBottom: 4 }}>{d.name}</p>
<p className="mono" style={{ color: d.rawPnl >= 0 ? '#00C389' : '#FF5C5C', fontWeight: 700 }}>
{d.rawPnl >= 0 ? '+' : ''}${(d.rawPnl / 100).toFixed(2)}
</p>
</div>
);
};
const CustomLegend = () => (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px 14px', justifyContent: 'center', marginTop: 8 }}>
{pieData.map(d => (
<div key={d.name} style={{ display: 'flex', alignItems: 'center', gap: 5 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: d.color, flexShrink: 0 }} />
<span style={{ fontSize: 11, color: '#A8A8A8', fontFamily: "'Inter', sans-serif" }}>{d.name}</span>
</div>
))}
</div>
);
return (
<div className="card" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<h3 className="section-title" style={{ fontSize: '1rem', marginBottom: 4, alignSelf: 'flex-start' }}>
P&L by Category
</h3>
<div style={{ position: 'relative', width: '100%', height: 220 }}>
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<defs>
{pieData.map(d => (
<radialGradient key={d.name} id={`donut-grad-${d.name}`} cx="50%" cy="50%" r="50%">
<stop offset="0%" stopColor={d.color} stopOpacity={0.9} />
<stop offset="100%" stopColor={d.color} stopOpacity={0.6} />
</radialGradient>
))}
</defs>
<Pie
data={pieData}
cx="50%"
cy="50%"
innerRadius={65}
outerRadius={95}
paddingAngle={2}
dataKey="value"
startAngle={90}
endAngle={-270}
>
{pieData.map((entry, i) => (
<Cell key={entry.name} fill={entry.color} fillOpacity={0.85} stroke="transparent" />
))}
</Pie>
<RechartsTooltip content={<CustomTooltip />} />
</PieChart>
</ResponsiveContainer>
{/* Center total P&L */}
<div style={{
position: 'absolute', inset: 0,
display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
pointerEvents: 'none',
}}>
<span style={{ fontSize: 10, color: '#6B7280', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.06em' }}>
Total P&L
</span>
<span className="mono" style={{
fontSize: 20, fontWeight: 700, lineHeight: 1.2,
color: totalPnl >= 0 ? '#00C389' : '#FF5C5C',
textShadow: `0 0 10px ${totalPnl >= 0 ? 'rgba(0,195,137,0.4)' : 'rgba(255,92,92,0.4)'}`,
}}>
{totalPnl >= 0 ? '+' : ''}${Math.abs(totalPnl / 100).toFixed(0)}
</span>
</div>
</div>
<CustomLegend />
</div>
);
}
/* ── Chart 5: Category Win/Loss Stacked Bar ── */
function CategoryWinLossChart({ categories }) {
const entries = Object.entries(categories || {});
if (!entries.length) return null;
const barData = entries
.map(([cat, markets]) => {
const arr = Array.isArray(markets) ? markets : [];
const wins = arr.reduce((s, m) => s + (m.wins || 0), 0);
const losses = arr.reduce((s, m) => s + (m.losses || 0), 0);
const total = wins + losses;
const wr = total > 0 ? ((wins / total) * 100).toFixed(1) : 0;
return { name: cat, wins, losses, total, wr: Number(wr) };
})
.filter(d => d.total > 0)
.sort((a, b) => b.total - a.total);
const CustomTooltip = ({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const d = payload[0]?.payload || {};
return (
<div style={{
background: '#1C1C24', border: '1px solid #2A2A35', borderRadius: 8,
padding: '10px 14px', fontSize: 12, fontFamily: "'Inter', sans-serif", minWidth: 150,
}}>
<p style={{ color: '#E0E0E0', fontWeight: 700, marginBottom: 6 }}>{label}</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span style={{ color: '#6B7280' }}>Wins</span>
<span className="mono" style={{ color: '#00C389', fontWeight: 700 }}>{d.wins}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span style={{ color: '#6B7280' }}>Losses</span>
<span className="mono" style={{ color: '#FF5C5C', fontWeight: 700 }}>{d.losses}</span>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: 12 }}>
<span style={{ color: '#6B7280' }}>Win Rate</span>
<span className="mono" style={{ color: d.wr >= 50 ? '#00C389' : '#FF5C5C', fontWeight: 700 }}>{d.wr}%</span>
</div>
</div>
</div>
);
};
return (
<div className="card">
<h3 className="section-title" style={{ fontSize: '1rem', marginBottom: 16 }}>
Category Win / Loss
</h3>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={barData} margin={{ top: 4, right: 16, left: 0, bottom: 4 }}>
<CartesianGrid
strokeDasharray="3 3"
stroke="#2A2A35"
strokeOpacity={0.6}
vertical={false}
/>
<XAxis
dataKey="name"
stroke="#2A2A35"
tick={{ fill: '#6B7280', fontSize: 10, fontFamily: "'Inter', sans-serif" }}
tickLine={false}
axisLine={{ stroke: '#2A2A35' }}
/>
<YAxis
stroke="#2A2A35"
tick={{ fill: '#6B7280', fontSize: 11, fontFamily: "'Inter', sans-serif" }}
tickLine={false}
axisLine={false}
allowDecimals={false}
/>
<RechartsTooltip content={<CustomTooltip />} cursor={{ fill: 'rgba(0,240,255,0.04)' }} />
<Bar dataKey="wins" stackId="a" fill="#00C389" fillOpacity={0.85} radius={[0, 0, 0, 0]} maxBarSize={40} name="Wins" />
<Bar dataKey="losses" stackId="a" fill="#FF5C5C" fillOpacity={0.85} radius={[4, 4, 0, 0]} maxBarSize={40} name="Losses" />
</BarChart>
</ResponsiveContainer>
{/* Legend */}
<div style={{ display: 'flex', justifyContent: 'center', gap: 20, marginTop: 8 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: '#00C389' }} />
<span style={{ fontSize: 11, color: '#A8A8A8' }}>Wins</span>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<div style={{ width: 10, height: 10, borderRadius: 2, background: '#FF5C5C' }} />
<span style={{ fontSize: 11, color: '#A8A8A8' }}>Losses</span>
</div>
</div>
</div>
);
}
// ── Market Category Classification ──
const CATEGORY_MAP = {
// Politics
'trump': 'Politics', 'president': 'Politics', 'impeach': 'Politics', 'congress': 'Politics',
'senate': 'Politics', 'house': 'Politics', 'governor': 'Politics', 'election': 'Politics',
'democrat': 'Politics', 'republican': 'Politics', 'vote': 'Politics', 'biden': 'Politics',
'vance': 'Politics', 'cabinet': 'Politics', 'scotus': 'Politics', 'supreme': 'Politics',
'pardon': 'Politics', 'approval': 'Politics', 'poll': 'Politics',
'secretary': 'Politics', 'defense': 'Politics', 'attorney': 'Politics',
// Economics
'gdp': 'Economy', 'inflation': 'Economy', 'cpi': 'Economy', 'fed': 'Economy',
'interest rate': 'Economy', 'unemployment': 'Economy', 'jobs': 'Economy', 'payroll': 'Economy',
'recession': 'Economy', 'stock': 'Economy', 'sp500': 'Economy', 's&p': 'Economy',
'nasdaq': 'Economy', 'dow': 'Economy', 'treasury': 'Economy', 'bond': 'Economy',
'debt': 'Economy', 'trade': 'Economy', 'tariff': 'Economy', 'deficit': 'Economy',
// Weather
'temperature': 'Weather', 'rain': 'Weather', 'snow': 'Weather', 'hurricane': 'Weather',
'weather': 'Weather', 'tornado': 'Weather', 'flood': 'Weather', 'heat': 'Weather',
'cold': 'Weather', 'storm': 'Weather', 'wind': 'Weather', 'precipitation': 'Weather',
'climate': 'Weather',
// Crypto
'bitcoin': 'Crypto', 'btc': 'Crypto', 'ethereum': 'Crypto', 'eth': 'Crypto',
'crypto': 'Crypto', 'token': 'Crypto', 'blockchain': 'Crypto', 'defi': 'Crypto',
// Sports
'nfl': 'Sports', 'nba': 'Sports', 'mlb': 'Sports', 'nhl': 'Sports',
'soccer': 'Sports', 'tennis': 'Sports', 'super bowl': 'Sports', 'championship': 'Sports',
'game': 'Sports', 'match': 'Sports', 'playoff': 'Sports', 'world series': 'Sports',
// Tech
'ai': 'Tech', 'spacex': 'Tech', 'tesla': 'Tech', 'apple': 'Tech',
'google': 'Tech', 'meta': 'Tech', 'openai': 'Tech', 'chatgpt': 'Tech',
'tech': 'Tech', 'rocket': 'Tech', 'launch': 'Tech',
// Culture
'oscar': 'Culture', 'movie': 'Culture', 'tv': 'Culture', 'celebrity': 'Culture',
'grammy': 'Culture', 'award': 'Culture', 'music': 'Culture', 'entertainment': 'Culture',
// Energy
'oil': 'Energy', 'gas': 'Energy', 'energy': 'Energy', 'solar': 'Energy',
'opec': 'Energy', 'crude': 'Energy', 'coal': 'Energy', 'renewable': 'Energy',
};
const CATEGORY_COLORS = {
'Politics': { bg: 'var(--blue)', glow: 'rgba(59,130,246,0.3)', hex: '#3B82F6' },
'Economy': { bg: 'var(--green)', glow: 'rgba(0,195,137,0.3)', hex: '#00C389' },
'Weather': { bg: 'var(--cyan)', glow: 'rgba(0,240,255,0.3)', hex: '#00F0FF' },
'Crypto': { bg: 'var(--orange)', glow: 'rgba(247,147,26,0.3)', hex: '#F7931A' },
'Sports': { bg: 'var(--purple)', glow: 'rgba(168,85,247,0.3)', hex: '#A855F7' },
'Tech': { bg: 'var(--cyan)', glow: 'rgba(0,240,255,0.3)', hex: '#00F0FF' },
'Culture': { bg: 'var(--orange)', glow: 'rgba(247,147,26,0.3)', hex: '#F7931A' },
'Energy': { bg: 'var(--orange)', glow: 'rgba(247,147,26,0.3)', hex: '#F7931A' },
'Other': { bg: 'var(--text-muted)', glow: 'rgba(107,114,128,0.3)', hex: '#6B7280' },
};
function classifyMarket(title) {
const lower = (title || '').toLowerCase();
for (const [keyword, category] of Object.entries(CATEGORY_MAP)) {
if (lower.includes(keyword)) return category;
}
return 'Other';
}
function getPnlColor(pnl) {
if (pnl > 50000) return 'var(--green)';
if (pnl > 10000) return '#4ade80';
if (pnl > 1000) return '#86efac';
if (pnl > 0) return '#bbf7d0';
if (pnl === 0) return 'var(--text-muted)';
if (pnl > -1000) return '#fecaca';
if (pnl > -10000) return '#f87171';
return 'var(--red)';
}
function getPnlBg(pnl) {
if (pnl > 50000) return 'rgba(0,195,137,0.35)';
if (pnl > 10000) return 'rgba(0,195,137,0.25)';
if (pnl > 1000) return 'rgba(0,195,137,0.15)';
if (pnl > 0) return 'rgba(0,195,137,0.08)';
if (pnl === 0) return 'rgba(107,114,128,0.15)';
if (pnl > -1000) return 'rgba(255,92,92,0.08)';
if (pnl > -10000) return 'rgba(255,92,92,0.15)';
return 'rgba(255,92,92,0.30)';
}
// ── Treemap Layout Algorithm ──
function squarify(items, x, y, w, h) {
if (!items.length) return [];
if (items.length === 1) {
return [{ ...items[0], x, y, w, h }];
}
const total = items.reduce((s, i) => s + i.size, 0);
if (total === 0) return items.map(i => ({ ...i, x, y, w: 0, h: 0 }));
// Split into two groups using golden ratio
let sum = 0;
let splitIdx = 0;
const target = total * 0.5;
for (let i = 0; i < items.length; i++) {
sum += items[i].size;
if (sum >= target) { splitIdx = i + 1; break; }
}
if (splitIdx === 0) splitIdx = 1;
if (splitIdx >= items.length) splitIdx = items.length - 1;
const left = items.slice(0, splitIdx);
const right = items.slice(splitIdx);
const leftSize = left.reduce((s, i) => s + i.size, 0);
const ratio = leftSize / total;
if (w >= h) {
const leftW = w * ratio;
return [
...squarify(left, x, y, leftW, h),
...squarify(right, x + leftW, y, w - leftW, h),
];
} else {
const leftH = h * ratio;
return [
...squarify(left, x, y, w, leftH),
...squarify(right, x, y + leftH, w, h - leftH),
];
}
}
// ── Individual Market Tile ──
function MarketTile({ item, onClick, isSelected }) {
const pnlDollars = (item.pnl / 100).toFixed(2);
const winRate = item.trades > 0 ? ((item.wins / item.trades) * 100).toFixed(0) : '0';
const isSmall = item.w < 120 || item.h < 80;
const isTiny = item.w < 80 || item.h < 50;
return (
<div
onClick={() => onClick(item)}
className="absolute cursor-pointer overflow-hidden group"
style={{
left: item.x,
top: item.y,
width: item.w,
height: item.h,
backgroundColor: getPnlBg(item.pnl),
border: isSelected
? '2px solid var(--cyan)'
: '1px solid var(--border)',
borderRadius: 6,
zIndex: isSelected ? 10 : 1,
transition: 'all 150ms ease',
boxShadow: isSelected ? '0 0 16px rgba(0,240,255,0.4)' : undefined,
}}
>
{/* Hover glow */}
<div
className="absolute inset-0 opacity-0 group-hover:opacity-100 pointer-events-none"
style={{
background: `radial-gradient(circle at center, ${getPnlBg(item.pnl).replace(')', ', transparent)').replace('rgba', 'radial-gradient').includes('radial') ? getPnlBg(item.pnl) : getPnlBg(item.pnl)}, transparent)`,
boxShadow: `inset 0 0 12px ${item.pnl >= 0 ? 'rgba(0,195,137,0.3)' : 'rgba(255,92,92,0.3)'}`,
transition: 'opacity 200ms ease',
}}
/>
<div className="relative h-full p-1.5 flex flex-col justify-between z-10">
{!isTiny && (
<>
<div className="truncate text-[10px] font-medium leading-tight" style={{ color: 'var(--text-dim)' }}>
{item.shortTitle}
</div>
<div className="flex items-end justify-between mt-auto">
<span className="mono font-bold text-xs" style={{ color: getPnlColor(item.pnl) }}>
{item.pnl >= 0 ? '+' : ''}{pnlDollars > 999 ? `$${(item.pnl / 100 / 1000).toFixed(1)}k` : `$${pnlDollars}`}
</span>
{!isSmall && (
<span className="text-[9px] font-bold mono" style={{
color: Number(winRate) >= 80 ? 'var(--green)' : Number(winRate) >= 50 ? 'var(--orange)' : 'var(--red)',
}}>
{winRate}%
</span>
)}
</div>
</>
)}
{isTiny && (
<div className="flex items-center justify-center h-full">
<span className="mono font-bold text-[10px]" style={{ color: getPnlColor(item.pnl) }}>
{item.pnl >= 0 ? '+' : ''}{(item.pnl / 100).toFixed(0)}
</span>
</div>
)}
</div>
</div>
);
}
// ── Category Header Tile ──
function CategoryBlock({ category, items, x, y, w, h, onTileClick, selectedTicker }) {
const catColor = CATEGORY_COLORS[category] || CATEGORY_COLORS.Other;
const totalPnl = items.reduce((s, i) => s + i.pnl, 0);
const totalTrades = items.reduce((s, i) => s + i.trades, 0);
const headerH = Math.min(28, h * 0.15);
const innerItems = squarify(items, 0, 0, w, h - headerH);
return (
<div
className="absolute overflow-hidden"
style={{
left: x,
top: y,
width: w,
height: h,
borderRadius: 8,
border: `1px solid ${catColor.hex}40`,
}}
>
{/* Category Header */}
<div
className="flex items-center justify-between px-2 relative z-20"
style={{
height: headerH,
background: `linear-gradient(135deg, ${catColor.hex}25, ${catColor.hex}0D)`,
borderBottom: `1px solid ${catColor.hex}40`,
}}
>
<span className="text-xs font-bold tracking-wide heading" style={{ color: catColor.hex }}>
{category}
</span>
<span className="text-[10px] mono" style={{ color: totalPnl >= 0 ? 'var(--green)' : 'var(--red)' }}>
{totalPnl >= 0 ? '+' : ''}${(totalPnl / 100).toFixed(0)} | {totalTrades} trades
</span>
</div>
{/* Market Tiles */}
<div className="relative" style={{ height: h - headerH }}>
{innerItems.map(tile => (
<MarketTile
key={tile.ticker}
item={{ ...tile, y: tile.y }}
onClick={onTileClick}
isSelected={selectedTicker === tile.ticker}
/>
))}
</div>
</div>
);
}
// ── Market Detail Panel ──
function DetailPanel({ market, onClose }) {
if (!market) return null;
const pnlDollars = (market.pnl / 100).toFixed(2);
const winRate = market.trades > 0 ? ((market.wins / market.trades) * 100).toFixed(1) : '0';
const catColor = CATEGORY_COLORS[market.category] || CATEGORY_COLORS.Other;
return (
<div className="card-glow fade-in">
<div className="flex items-start justify-between mb-4">
<div className="flex-1 min-w-0 mr-4">
<h3 className="heading text-sm font-bold truncate" style={{ color: 'var(--text)' }}>{market.title}</h3>
<p className="mono text-xs mt-1" style={{ color: 'var(--text-muted)' }}>{market.ticker}</p>
</div>
<button
onClick={onClose}
className="btn btn-ghost text-lg leading-none"
style={{ padding: '0 6px' }}
>
×
</button>
</div>
<div className="grid grid-cols-4 gap-3 text-center">
{[
{
label: 'P&L',
value: `${market.pnl >= 0 ? '+' : ''}$${pnlDollars}`,
color: market.pnl >= 0 ? 'var(--green)' : 'var(--red)',
},
{
label: 'Win Rate',
value: `${winRate}%`,
color: Number(winRate) >= 80 ? 'var(--green)' : Number(winRate) >= 50 ? 'var(--orange)' : 'var(--red)',
},
{
label: 'Trades',
value: market.trades.toLocaleString(),
color: 'var(--orange)',
},
{
label: 'Record',
valueEl: (
<p className="text-lg font-bold">
<span style={{ color: 'var(--green)' }}>{market.wins}</span>
<span style={{ color: 'var(--border-light)' }} className="mx-0.5">-</span>
<span style={{ color: 'var(--red)' }}>{market.losses}</span>
</p>
),
},
].map(s => (
<div
key={s.label}
className="rounded-lg p-2.5 text-center"
style={{ background: 'var(--surface-el)', border: '1px solid var(--border)' }}
>
<p className="stat-label mb-1">{s.label}</p>
{s.valueEl || (
<p className="stat-v text-lg" style={{ color: s.color }}>{s.value}</p>
)}
</div>
))}
</div>
<div className="mt-3 flex items-center justify-between text-xs">
<span style={{ color: 'var(--text-muted)' }}>
Category:{' '}
<span style={{ color: catColor.hex }} className="font-semibold">{market.category}</span>
</span>
{market.url && (
<a
href={market.url}
target="_blank"
rel="noopener noreferrer"
className="text-cyan transition-colors"
style={{ color: 'var(--cyan)' }}
>
View on Kalshi →
</a>
)}
</div>
{market.edge && (
<div
className="mt-2 text-[11px] rounded-lg px-3 py-2"
style={{
color: 'var(--text-dim)',
background: 'var(--surface-el)',
border: '1px solid var(--border)',
}}
>
{market.edge}
</div>
)}
</div>
);
}
// ── Legend ──
function Legend() {
const pnlRanges = [
{ label: '> $500', bg: 'rgba(0,195,137,0.35)' },
{ label: '$100-500', bg: 'rgba(0,195,137,0.25)' },
{ label: '$10-100', bg: 'rgba(0,195,137,0.15)' },
{ label: '$0-10', bg: 'rgba(0,195,137,0.08)' },
{ label: '$0', bg: 'rgba(107,114,128,0.15)' },
{ label: '-$10', bg: 'rgba(255,92,92,0.08)' },
{ label: '-$100', bg: 'rgba(255,92,92,0.15)' },
{ label: '< -$100', bg: 'rgba(255,92,92,0.30)' },
];
return (
<div className="flex items-center gap-2 flex-wrap">
<span className="stat-label mr-1">P&L Scale:</span>
{pnlRanges.map(r => (
<div key={r.label} className="flex items-center gap-1">
<div
className="w-3 h-3 rounded-sm"
style={{ backgroundColor: r.bg, border: '1px solid var(--border)' }}
/>
<span className="text-[9px]" style={{ color: 'var(--text-muted)' }}>{r.label}</span>
</div>
))}
</div>
);
}
// ── Main Heatmap Page ──
export default function Heatmap() {
const [data, setData] = useState(null);
const [signals, setSignals] = useState(null);
const [loading, setLoading] = useState(true);
const [selectedTicker, setSelectedTicker] = useState(null);
const [viewMode, setViewMode] = useState('category'); // 'category' | 'flat'
const [sortBy, setSortBy] = useState('pnl'); // 'pnl' | 'trades' | 'winrate'
useEffect(() => {
load();
const i = setInterval(load, 30000);
return () => clearInterval(i);
}, []);
async function load() {
try {
const [d, s] = await Promise.all([api('/dashboard'), api('/signals')]);
setData(d);
setSignals(s);
} catch {}
setLoading(false);
}
// Combine top_markets and worst_markets, strategies into categories
const { categories, allMarkets, summary } = useMemo(() => {
if (!data) return { categories: {}, allMarkets: [], summary: {} };
const markets = [];
const seen = new Set();
// Add top_markets
for (const m of (data.top_markets || [])) {
if (!seen.has(m.ticker)) {
seen.add(m.ticker);
const cat = classifyMarket(m.title);
markets.push({
...m,
category: cat,
trades: (m.wins || 0) + (m.losses || 0),
shortTitle: (m.title || m.ticker).split('?')[0].substring(0, 40),
size: Math.max(Math.abs(m.pnl || 1), 100),
});
}
}
// Add worst_markets
for (const m of (data.worst_markets || [])) {
if (!seen.has(m.ticker)) {
seen.add(m.ticker);
const cat = classifyMarket(m.title);
markets.push({
...m,
category: cat,
trades: (m.wins || 0) + (m.losses || 0),
shortTitle: (m.title || m.ticker).split('?')[0].substring(0, 40),
size: Math.max(Math.abs(m.pnl || 1), 100),
});
}
}
// Add strategies as synthetic market entries
for (const s of (data.strategies || [])) {
const key = `strategy-${s.strategy || s.name}`;
if (!seen.has(key)) {
seen.add(key);
markets.push({
ticker: key,
title: `Strategy: ${s.name}`,
shortTitle: s.name,
category: 'Other',
wins: s.wins || 0,
losses: s.losses || 0,
trades: (s.wins || 0) + (s.losses || 0),
pnl: s.pnl || 0,
edge: `Win Rate: ${s.win_rate || 0}%`,
size: Math.max(Math.abs(s.pnl || 1), 100),
});
}
}
// Sort
if (sortBy === 'pnl') markets.sort((a, b) => b.pnl - a.pnl);
else if (sortBy === 'trades') markets.sort((a, b) => b.trades - a.trades);
else if (sortBy === 'winrate') {
markets.sort((a, b) => {
const wrA = a.trades > 0 ? a.wins / a.trades : 0;
const wrB = b.trades > 0 ? b.wins / b.trades : 0;
return wrB - wrA;
});
}
// Group by category
const cats = {};
for (const m of markets) {
if (!cats[m.category]) cats[m.category] = [];
cats[m.category].push(m);
}
// Summary stats
const totalPnl = markets.reduce((s, m) => s + m.pnl, 0);
const totalTrades = markets.reduce((s, m) => s + m.trades, 0);
const totalWins = markets.reduce((s, m) => s + m.wins, 0);
const totalLosses = markets.reduce((s, m) => s + m.losses, 0);
const profitableCount = markets.filter(m => m.pnl > 0).length;
return {
categories: cats,
allMarkets: markets,
summary: { totalPnl, totalTrades, totalWins, totalLosses, profitableCount, totalMarkets: markets.length },
};
}, [data, sortBy]);
const selectedMarket = allMarkets.find(m => m.ticker === selectedTicker);
if (loading) return (
<div className="flex items-center justify-center h-64">
<div
className="w-8 h-8 rounded-full border-2 border-t-transparent animate-spin"
style={{ borderColor: 'var(--cyan)', borderTopColor: 'transparent' }}
/>
</div>
);
// Calculate treemap layout
const WIDTH = 1200;
const HEIGHT = 700;
const categoryEntries = Object.entries(categories).sort((a, b) => {
const pnlA = a[1].reduce((s, m) => s + Math.abs(m.pnl), 0);
const pnlB = b[1].reduce((s, m) => s + Math.abs(m.pnl), 0);
return pnlB - pnlA;
});
const categoryBlocks = squarify(
categoryEntries.map(([cat, items]) => ({
name: cat,
size: items.reduce((s, i) => s + Math.abs(i.pnl || 1), 0) + items.length * 100,
items,
})),
0, 0, WIDTH, HEIGHT
);
// Flat mode layout
const flatBlocks = viewMode === 'flat' ? squarify(
allMarkets.map(m => ({ ...m, size: Math.max(Math.abs(m.pnl || 1), 200) })),
0, 0, WIDTH, HEIGHT
) : [];
const summaryStats = [
{
label: 'Total P&L',
value: `${summary.totalPnl >= 0 ? '+' : ''}$${(summary.totalPnl / 100).toLocaleString(undefined, { minimumFractionDigits: 0 })}`,
color: summary.totalPnl >= 0 ? 'var(--green)' : 'var(--red)',
},
{ label: 'Total Trades', value: summary.totalTrades?.toLocaleString() || '0', color: 'var(--orange)' },
{
label: 'Win Rate',
value: summary.totalTrades > 0 ? `${((summary.totalWins / summary.totalTrades) * 100).toFixed(1)}%` : '0%',
color: 'var(--blue)',
},
{ label: 'Markets', value: summary.totalMarkets || 0, color: 'var(--purple)' },
{ label: 'Profitable', value: summary.profitableCount || 0, color: 'var(--green)' },
{ label: 'Categories', value: Object.keys(categories).length, color: 'var(--cyan)' },
];
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="heading text-2xl font-bold kalshi-gradient">Market Heatmap</h1>
<p className="section-subtitle mt-1">Treemap visualization of trading activity and P&L by market category</p>
</div>
<div className="flex items-center gap-3">
{/* View mode toggle */}
<div
className="flex items-center gap-0.5 rounded-lg p-0.5"
style={{ background: 'var(--surface-el)', border: '1px solid var(--border)' }}
>
{['category', 'flat'].map(mode => (
<button
key={mode}
onClick={() => setViewMode(mode)}
className="btn btn-sm"
style={viewMode === mode
? { background: 'var(--cyan)', color: 'var(--bg)', fontWeight: 700 }
: { background: 'transparent', color: 'var(--text-muted)' }
}
>
{mode === 'category' ? 'By Category' : 'All Markets'}
</button>
))}
</div>
{/* Sort select */}
<select
value={sortBy}
onChange={e => setSortBy(e.target.value)}
className="input"
style={{ width: 'auto', paddingRight: '1.5rem' }}
>
<option value="pnl">Sort by P&L</option>
<option value="trades">Sort by Trades</option>
<option value="winrate">Sort by Win Rate</option>
</select>
<button onClick={load} className="btn btn-o btn-sm">Refresh</button>
</div>
</div>
{/* Summary Stats — Win Rate and Profitable use RingGauge */}
<div className="grid grid-cols-6 gap-3">
{summaryStats.map((s, i) => {
const accentMap = ['card-accent-green', 'card-accent-orange', 'card-accent-blue', 'card-accent-purple', 'card-accent-green', 'card-accent-cyan'];
// Win Rate (index 2) gets a green ring gauge
if (s.label === 'Win Rate') {
const numericWR = parseFloat(s.value) || 0;
return (
<div key={s.label} className={`card ${accentMap[i] || 'card-accent-cyan'}`} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<RingGauge
value={numericWR}
size={52}
strokeWidth={4}
color={numericWR >= 80 ? 'var(--green)' : numericWR >= 50 ? 'var(--orange)' : 'var(--red)'}
/>
<div>
<p className="stat-label mb-0.5">{s.label}</p>
<p className="stat-v mono text-lg" style={{ color: s.color }}>{s.value}</p>
</div>
</div>
);
}
// Profitable (index 4) gets a cyan ring gauge (profitable% out of total markets)
if (s.label === 'Profitable') {
const totalMkts = summaryStats.find(x => x.label === 'Markets')?.value || 1;
const profitPct = totalMkts > 0 ? Math.round((Number(s.value) / Number(totalMkts)) * 100) : 0;
return (
<div key={s.label} className={`card ${accentMap[i] || 'card-accent-cyan'}`} style={{ display: 'flex', alignItems: 'center', gap: 12 }}>
<RingGauge
value={profitPct}
size={52}
strokeWidth={4}
color="var(--green)"
/>
<div>
<p className="stat-label mb-0.5">{s.label}</p>
<p className="stat-v mono text-lg" style={{ color: s.color }}>{s.value}</p>
</div>
</div>
);
}
return (
<div key={s.label} className={`card ${accentMap[i] || 'card-accent-cyan'}`}>
<p className="stat-label mb-1">{s.label}</p>
<p className="stat-v mono text-xl" style={{ color: s.color }}>{s.value}</p>
</div>
);
})}
</div>
{/* ── Charts 4 & 5: Category Donut + Win/Loss Bar ── */}
{Object.keys(categories).length > 0 && (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 2fr', gap: 16 }}>
<CategoryDonutChart categories={categories} />
<CategoryWinLossChart categories={categories} />
</div>
)}
{/* Category Pills */}
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(categories).map(([cat, items]) => {
const catColor = CATEGORY_COLORS[cat] || CATEGORY_COLORS.Other;
const catPnl = items.reduce((s, m) => s + m.pnl, 0);
return (
<div
key={cat}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-full"
style={{
backgroundColor: `${catColor.hex}18`,
border: `1px solid ${catColor.hex}40`,
}}
>
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: catColor.hex }} />
<span className="text-xs font-semibold heading" style={{ color: catColor.hex }}>{cat}</span>
<span className="text-[10px]" style={{ color: 'var(--text-muted)' }}>{items.length}</span>
<span
className="text-[10px] mono font-bold"
style={{ color: catPnl >= 0 ? 'var(--green)' : 'var(--red)' }}
>
{catPnl >= 0 ? '+' : ''}${(catPnl / 100).toFixed(0)}
</span>
</div>
);
})}
</div>
{/* Treemap */}
<div
className="card p-2 relative overflow-hidden"
style={{ minHeight: HEIGHT + 20 }}
>
<div className="relative" style={{ width: WIDTH, height: HEIGHT, margin: '0 auto', maxWidth: '100%' }}>
{viewMode === 'category' ? (
categoryBlocks.map(block => (
<CategoryBlock
key={block.name}
category={block.name}
items={block.items}
x={block.x}
y={block.y}
w={block.w}
h={block.h}
onTileClick={m => setSelectedTicker(m.ticker === selectedTicker ? null : m.ticker)}
selectedTicker={selectedTicker}
/>
))
) : (
flatBlocks.map(tile => (
<MarketTile
key={tile.ticker}
item={tile}
onClick={m => setSelectedTicker(m.ticker === selectedTicker ? null : m.ticker)}
isSelected={selectedTicker === tile.ticker}
/>
))
)}
</div>
{/* Legend overlay */}
<div className="mt-3 px-2">
<Legend />
</div>
</div>
{/* Selected Market Detail */}
{selectedMarket && (
<DetailPanel market={selectedMarket} onClose={() => setSelectedTicker(null)} />
)}
{/* Category Breakdown Table */}
<div className="card">
<div className="flex items-center gap-2 mb-4">
<h2 className="section-title">Category Breakdown</h2>
</div>
<table>
<thead>
<tr>
<th>Category</th>
<th>Markets</th>
<th>Total Trades</th>
<th>Win Rate</th>
<th>P&L</th>
<th>Best Market</th>
</tr>
</thead>
<tbody>
{categoryEntries.map(([cat, items]) => {
const catColor = CATEGORY_COLORS[cat] || CATEGORY_COLORS.Other;
const catPnl = items.reduce((s, m) => s + m.pnl, 0);
const catTrades = items.reduce((s, m) => s + m.trades, 0);
const catWins = items.reduce((s, m) => s + m.wins, 0);
const catWr = catTrades > 0 ? ((catWins / catTrades) * 100).toFixed(1) : '0';
const best = items.reduce((b, m) => m.pnl > (b?.pnl || -Infinity) ? m : b, items[0]);
return (
<tr key={cat}>
<td>
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: catColor.hex }} />
<span className="font-semibold heading" style={{ color: catColor.hex }}>{cat}</span>
</div>
</td>
<td style={{ color: 'var(--text-dim)' }}>{items.length}</td>
<td className="mono" style={{ color: 'var(--text-dim)' }}>{catTrades.toLocaleString()}</td>
<td
className="mono font-bold"
style={{
color: Number(catWr) >= 80 ? 'var(--green)'
: Number(catWr) >= 50 ? 'var(--orange)'
: 'var(--red)',
}}
>
{catWr}%
</td>
<td
className="mono font-bold"
style={{ color: catPnl >= 0 ? 'var(--green)' : 'var(--red)' }}
>
{catPnl >= 0 ? '+' : ''}${(catPnl / 100).toFixed(2)}
</td>
<td
className="text-xs truncate max-w-[200px]"
style={{ color: 'var(--text-muted)' }}
>
{best?.shortTitle || '--'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}