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