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 { addrHue, whaleStyle, categoryGlyph, whaleCodename, whaleCodenamesUnique, whaleHorizon } from '../utils/whales';
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' },
];
// 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%.
function edgeScore(t: Trader): number | null {
const clamp = (x: number) => Math.max(0, Math.min(1, x));
const parts: [number, number][] = [];
const wr = t.winRatePos ?? t.winRate;
if (wr != null) parts.push([clamp((wr - 0.4) / 0.5), 0.30]);
if (t.backtestPf != null) parts.push([clamp(t.backtestPf / 2), 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), 0.15]);
if (t.activeDays != null) parts.push([clamp(t.activeDays / 30), 0.10]);
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');
// 'edge' is a client-side composite re-rank; fetch the pool by a real server sort.
const serverSort: Sort = sort === 'edge' ? 'quality' : sort;
// 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;
});
const ranked = sort === 'edge'
? [...filtered].sort((a, b) => (edgeScore(b) ?? -1) - (edgeScore(a) ?? -1))
: filtered;
const visible = ranked.slice(0, 100);
const codenames = whaleCodenamesUnique(visible.map(t => t.address), lang);
return (
<div className="lead-page">
<header className="lead-header">
<h1 className="lead-title">{tr('leaders.title')}</h1>
<p className="lead-sub">{tr('leaders.sub')}</p>
</header>
<div className="lead-sortbar">
{SORTS.map(s => (
<button
key={s.key}
className={`lead-sort ${sort === s.key ? 'active' : ''}`}
onClick={() => setSort(s.key)}
>
{tr(s.labelKey)}
</button>
))}
</div>
<div className="lead-filterbar">
<button
className={`lead-filter-toggle ${activeFilters > 0 ? 'active' : ''}`}
onClick={() => setShowFilters(v => !v)}
>
{tr('leaders.filters')}{activeFilters > 0 ? ` · ${activeFilters}` : ''} {showFilters ? '▴' : '▾'}
</button>
<span className="lead-found">{tr('leaders.found', { n: filtered.length })}</span>
{activeFilters > 0 && (
<button className="lead-filter-reset" 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 className="wf-skeleton">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="wf-skeleton-row" />
))}
</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="lead-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 hue = addrHue(t.address);
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="lead-card"
onClick={() => navigate('/whale', { state: { address: t.address } })}
>
<div className="lead-row-head">
<span className="lead-rank">#{i + 1}</span>
<div className="lead-avatar" style={{ position: 'relative', background: style ? style.color : `hsl(${hue} 45% 34%)`, fontSize: 18, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{categoryGlyph(t.topCategory)}
</div>
<div className="lead-id">
<span className="lead-name">
{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="lead-sub2">
{edge != null && <span className="lead-edge" title={tr('leaders.edgeTip')}>EDGE {edge.toFixed(1)}</span>}
{t.topCategory && <span className="lead-cat">{t.topCategory}</span>}
<span className="lead-online"><i className="lead-dot" />{tr('leaders.active')} {timeAgo(t.lastSeen)}</span>
</span>
</div>
<div className="lead-wrwrap">
<span className={`lead-wr ${wrCls}`}>{wr}</span>
<span className="lead-wr-cap">{tr('leaders.winRateCap')}</span>
</div>
</div>
<div className="lead-stats">
<div className="lead-stat">
<b style={{ color: t.totalPnl != null ? (t.totalPnl >= 0 ? 'var(--profit)' : 'var(--loss)') : undefined }}>{pnlTxt}</b><span>P&L</span>
</div>
<div className="lead-stat">
<b style={{ color: t.pnl7d != null ? (t.pnl7d >= 0 ? 'var(--profit)' : 'var(--loss)') : undefined }}>{momTxt}</b><span>{tr('leaders.mom7d')}</span>
</div>
<div className="lead-stat">
<b>{formatVolume(t.totalVolume)}</b><span>{tr('leaders.volume')}</span>
</div>
</div>
<div className="lead-foot">
<div className="lead-foot-info">
{(() => {
const bt = backtests[t.address.toLowerCase()];
if (!bt || bt.copies === 0) return <span className="lead-active">{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;
// Green requires both a profitable PF AND a trustworthy sample;
// profitable-but-thin or borderline PF → yellow; losing → red.
const color = profitable && enough ? '#16a34a' : (profitable || ok) ? '#eab308' : '#ef4444';
const lowSample = profitable && !enough;
return (
<span
className="lead-bt"
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' }}
>
🎯 PF {pfTxt} · {bt.copies}
</span>
);
})()}
</div>
{(() => {
const sp = sparkPoints(t.spark);
return sp ? (
<svg className="lead-spark" viewBox="0 0 48 22" preserveAspectRatio="none">
<polyline points={sp.pts} fill="none" strokeWidth="1.5"
stroke={sp.up ? 'var(--profit)' : 'var(--loss)'} />
</svg>
) : null;
})()}
<button
className="lead-copy"
onClick={(e) => { e.stopPropagation(); navigate('/whale', { state: { address: t.address } }); }}
>
{tr('leaders.copy')}
</button>
</div>
</div>
);
})}
</div>
)}
</div>
);
}
📜 Git History
03a2d80chore(save): session 2026-06-22 — Phase2 redesign + 3mo history + realized-PnL fix; staged _impl edits11 days ago
Show last diff
Loading...