import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTopTraders, type Trader } from '../hooks/useTopTraders';
import { useBacktests } from '../hooks/useBacktests';
import { useCopySubscriptions } from '../hooks/useCopySubscriptions';
import { useT } from '../i18n/LanguageContext';
import { formatVolume, timeAgo } from '../utils/format';
import { whaleStyle, whaleCodename, whaleCodenamesUnique, whaleHorizon } from '../utils/whales';
import '../redesign/editorial.css';
// Board avatar: crisp emoji (shark = COPY/Winners, skull = FADE/Losers). Photo crops were
// illegible at 42px; emoji render sharp at any size and stay on-brand.
const boardEmoji = (mode: 'copy' | 'fade') => (mode === 'fade' ? '💀' : '🦈');
type Sort = 'edge' | 'quality' | 'volume' | 'win_rate' | 'trades' | 'activity' | 'profit';
// PF on a tiny sample is noise, so the copy-fit badge only turns green with at least
// this many backtested copies — below it even a high (or ∞) PF stays yellow.
const MIN_GREEN_COPIES = 20;
const SORTS: { key: Sort; labelKey: string }[] = [
{ key: 'edge', labelKey: 'leaders.sort.edge' },
{ key: 'quality', labelKey: 'leaders.sort.quality' },
{ key: 'activity', labelKey: 'leaders.sort.activity' },
{ key: 'profit', labelKey: 'leaders.sort.pnl' },
{ key: 'volume', labelKey: 'leaders.sort.volume' },
{ key: 'win_rate', labelKey: 'leaders.sort.winrate' },
{ key: 'trades', labelKey: 'leaders.sort.trades' },
];
// Parse the last-16 settled per-trade PnL array (same source as the sparkline) into numbers.
function sparkSeries(raw: string | null): number[] | null {
if (!raw) return null;
let arr: unknown;
try { arr = JSON.parse(raw); } catch { return null; }
if (!Array.isArray(arr)) return null;
const nums = arr.filter((v): v is number => typeof v === 'number');
return nums.length ? nums : null;
}
// EDGE composite (0..10) from REAL whale stats already in the payload — transparent,
// graceful with nulls. Client-side so it drives the badge + sort without backend risk.
// Weights: win-rate 30%, backtest PF (our config) 30%, sample 15%, realized PnL 15%,
// activity 10%, plus consistency (Sharpe-like) 12% & low-drawdown 8% from the PnL series.
function edgeScore(t: Trader): number | null {
const clamp = (x: number) => Math.max(0, Math.min(1, x));
// Confidence from the SETTLED sample (resolved positions): luck-prone skill metrics
// (win-rate, PF, realized PnL) earn full credit only once ~25 positions have actually
// settled — below that they shrink toward 0, so a "100% on 3 trades" whale can't fake
// the top of the board. Unknown sample (null) keeps full credit (no regression).
const conf = t.closedPositions != null ? clamp(t.closedPositions / 25) : 1;
const parts: [number, number][] = [];
const wr = t.winRatePos ?? t.winRate;
if (wr != null) parts.push([clamp((wr - 0.4) / 0.5) * conf, 0.30]);
if (t.backtestPf != null) parts.push([clamp(t.backtestPf / 2) * conf, 0.30]);
parts.push([clamp(Math.log10((t.totalTrades || 0) + 1) / Math.log10(500)), 0.15]);
const pnl = t.realizedPnlPos ?? t.totalPnl;
if (pnl != null) parts.push([clamp(pnl / 50000) * conf, 0.15]);
if (t.activeDays != null) parts.push([clamp(t.activeDays / 30), 0.10]);
// Consistency (Sharpe-like) & max-drawdown from the last-16 settled-PnL series — same
// data as the sparkline, computed client-side. Both are scale-invariant so they compare
// fairly across whales of different size. Luck-prone too, so they share the conf shrink.
const series = sparkSeries(t.spark);
if (series && series.length >= 4) {
const mean = series.reduce((a, b) => a + b, 0) / series.length;
const sd = Math.sqrt(series.reduce((a, b) => a + (b - mean) ** 2, 0) / series.length);
const sharpe = sd > 1e-9 ? mean / sd : (mean > 0 ? 1 : 0); // steady positive PnL → high
parts.push([clamp((sharpe + 0.5) / 1.5) * conf, 0.12]);
let cum = 0, peak = 0, maxDD = 0; // deepest equity dip as a fraction of the running peak
for (const v of series) { cum += v; if (cum > peak) peak = cum; if (peak > 0) maxDD = Math.max(maxDD, (peak - cum) / peak); }
parts.push([clamp(1 - maxDD) * conf, 0.08]);
}
if (parts.length === 0) return null;
const wsum = parts.reduce((a, [, w]) => a + w, 0);
const s = parts.reduce((a, [v, w]) => a + v * w, 0) / wsum;
return Math.round(s * 100) / 10; // 0..10, one decimal
}
// Build a sparkline from the last-16 per-trade PnL array (cumulative equity shape).
function sparkPoints(raw: string | null): { pts: string; up: boolean } | null {
if (!raw) return null;
let arr: unknown;
try { arr = JSON.parse(raw); } catch { return null; }
if (!Array.isArray(arr) || arr.length < 2) return null;
let cum = 0;
const cums = arr.map(v => (cum += (typeof v === 'number' ? v : 0)));
const min = Math.min(...cums), max = Math.max(...cums), range = (max - min) || 1;
const W = 48, H = 22;
const pts = cums.map((c, i) =>
`${((i / (cums.length - 1)) * W).toFixed(1)},${(H - ((c - min) / range) * H).toFixed(1)}`
).join(' ');
return { pts, up: cums[cums.length - 1] >= cums[0] };
}
/**
* 🐋 Лидеры — главный экран копитрейдинга. Лидерборд копируемых китов поверх
* /api/signals/top-traders (whale_wallets). Старт = пассивные киты (публичные
* ончейн-данные, бейдж «📊 Публичные данные»). Кнопка «Копировать» ведёт на профиль
* лидера, где живёт копи-конфиг (добавляется в следующих шагах).
*/
// Honest positional win-rate preferred over legacy chance %; both 0..1.
const wrOf = (t: { winRatePos: number | null; winRate: number | null }) =>
t.winRatePos ?? t.winRate;
// Slider + synced number input. value 0 means "filter off".
function FilterSlider(props: {
label: string; value: number; onChange: (v: number) => void;
min: number; max: number; step: number; suffix?: string; prefix?: string;
}) {
const { label, value, onChange, min, max, step, suffix, prefix } = props;
return (
<div className="lead-filter-row">
<div className="lead-filter-top">
<span className="lead-filter-label">{label}</span>
<span className="lead-filter-val">
{value > 0 ? `${prefix ?? ''}${value}${suffix ?? ''}` : '—'}
</span>
</div>
<div className="lead-filter-ctl">
<input type="range" min={min} max={max} step={step} value={value}
onChange={e => onChange(Number(e.target.value))} style={{ flex: 1 }} />
<input type="number" min={min} max={max} step={step} value={value || ''}
placeholder="0" onChange={e => onChange(Number(e.target.value) || 0)}
className="lead-filter-num" />
</div>
</div>
);
}
const FILTERS_KEY = 'sz_leaders_filters';
function loadFilters(): Record<string, number | boolean | string> {
try { return JSON.parse(localStorage.getItem(FILTERS_KEY) || '{}'); } catch { return {}; }
}
export default function LeadersPage() {
const navigate = useNavigate();
const saved = loadFilters();
const [sort, setSort] = useState<Sort>((saved.sort as Sort) || 'edge');
// Board mode: 'copy' = Winners (best first, copy them) · 'fade' = Losers (worst first, bet against).
const [boardMode, setBoardMode] = useState<'copy' | 'fade'>('copy');
// ONE constant pool (quality spans winners AND losers); ALL ranking is client-side below.
// → sort tabs & COPY/FADE switch are instant (no refetch, no loading flicker / broken layout).
const serverSort: Sort = 'quality';
// Pull the whole gated pool (~300+), not just top-40 — client-side filters
// need the full set, and the old 40-cap + slice(30) is what showed "only 17".
const { traders, loading } = useTopTraders(serverSort, 300);
// Phase E: expected PF/PnL "if we copied them under our config" — copy-fit badge.
const backtests = useBacktests(traders.map(t => t.address));
const { subs } = useCopySubscriptions();
const { t: tr, lang } = useT();
// Quality filters (client-side, instant). 0 = off. Persisted to localStorage.
const [showFilters, setShowFilters] = useState(false);
const [minWr, setMinWr] = useState(Number(saved.minWr) || 0); // %
const [minPf, setMinPf] = useState(Number(saved.minPf) || 0); // profit factor
const [minClosed, setMinClosed] = useState(Number(saved.minClosed) || 0); // closed positions
const [minAvgSize, setMinAvgSize] = useState(Number(saved.minAvgSize) || 0); // avg trade size $
const [minActiveDays, setMinActiveDays] = useState(Number(saved.minActiveDays) || 0); // active days /30
const [minPnl, setMinPnl] = useState(Number(saved.minPnl) || 0); // realized positional PnL $
const [maxAvgPrice, setMaxAvgPrice] = useState(Number(saved.maxAvgPrice) || 0); // avg entry price ceiling, % (0=off)
const [onlyQuality, setOnlyQuality] = useState(Boolean(saved.onlyQuality));
useEffect(() => {
localStorage.setItem(FILTERS_KEY, JSON.stringify({
sort, minWr, minPf, minClosed, minAvgSize, minActiveDays, minPnl, maxAvgPrice, onlyQuality,
}));
}, [sort, minWr, minPf, minClosed, minAvgSize, minActiveDays, minPnl, maxAvgPrice, onlyQuality]);
const activeFilters = (minWr > 0 ? 1 : 0) + (minPf > 0 ? 1 : 0) + (minClosed > 0 ? 1 : 0)
+ (minAvgSize > 0 ? 1 : 0) + (minActiveDays > 0 ? 1 : 0) + (minPnl > 0 ? 1 : 0)
+ (maxAvgPrice > 0 ? 1 : 0) + (onlyQuality ? 1 : 0);
const resetFilters = () => {
setMinWr(0); setMinPf(0); setMinClosed(0); setMinAvgSize(0); setMinActiveDays(0); setMinPnl(0); setMaxAvgPrice(0); setOnlyQuality(false);
};
// Hide whales the user already copies (active/paused) — they live in My Copies.
// Archived stay visible (re-copyable) and render as plain discovery cards.
const copied = new Map(subs.map(s => [s.address.toLowerCase(), s.status]));
const filtered = traders
.filter(t => { const st = copied.get(t.address.toLowerCase()); return st !== 'active' && st !== 'paused'; })
.filter(t => {
if (onlyQuality && t.qualityFlag !== 1) return false;
if (minAvgSize > 0 && (t.avgTradeSize ?? 0) < minAvgSize) return false;
if (minActiveDays > 0 && (t.activeDays ?? 0) < minActiveDays) return false;
if (minPnl > 0 && (t.realizedPnlPos ?? -Infinity) < minPnl) return false;
if (maxAvgPrice > 0 && (t.avgBuy == null || t.avgBuy * 100 > maxAvgPrice)) return false;
if (minWr > 0) { const wr = wrOf(t); if (wr == null || wr * 100 < minWr) return false; }
if (minPf > 0 && (t.backtestPf == null || t.backtestPf < minPf)) return false;
if (minClosed > 0 && (t.closedPositions ?? 0) < minClosed) return false;
return true;
});
// All ranking client-side on the single pool. The active sort tab picks the metric; COPY shows
// the BEST first, FADE the WORST first (real losers). FADE also requires a real settled sample
// so it never surfaces "100% win-rate on 2 trades" noise.
const metricOf = (t: Trader): number => {
switch (sort) {
case 'volume': return t.totalVolume ?? 0;
case 'win_rate': return wrOf(t) ?? -1;
case 'trades': return t.totalTrades ?? 0;
case 'activity': return t.lastSeen ? Date.parse(t.lastSeen) || 0 : 0; // recency = what the card shows ("active 52 min")
case 'profit': return t.realizedPnlPos ?? t.totalPnl ?? 0;
case 'quality': return (t.qualityFlag ?? 0) * 1000 + (edgeScore(t) ?? 0);
default: return edgeScore(t) ?? -1; // 'edge'
}
};
// FADE board = ONLY genuine fade targets: traders who actually LOSE money (negative realized
// P&L) with a real settled sample. This excludes high-win-rate / positive whales that aren't
// losers at all (the "80% WR shows up in Losers" bug).
const FADE_MIN_SAMPLE = 10;
const realizedOf = (t: Trader) => t.realizedPnlPos ?? t.totalPnl ?? 0;
const isFadeTarget = (t: Trader) => (t.closedPositions ?? 0) >= FADE_MIN_SAMPLE && realizedOf(t) < 0;
const pool = boardMode === 'fade' ? filtered.filter(isFadeTarget) : filtered;
// Direction: performance metrics (worse = better fade target) sort WORST-first; engagement
// metrics (activity/volume/trades) sort normally DESC so e.g. "Activity" brings the most-active
// losers up (what the user expects), not the least-active.
const FADE_PERF_METRIC = sort === 'edge' || sort === 'quality' || sort === 'win_rate' || sort === 'profit';
const fadeAsc = boardMode === 'fade' && FADE_PERF_METRIC;
const ordered = [...pool].sort((a, b) => {
const d = metricOf(b) - metricOf(a); // descending = best/highest first
return fadeAsc ? -d : d; // perf metrics in FADE = worst first
});
const displayCount = ordered.length;
const visible = ordered.slice(0, 100);
const codenames = whaleCodenamesUnique(visible.map(t => t.address), lang);
return (
<div className="pk-leaders">
<header className="pk-hh">
{/* COPY board → Winners (copy the best) · FADE board → Losers (bet against the worst). */}
<h1>{tr(boardMode === 'fade' ? 'leaders.titleFade' : 'leaders.titleCopy')}</h1>
<p>{tr('leaders.sub')}</p>
<div className="pk-ltoggle" data-side={boardMode}>
<span className="pk-c" onClick={() => setBoardMode('copy')}>⇄ COPY</span>
<span className="pk-f" onClick={() => setBoardMode('fade')}>💀 FADE</span>
</div>
</header>
<div className="pk-sortbar">
{SORTS.map(s => (
<button
key={s.key}
className={`pk-sort ${sort === s.key ? 'active' : ''}`}
onClick={() => setSort(s.key)}
>
{tr(s.labelKey)}
</button>
))}
</div>
<div className="pk-fbar">
<button
className={`pk-ftoggle ${activeFilters > 0 ? 'active' : ''}`}
onClick={() => setShowFilters(v => !v)}
>
{tr('leaders.filters')}{activeFilters > 0 ? ` · ${activeFilters}` : ''} {showFilters ? '▴' : '▾'}
</button>
<span className="pk-found">{tr('leaders.found', { n: displayCount })}</span>
{activeFilters > 0 && (
<button className="pk-freset" onClick={resetFilters}>{tr('leaders.filterReset')}</button>
)}
</div>
{showFilters && (
<div className="lead-filter-panel">
<FilterSlider label={tr('leaders.filterWr')} value={minWr} onChange={setMinWr}
min={0} max={100} step={1} suffix="%" />
<FilterSlider label={tr('leaders.filterPf')} value={minPf} onChange={setMinPf}
min={0} max={3} step={0.1} />
<FilterSlider label={tr('leaders.filterClosed')} value={minClosed} onChange={setMinClosed}
min={0} max={100} step={1} />
<FilterSlider label={tr('leaders.filterAvgSize')} value={minAvgSize} onChange={setMinAvgSize}
min={0} max={20000} step={500} prefix="$" />
<FilterSlider label={tr('leaders.filterActiveDays')} value={minActiveDays} onChange={setMinActiveDays}
min={0} max={30} step={1} />
<FilterSlider label={tr('leaders.filterPnl')} value={minPnl} onChange={setMinPnl}
min={0} max={100000} step={1000} prefix="$" />
<FilterSlider label={tr('leaders.filterAvgPrice')} value={maxAvgPrice} onChange={setMaxAvgPrice}
min={0} max={95} step={5} suffix="¢" />
<label className="lead-filter-quality">
<input type="checkbox" checked={onlyQuality} onChange={e => setOnlyQuality(e.target.checked)} />
{tr('leaders.filterQuality')}
</label>
</div>
)}
{loading && traders.length === 0 ? (
<div>
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="pk-skel" />
))}
</div>
) : visible.length === 0 ? (
<div className="wf-empty">
<div className="wf-empty-icon">🐋</div>
<div className="wf-empty-title">{tr('leaders.emptyTitle')}</div>
<div className="wf-empty-sub">
{tr('leaders.emptySub')}
</div>
</div>
) : (
<div className="pk-list">
{visible.map((t, i) => {
const wrNum = wrOf(t);
const wr = wrNum != null ? `${Math.round(wrNum * 100)}%` : '—';
const wrCls = wrNum == null
? '' : wrNum >= 0.6 ? 'lead-wr-good' : wrNum <= 0.4 ? 'lead-wr-bad' : '';
const style = whaleStyle(t.avgBuy, t.sellRate);
const horizon = whaleHorizon(t.totalTrades, t.firstSeen, t.lastSeen);
const pnlTxt = t.totalPnl != null ? `${t.totalPnl >= 0 ? '+' : '−'}${formatVolume(Math.abs(t.totalPnl))}` : '—';
const momTxt = t.pnl7d != null ? `${t.pnl7d >= 0 ? '+' : '−'}${formatVolume(Math.abs(t.pnl7d))}` : '—';
const edge = edgeScore(t);
return (
<div
key={t.address}
className="pk-card"
onClick={() => navigate('/whale', { state: { address: t.address, mode: boardMode } })}
>
<div className="pk-chead">
<span className="pk-crank">{i + 1}</span>
<div className="pk-cav" aria-hidden>{boardEmoji(boardMode)}</div>
<div className="pk-cid">
<span className="pk-cname">
{codenames.get(t.address.toLowerCase()) ?? whaleCodename(t.address, lang)}
{style && (
<span title={tr(style.key)} style={{ marginLeft: 5, fontSize: 12 }}>{style.icon}</span>
)}
{horizon && (
<span title={tr(horizon.key)} style={{ marginLeft: 4, fontSize: 12 }}>{horizon.icon}</span>
)}
{t.qualityFlag === 1 && (
<span title={tr('leaders.qualityTip')} style={{ marginLeft: 4, fontSize: 12 }}>⭐</span>
)}
</span>
<span className="pk-cmeta">
{edge != null && <span className="pk-cedge" title={tr('leaders.edgeTip')}>EDGE {edge.toFixed(1)}</span>}
{t.topCategory && <span className="pk-ccat">{t.topCategory}</span>}
<span className="pk-conline"><i className="pk-cdot" />{tr('leaders.active')} {timeAgo(t.lastSeen)}</span>
</span>
</div>
<div className="pk-cwr">
<span className={`pk-wrv ${wrCls === 'lead-wr-good' ? 'good' : wrCls === 'lead-wr-bad' ? 'bad' : ''}`}>{wr}</span>
<span className="pk-wrc">{tr('leaders.winRateCap')}</span>
</div>
</div>
<div className="pk-cstats">
<div className="pk-cstat">
<b style={{ color: t.totalPnl != null ? (t.totalPnl >= 0 ? 'var(--g-soft)' : 'var(--r)') : undefined }}>{pnlTxt}</b><span>P&L</span>
</div>
<div className="pk-cstat">
<b style={{ color: t.pnl7d != null ? (t.pnl7d >= 0 ? 'var(--g-soft)' : 'var(--r)') : undefined }}>{momTxt}</b><span>{tr('leaders.mom7d')}</span>
</div>
<div className="pk-cstat">
<b>{formatVolume(t.totalVolume)}</b><span>{tr('leaders.volume')}</span>
</div>
</div>
<div className="pk-cfoot">
{(() => {
const bt = backtests[t.address.toLowerCase()];
if (!bt || bt.copies === 0) return <span className="pk-cpublic">📊 {tr('leaders.publicData')}</span>;
const pfTxt = bt.pf == null ? '∞' : bt.pf.toFixed(2);
const enough = bt.copies >= MIN_GREEN_COPIES;
const profitable = bt.pf == null || bt.pf >= 1.2;
const ok = bt.pf != null && bt.pf >= 1 && bt.pf < 1.2;
const color = profitable && enough ? '#2fb86a' : (profitable || ok) ? '#c9a84a' : '#d8425f';
const lowSample = profitable && !enough;
return (
<span
title={lang === 'ru'
? `Бэктест 30д под наш конфиг ($2 fix, 5–95¢, до резолва): PF ${pfTxt}, ${bt.copies} копий, win ${Math.round(bt.winRate * 100)}%, ${bt.pnl >= 0 ? '+' : '−'}$${Math.abs(bt.pnl).toFixed(2)}, ROI ${Math.round(bt.roi * 100)}%${lowSample ? ` · мало копий (<${MIN_GREEN_COPIES}) — не подсвечиваем зелёным` : ''}`
: `30d backtest under our config ($2 fix, 5–95¢, hold-to-resolution): PF ${pfTxt}, ${bt.copies} copies, win ${Math.round(bt.winRate * 100)}%, ${bt.pnl >= 0 ? '+' : '−'}$${Math.abs(bt.pnl).toFixed(2)}, ROI ${Math.round(bt.roi * 100)}%${lowSample ? ` · small sample (<${MIN_GREEN_COPIES}) — not flagged green` : ''}`}
style={{ color, fontWeight: 700, fontSize: 11, whiteSpace: 'nowrap', fontFamily: 'var(--pk-mono)' }}
>
🎯 PF {pfTxt} · {bt.copies}
</span>
);
})()}
{(() => {
const sp = sparkPoints(t.spark);
return sp ? (
<svg className="pk-cspark" viewBox="0 0 48 22" preserveAspectRatio="none">
<polyline points={sp.pts} fill="none" strokeWidth="1.5"
stroke={sp.up ? 'var(--g-soft)' : 'var(--r)'} />
</svg>
) : <span className="pk-cspark" />;
})()}
<button
className={`pk-ccopy ${boardMode === 'fade' ? 'pk-ccopy-fade' : ''}`}
onClick={(e) => { e.stopPropagation(); navigate('/whale', { state: { address: t.address, mode: boardMode } }); }}
>
{boardMode === 'fade' ? (lang === 'ru' ? 'Фейд' : 'Fade') : tr('leaders.copy')}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
📜 Git History
2abd2dcfix(poli): Activity sort uses recency (lastSeen), matching the card label10 days ago
18f8305fix(poli): FADE board = only money-losers + sane per-metric sort direction10 days ago
a37e054fix(poli): FADE board — working sorts, no switch lag, emoji avatars10 days ago
431e159fix(poli): FADE board shows real losers + avatar fit + win-rate layout10 days ago
11c6f24fix(poli): FADE board ranks confident losers, not low-sample noise10 days ago
b398e2efeat(poli): activate Leaders COPY⇄FADE toggle (Winners/Losers board)10 days ago
a268290feat(poli): editorial Whale Profile + clean chrome shark/skull avatars (chunk 4)11 days ago
a5393b2feat(poli): Leaders title → Winners (copy) / Losers (fade)11 days ago
e2a77c1feat(poli): editorial redesign LIVE on main Leaders screen (chunk 3)11 days ago
61b7393feat(leaders): fold Sharpe-consistency & drawdown into EDGE score (P0.2)11 days ago
Show last diff
Loading...