← Back
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...