// Tempo — Backtester (bar-replay) : moteur PUR — API v2
// ─────────────────────────────────────────────────────────────────────────
// Hook useBacktest(bars, opts) : aucune dépendance DOM, aucune lib externe.
// Gère la lecture (playhead / play / pause / vitesse), un compte simulé
// (solde, positions, ordres en attente, trades clôturés, P&L réalisé/latent,
// courbe d'équité) et l'exécution auto des fills à chaque avancée de bougie.
//
// opts = { startBalance, contract, resetKey, spread, commission }
//        + v2.1 (amendement ADDITIF au contrat) :
//          barSeconds — durée d'une bougie en secondes ; sert d'horizon de
//          données connues (anti look-ahead) : au switch de TF, le re-mapping
//          ne pointe que sur une bougie entièrement CLOSE (time + barSeconds
//          <= horizon), jamais sur la bougie « en formation » du TF supérieur.
//          tf — libellé du TF courant, estampillé sur positions et trades
//          (champ tf) pour le journal multi-TF.
//
// Conventions v2 (IMPORTANTES) :
//   bars = [{ time, open, high, low, close, volume }] trié croissant (unixSeconds).
//   visibleBars = bars.slice(0, playhead + 1) (la bougie « courante » incluse).
//   currentPrice = close de la bougie playhead.
//   SPREAD     : appliqué à L'ENTRÉE des ordres MARCHÉ uniquement (long entre à
//                close + spread, short à close − spread). Les fills limit/stop/
//                SL/TP s'exécutent au prix EXACT de l'ordre, sans spread.
//   COMMISSION : montant FIXE (devise du compte) déduit du pnl à CHAQUE
//                événement de clôture (totale, partielle, SL, TP).
//                pnl NET de commission ; r calculé sur les prix BRUTS.
//   RESET      : uniquement quand opts.resetKey change. Un changement de bars
//                SANS changement de resetKey (= switch de timeframe) RE-MAPPE
//                le playhead par le TEMPS (recherche binaire), positions /
//                ordres / trades PRÉSERVÉS, AUCUN fill traité pendant le re-mapping.
//   FILLS      : par bougie, 1) les ordres en attente se déclenchent, puis
//                2) SL/TP sur TOUTES les positions (y compris celles ouvertes à
//                cette même bougie). PRIORITÉ AU SL si SL et TP touchés dans la
//                même bougie (choix conservateur).
//   P&L position = (exit - entry) × qty × (long ? 1 : -1) × cs.
//   R-multiple   = (exit - entry) × dir / |entry - sl| (si SL initial, prix bruts).
// ─────────────────────────────────────────────────────────────────────────

function useBacktest(bars, opts) {
  opts = opts || {};
  const startBalance = Number(opts.startBalance) > 0 ? Number(opts.startBalance) : 100000;
  // Taille de contrat (lot) : P&L = (sortie-entrée) × qty × contract.
  const contract = Number(opts.contract) > 0 ? Number(opts.contract) : 1;
  // Spread (unités de prix) et commission (devise) — 0 par défaut.
  const spread = Number(opts.spread) > 0 ? Number(opts.spread) : 0;
  const commission = Number(opts.commission) > 0 ? Number(opts.commission) : 0;
  // v2.1 : durée d'une bougie (secondes, 0 = inconnue → comportement v2) et
  // libellé du TF courant (estampillage des positions / trades).
  const barSeconds = Number(opts.barSeconds) > 0 ? Number(opts.barSeconds) : 0;
  const tfLabel = (typeof opts.tf === 'string' && opts.tf) ? opts.tf : null;

  // Refs « live » sur les opts : les callbacks (identité stable) lisent toujours
  // la valeur du render courant, même si l'actif / les frais changent.
  const contractRef = React.useRef(contract);       contractRef.current = contract;
  const spreadRef = React.useRef(spread);           spreadRef.current = spread;
  const commissionRef = React.useRef(commission);   commissionRef.current = commission;
  const startBalanceRef = React.useRef(startBalance); startBalanceRef.current = startBalance;
  const barSecondsRef = React.useRef(barSeconds);   barSecondsRef.current = barSeconds;
  const tfRef = React.useRef(tfLabel);              tfRef.current = tfLabel;

  const safeBars = Array.isArray(bars) ? bars : [];
  const nBars = safeBars.length;

  // ── État de lecture ────────────────────────────────────────────────────
  const [playhead, setPlayhead] = React.useState(0);
  const [playing, setPlaying] = React.useState(false);
  const [speed, setSpeed] = React.useState(1);

  // ── État du compte ─────────────────────────────────────────────────────
  const [positions, setPositions] = React.useState([]);         // positions ouvertes
  const [pendingOrders, setPendingOrders] = React.useState([]); // ordres limit/stop en attente
  const [closedTrades, setClosedTrades] = React.useState([]);   // trades réalisés
  const [realizedPnl, setRealizedPnl] = React.useState(0);
  const [equityCurve, setEquityCurve] = React.useState([]);     // [{time, equity}] après chaque clôture

  // Compteur d'id local pour les positions / ordres / trades de cette session.
  const idRef = React.useRef(1);
  const nextId = () => 'p' + (idRef.current++);

  // Refs « live » : l'auto-exécution (avancée de bougie) et les snapshots lisent
  // toujours l'état le plus à jour de façon SYNCHRONE, sans recréer les callbacks.
  // positions/pending sont resynchronisées à chaque render ; closed/realized/
  // equity ne sont mutées QUE via recordCloses / resetSessionAt / restoreSnapshot.
  const positionsRef = React.useRef(positions);   positionsRef.current = positions;
  const pendingRef = React.useRef(pendingOrders); pendingRef.current = pendingOrders;
  const closedRef = React.useRef([]);
  const realizedRef = React.useRef(0);
  const equityRef = React.useRef([]);
  const barsRef = React.useRef(safeBars);         barsRef.current = safeBars;
  const playheadRef = React.useRef(playhead);     playheadRef.current = playhead;
  // Temps (unixSeconds) du bar au playhead — pivot du re-mapping entre timeframes.
  // null tant qu'aucune bougie n'a jamais été chargée (tout premier montage).
  const currentTimeRef = React.useRef(null);
  // Horizon de données CONNUES (unixSeconds) = close de la bougie au playhead
  // (time + barSeconds). Indépendant du TF → le re-mapping au switch ne peut
  // jamais exposer une bougie dont le close dépasse cet horizon (anti look-ahead).
  const knownUntilRef = React.useRef(null);
  // Dernier jeu de bougies déjà traité (par le reset OU le re-mapping) : évite
  // que l'effet de re-mapping n'écrase le positionnement fait par le reset.
  const lastBarsRef = React.useRef(null);

  // ── Enregistrement centralisé des clôtures ──────────────────────────────
  // Un lot de trades clôturés → concat des trades, P&L réalisé, et UN point de
  // courbe d'équité par événement de clôture (equity = solde APRÈS la clôture).
  const recordCloses = React.useCallback((closedList) => {
    if (!closedList || !closedList.length) return;
    let run = realizedRef.current;
    const pts = closedList.map(t => {
      run += t.pnl;
      return { time: t.exitTime, equity: startBalanceRef.current + run };
    });
    realizedRef.current = run;
    closedRef.current = closedRef.current.concat(closedList);
    equityRef.current = equityRef.current.concat(pts);
    setClosedTrades(closedRef.current);
    setRealizedPnl(run);
    setEquityCurve(equityRef.current);
  }, []);

  // ── Session neuve : purge du compte + playhead sur `start` ──────────────
  const resetSessionAt = React.useCallback((start) => {
    setPlaying(false);
    positionsRef.current = []; setPositions([]);
    pendingRef.current = [];   setPendingOrders([]);
    closedRef.current = [];    setClosedTrades([]);
    realizedRef.current = 0;   setRealizedPnl(0);
    idRef.current = 1;
    const bar = barsRef.current[start] || null;
    playheadRef.current = start;
    currentTimeRef.current = bar ? bar.time : null;
    knownUntilRef.current = bar ? bar.time + barSecondsRef.current : null;
    // Point initial de la courbe d'équité : {time du bar de départ, startBalance}.
    equityRef.current = bar ? [{ time: bar.time, equity: startBalanceRef.current }] : [];
    setEquityCurve(equityRef.current);
    setPlayhead(start);
  }, []);

  // ── Reset COMPLET : uniquement quand resetKey change (nouvelle session) ──
  // Démarrage sur un point ALÉATOIRE avec de l'historique visible (style FX
  // Replay : « Select random bar ») plutôt que la bougie 0 seule (chart vide).
  React.useEffect(() => {
    lastBarsRef.current = barsRef.current; // ces bars sont traitées ici, pas par le re-mapping
    resetSessionAt(_btRandomStart(barsRef.current.length));
  }, [opts.resetKey, resetSessionAt]);

  // ── Changement de bars SANS resetKey (= switch de timeframe) ─────────────
  // Re-mappe le playhead par le TEMPS (dernier bar dont time <= currentTime),
  // positions / ordres / trades PRÉSERVÉS, AUCUN fill traité. Cas particulier
  // du tout premier chargement (bars vides au reset puis remplies) : on ne
  // touche pas au compte (vide) mais on (re)tire le point de départ aléatoire.
  React.useEffect(() => {
    if (safeBars === lastBarsRef.current) return; // déjà traité par le reset
    lastBarsRef.current = safeBars;
    const n = safeBars.length;
    if (!n) { playheadRef.current = 0; setPlayhead(0); return; } // série vide : on borne, sans perdre currentTime
    if (currentTimeRef.current == null) {
      // Premier remplissage après un reset sur bars vides → positionnement aléatoire.
      resetSessionAt(_btRandomStart(n));
      return;
    }
    // Re-mapping pur : currentTimeRef / knownUntilRef restent inchangés pour ne
    // pas dériver lors de switchs successifs (le bar mappé peut ouvrir avant lui).
    // ANTI LOOK-AHEAD : on ne mappe que sur une bougie entièrement CLOSE par
    // rapport à l'horizon connu (time + durée du TF <= knownUntil) — la bougie
    // « en formation » du TF supérieur (dont l'OHLC s'étendrait au-delà des
    // données déjà vues) n'est jamais affichée ni tradée. Ce bucket écarté sera
    // traité EN ENTIER par la prochaine avancée du playhead : pas de trou de
    // fills après le switch.
    const known = knownUntilRef.current != null ? knownUntilRef.current : currentTimeRef.current;
    const idx = Math.max(0, _btIndexByTime(safeBars, known - barSecondsRef.current));
    playheadRef.current = idx;
    setPlayhead(idx);
  }, [safeBars, resetSessionAt]);

  // ── Avancée du playhead jusqu'à `target` + exécution auto des fills ─────
  // Traite TOUTES les bougies traversées EN UNE FOIS sur des copies LOCALES
  // (via _btProcessFills) puis un seul lot de setState — pas de double-clôture
  // quand on saute plusieurs bougies (scrubber / step rapide / jumpToTime).
  // Les refs sont mises à jour de façon SYNCHRONE pour rester correctes même si
  // les rendus prennent du retard. Un recul ne « dé-clôture » jamais.
  const advancePlayhead = React.useCallback((target) => {
    const nb = barsRef.current.length;
    const prev = playheadRef.current;
    target = Math.max(0, Math.min(nb - 1, Math.round(Number(target) || 0)));
    if (target === prev) return;
    if (target < prev) {
      // Recul visuel uniquement : pas de fills, pas de dé-clôture.
      playheadRef.current = target;
      const b = barsRef.current[target];
      if (b) { currentTimeRef.current = b.time; knownUntilRef.current = b.time + barSecondsRef.current; }
      setPlayhead(target);
      return;
    }
    const res = _btProcessFills(
      positionsRef.current, pendingRef.current, barsRef.current,
      prev, target, { commission: commissionRef.current }
    );
    const nFilled = pendingRef.current.length - res.pending.length;
    playheadRef.current = target;
    const b = barsRef.current[target];
    if (b) { currentTimeRef.current = b.time; knownUntilRef.current = b.time + barSecondsRef.current; }
    if (nFilled > 0) { pendingRef.current = res.pending; setPendingOrders(res.pending); }
    if (res.closed.length || nFilled > 0) { positionsRef.current = res.open; setPositions(res.open); }
    if (res.closed.length) recordCloses(res.closed);
    setPlayhead(target);
  }, [recordCloses]);

  // ── Lecture automatique (UN seul interval stable, piloté par les refs) ──
  // Vitesse jusqu'à ×60 : plancher à 16 ms (~un tick par frame).
  React.useEffect(() => {
    if (!playing) return;
    const ms = Math.max(16, Math.round(1000 / Math.max(1, speed)));
    const t = setInterval(() => {
      const nb = barsRef.current.length;
      const prev = playheadRef.current;
      if (prev >= nb - 1) { setPlaying(false); return; }
      advancePlayhead(prev + 1);
    }, ms);
    return () => clearInterval(t);
  }, [playing, speed, advancePlayhead]);

  // ── Contrôles de lecture ────────────────────────────────────────────────
  const play = React.useCallback(() => {
    if (nBars > 1 && playhead < nBars - 1) setPlaying(true);
  }, [nBars, playhead]);

  const pause = React.useCallback(() => setPlaying(false), []);

  const stepForward = React.useCallback(() => {
    setPlaying(false);
    advancePlayhead(playheadRef.current + 1);
  }, [advancePlayhead]);

  const stepBack = React.useCallback(() => {
    // Recul visuel uniquement : on ne « dé-clôture » pas les trades déjà
    // réalisés (cohérent avec un journal). On borne juste le playhead.
    setPlaying(false);
    advancePlayhead(playheadRef.current - 1);
  }, [advancePlayhead]);

  const jumpTo = React.useCallback((index) => {
    setPlaying(false);
    advancePlayhead(index);
  }, [advancePlayhead]);

  // Saut par TEMPS (unixSeconds) : dernier bar dont time <= t, puis avancée
  // normale (les fills en route sont traités). Ne recule jamais en dé-clôturant.
  const jumpToTime = React.useCallback((unixSeconds) => {
    const t = Number(unixSeconds);
    if (!Number.isFinite(t)) return;
    const idx = _btIndexByTime(barsRef.current, t);
    if (idx < 0) return;
    setPlaying(false);
    advancePlayhead(idx);
  }, [advancePlayhead]);

  const goToStart = React.useCallback(() => {
    resetSessionAt(0);
  }, [resetSessionAt]);

  // Point de départ aléatoire dans l'historique (style FX Replay) : on place
  // le playhead dans la 1re moitié pour laisser du « futur » à rejouer.
  const goToRandom = React.useCallback(() => {
    resetSessionAt(_btRandomStart(barsRef.current.length));
  }, [resetSessionAt]);

  // ── Dérivés (bougies visibles, prix courant, P&L latent) ────────────────
  const currentBar = safeBars[Math.min(playhead, Math.max(0, nBars - 1))] || null;
  const currentPrice = currentBar ? currentBar.close : 0;

  const visibleBars = React.useMemo(
    () => safeBars.slice(0, Math.min(playhead + 1, nBars)),
    [safeBars, playhead, nBars]
  );

  // P&L latent (mark-to-market au prix courant) sur les positions ouvertes.
  const unrealizedPnl = React.useMemo(() => {
    if (!currentBar) return 0;
    let sum = 0;
    for (const pos of positions) {
      sum += _btMarkToMarket(pos, currentPrice);
    }
    return sum;
  }, [positions, currentPrice, currentBar]);

  const balance = startBalance + realizedPnl;
  const equity = balance + unrealizedPnl;

  // ── Ordres ──────────────────────────────────────────────────────────────
  // placeOrder({ side, qty, type?, price?, sl?, tp? })
  //   type ∈ 'market' (défaut) | 'limit' | 'stop'.
  //   Market : exécution immédiate au close ± spread (long +, short −).
  //   Limit/stop : empilé dans pendingOrders, fill futur AU prix de l'ordre.
  //   Retourne {error: 'message'} si invalide, sinon la position / l'ordre créé.
  const placeOrder = React.useCallback((order) => {
    order = order || {};
    const side = order.side === 'short' ? 'short' : 'long';
    const type = (order.type === 'limit' || order.type === 'stop') ? order.type : 'market';
    const isLong = side === 'long';
    const qty = Number(order.qty);
    if (!Number.isFinite(qty) || qty <= 0) return { error: 'Quantité invalide.' };
    const bar = barsRef.current[Math.min(playheadRef.current, barsRef.current.length - 1)];
    if (!bar) return { error: 'Aucune bougie courante.' };
    const sl = _btNum(order.sl);
    const tp = _btNum(order.tp);

    if (type === 'market') {
      // Spread appliqué à l'entrée MARCHÉ uniquement : long paie l'ask, short le bid.
      const entry = isLong ? bar.close + spreadRef.current : bar.close - spreadRef.current;
      const err = _btCheckSlTp(isLong, entry, sl, tp);
      if (err) return { error: err };
      const pos = {
        id: nextId(),
        side, qty, entry, sl, tp,
        sl0: sl,                            // SL INITIAL figé (calcul du R, insensible au BE / trailing)
        cs: contractRef.current,            // taille de contrat figée à l'ouverture
        tf: tfRef.current,                  // TF au moment de l'ouverture (journal multi-TF)
        entryTime: bar.time,
        entryIndex: playheadRef.current,
        entryKind: 'market',                // entrée au CLOSE de sa bougie → pas d'éval SL/TP same-bar
      };
      const next = positionsRef.current.concat(pos);
      positionsRef.current = next;   // ref autoritative (cohérent avec advancePlayhead)
      setPositions(next);
      return pos;
    }

    // Ordre en attente (limit / stop) : validé par rapport à SON prix, ET côté
    // du marché par rapport au close courant — un achat limite se place SOUS le
    // prix, un achat stop AU-DESSUS (miroir en vente). Sinon le déclenchement
    // fillerait à un prix jamais coté (P&L fantôme).
    const price = Number(order.price);
    if (!Number.isFinite(price)) return { error: 'Prix de l’ordre invalide.' };
    if (type === 'limit') {
      if (isLong && price >= bar.close) return { error: 'Un achat limite doit être placé sous le prix courant.' };
      if (!isLong && price <= bar.close) return { error: 'Une vente limite doit être placée au-dessus du prix courant.' };
    } else {
      if (isLong && price <= bar.close) return { error: 'Un achat stop doit être placé au-dessus du prix courant.' };
      if (!isLong && price >= bar.close) return { error: 'Une vente stop doit être placée sous le prix courant.' };
    }
    const err = _btCheckSlTp(isLong, price, sl, tp);
    if (err) return { error: err };
    const po = {
      id: nextId(),
      type, side, qty, price, sl, tp,
      cs: contractRef.current,
      tf: tfRef.current,                    // TF au moment de la pose (propagé au fill)
      createdTime: bar.time,
    };
    const next = pendingRef.current.concat(po);
    pendingRef.current = next;
    setPendingOrders(next);
    return po;
  }, []);

  // Retire un ordre en attente.
  const cancelOrder = React.useCallback((id) => {
    const next = pendingRef.current.filter(o => o.id !== id);
    if (next.length === pendingRef.current.length) return;
    pendingRef.current = next;
    setPendingOrders(next);
  }, []);

  // Modifie SL/TP d'une position ouverte. Validation par rapport au PRIX
  // COURANT (et non à l'entrée) → autorise le trailing du SL au-dessus de
  // l'entrée. Clés absentes = inchangées ; null / '' = suppression du niveau.
  const modifyPosition = React.useCallback((id, patch) => {
    patch = patch || {};
    const pos = positionsRef.current.find(p => p.id === id);
    if (!pos) return { error: 'Position introuvable.' };
    const bar = barsRef.current[Math.min(playheadRef.current, barsRef.current.length - 1)];
    if (!bar) return { error: 'Aucune bougie courante.' };
    const isLong = pos.side === 'long';
    const sl = ('sl' in patch) ? _btNum(patch.sl) : pos.sl;
    const tp = ('tp' in patch) ? _btNum(patch.tp) : pos.tp;
    const err = _btCheckSlTp(isLong, bar.close, sl, tp, true);
    if (err) return { error: err };
    const updated = Object.assign({}, pos, { sl, tp });
    const next = positionsRef.current.map(p => p.id === id ? updated : p);
    positionsRef.current = next;
    setPositions(next);
    return updated;
  }, []);

  // Passe le SL exactement à l'entrée (break-even). Bypass de la validation
  // stricte : c'est VOULU (le SL peut être au-dessus/dessous du prix courant,
  // la clôture éventuelle sera taguée reason:'be' par _btProcessFills).
  const breakEven = React.useCallback((id) => {
    const pos = positionsRef.current.find(p => p.id === id);
    if (!pos) return { error: 'Position introuvable.' };
    const updated = Object.assign({}, pos, { sl: pos.entry });
    const next = positionsRef.current.map(p => p.id === id ? updated : p);
    positionsRef.current = next;
    setPositions(next);
    return updated;
  }, []);

  // Clôture partielle : `qty` (0 < qty < pos.qty) au prix courant. Le trade
  // clôturé est marqué partial:true ; la position restante GARDE le même id
  // avec une qty réduite. Commission PLEINE à chaque partiel (convention).
  const closePartial = React.useCallback((id, qty) => {
    qty = Number(qty);
    const pos = positionsRef.current.find(p => p.id === id);
    if (!pos) return { error: 'Position introuvable.' };
    if (!Number.isFinite(qty) || qty <= 0 || qty >= pos.qty) return { error: 'Quantité partielle invalide.' };
    const bar = barsRef.current[Math.min(playheadRef.current, barsRef.current.length - 1)];
    if (!bar) return { error: 'Aucune bougie courante.' };
    const part = Object.assign({}, pos, { qty });
    const trade = _btClosePos(part, bar.close, bar.time, 'partial', commissionRef.current);
    trade.partial = true;
    const rest = Object.assign({}, pos, { qty: pos.qty - qty });
    const next = positionsRef.current.map(p => p.id === id ? rest : p);
    positionsRef.current = next;
    setPositions(next);
    recordCloses([trade]);
    return rest;
  }, [recordCloses]);

  const closePosition = React.useCallback((id) => {
    const bar = barsRef.current[Math.min(playheadRef.current, barsRef.current.length - 1)];
    if (!bar) return;
    const target = positionsRef.current.find(p => p.id === id);
    if (!target) return;
    const trade = _btClosePos(target, bar.close, bar.time, 'manual', commissionRef.current);
    const next = positionsRef.current.filter(p => p.id !== id);
    positionsRef.current = next;
    setPositions(next);
    recordCloses([trade]);
  }, [recordCloses]);

  const closeAll = React.useCallback(() => {
    const bar = barsRef.current[Math.min(playheadRef.current, barsRef.current.length - 1)];
    if (!bar) return;
    const open = positionsRef.current;
    if (!open.length) return;
    const trades = open.map(p => _btClosePos(p, bar.close, bar.time, 'manual', commissionRef.current));
    positionsRef.current = [];
    setPositions([]);
    recordCloses(trades);
  }, [recordCloses]);

  // ── Snapshot / restauration (persistance de session) ────────────────────
  // currentTime = time du bar au playhead (indépendant du timeframe).
  const getSnapshot = React.useCallback(() => {
    const bar = barsRef.current[Math.min(playheadRef.current, Math.max(0, barsRef.current.length - 1))];
    return {
      v: 1,
      currentTime: bar ? bar.time : currentTimeRef.current,
      positions: positionsRef.current.slice(),
      pendingOrders: pendingRef.current.slice(),
      closedTrades: closedRef.current.slice(),
      realizedPnl: realizedRef.current,
      equityCurve: equityRef.current.slice(),
      idCounter: idRef.current,
    };
  }, []);

  // Restaure un snapshot puis re-mappe currentTime → index sur les bars
  // courantes (le snapshot peut venir d'un autre timeframe). Défensif :
  // snapshot invalide → no-op + console.warn.
  const restoreSnapshot = React.useCallback((snap) => {
    if (!snap || snap.v !== 1 ||
        !Number.isFinite(Number(snap.currentTime)) ||
        !Array.isArray(snap.positions) || !Array.isArray(snap.pendingOrders) ||
        !Array.isArray(snap.closedTrades)) {
      try { console.warn('[Tempo] restoreSnapshot : snapshot invalide, restauration ignorée.', snap); } catch (_) {}
      return;
    }
    setPlaying(false);
    positionsRef.current = snap.positions.slice(); setPositions(positionsRef.current);
    pendingRef.current = snap.pendingOrders.slice(); setPendingOrders(pendingRef.current);
    closedRef.current = snap.closedTrades.slice();   setClosedTrades(closedRef.current);
    realizedRef.current = Number.isFinite(Number(snap.realizedPnl))
      ? Number(snap.realizedPnl)
      : closedRef.current.reduce((s, t) => s + (Number(t.pnl) || 0), 0);
    setRealizedPnl(realizedRef.current);
    equityRef.current = Array.isArray(snap.equityCurve) ? snap.equityCurve.slice() : [];
    setEquityCurve(equityRef.current);
    if (Number.isFinite(Number(snap.idCounter)) && Number(snap.idCounter) >= idRef.current) {
      idRef.current = Number(snap.idCounter);
    }
    currentTimeRef.current = Number(snap.currentTime);
    // Horizon connu = close de la bougie au playhead sauvegardé (le snapshot est
    // restauré sur le TF de la session, posé par la page avant les bougies).
    knownUntilRef.current = currentTimeRef.current + barSecondsRef.current;
    const idx = Math.max(0, _btIndexByTime(barsRef.current, currentTimeRef.current));
    playheadRef.current = idx;
    setPlayhead(idx);
  }, []);

  return {
    // bougies & lecture
    bars: safeBars, nBars, visibleBars, playhead, currentBar, currentPrice,
    playing, speed, setSpeed,
    play, pause, stepForward, stepBack, jumpTo, jumpToTime, goToStart, goToRandom,
    atStart: playhead <= 0, atEnd: playhead >= nBars - 1,
    // compte
    startBalance, balance, equity, realizedPnl, unrealizedPnl,
    positions, pendingOrders, closedTrades, equityCurve,
    // ordres & positions
    placeOrder, cancelOrder, closePosition, closePartial, closeAll,
    modifyPosition, breakEven,
    // persistance
    getSnapshot, restoreSnapshot,
  };
}

// ── Helpers purs ───────────────────────────────────────────────────────────

// Point de départ aléatoire dans 15%–60% de l'historique (style FX Replay) :
// assez d'historique visible derrière, assez de « futur » à rejouer devant.
function _btRandomStart(n) {
  if (!Number.isFinite(n) || n < 4) return 0;
  const lo = Math.floor(n * 0.15);
  const hi = Math.floor(n * 0.6);
  return Math.max(0, Math.min(n - 1, lo + Math.floor(Math.random() * Math.max(1, hi - lo))));
}

// Recherche binaire : dernier index dont time <= t (−1 si aucun / invalide).
function _btIndexByTime(bars, t) {
  if (!Array.isArray(bars) || !bars.length || !Number.isFinite(t)) return -1;
  let lo = 0, hi = bars.length - 1, res = -1;
  while (lo <= hi) {
    const mid = (lo + hi) >> 1;
    if (bars[mid].time <= t) { res = mid; lo = mid + 1; }
    else { hi = mid - 1; }
  }
  return res;
}

// Normalise une valeur numérique optionnelle : null si absente / vide / non finie.
function _btNum(v) {
  if (v == null || v === '') return null;
  const n = Number(v);
  return Number.isFinite(n) ? n : null;
}

// Validation SL/TP par rapport à un prix de référence (entrée effective pour
// un market, prix de l'ordre pour un pending, prix COURANT pour une modif).
// Long : sl < ref < tp ; short miroir. sl/tp optionnels individuellement.
// Renvoie un message d'erreur en français, ou null si valide.
function _btCheckSlTp(isLong, ref, sl, tp, vsCurrent) {
  if (!Number.isFinite(ref)) return 'Prix de référence invalide.';
  const refLabel = vsCurrent ? 'prix courant' : 'prix d’entrée';
  if (sl != null && (isLong ? sl >= ref : sl <= ref)) {
    return 'SL invalide : doit être ' + (isLong ? 'sous le ' : 'au-dessus du ') + refLabel + '.';
  }
  if (tp != null && (isLong ? tp <= ref : tp >= ref)) {
    return 'TP invalide : doit être ' + (isLong ? 'au-dessus du ' : 'sous le ') + refLabel + '.';
  }
  return null;
}

// Traite les bougies (fromIdx+1 .. toIdx) sur des COPIES LOCALES des positions
// et des ordres en attente. cfg = { commission }.
// Ordre PAR BOUGIE : 1) les pendings se déclenchent (limit long : low <= price ;
// stop long : high >= price ; short miroir ; fill AU prix de l'ordre, SANS
// spread) et deviennent des positions ; 2) SL/TP évalués sur TOUTES les
// positions, y compris celles ouvertes à cette même bougie. PRIORITÉ AU SL si
// SL et TP touchés dans la même bougie (conservateur). Une position clôturée
// quitte `open` → pas de double comptage du P&L en sautant plusieurs bougies.
// Un SL exactement à l'entrée est tagué reason:'be' (break-even).
// Renvoie { open, pending, closed }.
function _btProcessFills(openIn, pendingIn, bars, fromIdx, toIdx, cfg) {
  let open = (openIn || []).slice();
  let pending = (pendingIn || []).slice();
  const closed = [];
  const commission = cfg && Number.isFinite(cfg.commission) && cfg.commission > 0 ? cfg.commission : 0;
  for (let idx = fromIdx + 1; idx <= toIdx; idx++) {
    if (!open.length && !pending.length) break;
    const bar = bars[idx];
    if (!bar) continue;
    // 1) Déclenchement des ordres en attente → positions (fill au prix de l'ordre).
    if (pending.length) {
      const stillPending = [];
      for (const o of pending) {
        // GARDE TEMPORELLE : jamais de déclenchement sur une bougie antérieure
        // ou égale à la pose de l'ordre (bougies re-traversées après un recul
        // du scrubber). Garde par le TEMPS → robuste aux switchs de TF.
        if (Number.isFinite(o.createdTime) && bar.time <= o.createdTime) { stillPending.push(o); continue; }
        const isLong = o.side === 'long';
        const hit = o.type === 'limit'
          ? (isLong ? bar.low <= o.price : bar.high >= o.price)
          : (isLong ? bar.high >= o.price : bar.low <= o.price);
        if (hit) {
          open.push({
            id: o.id, side: o.side, qty: o.qty,
            // Défense : le prix de fill est borné au range réellement coté.
            entry: Math.min(bar.high, Math.max(bar.low, o.price)),
            sl: o.sl, tp: o.tp,
            sl0: o.sl,               // SL INITIAL figé (calcul du R)
            cs: o.cs, tf: o.tf || null,
            entryTime: bar.time, entryIndex: idx,
            entryKind: 'pending',    // fill intra-bougie → éval SL/TP same-bar conservée
          });
        } else {
          stillPending.push(o);
        }
      }
      pending = stillPending;
    }
    // 2) SL/TP — y compris sur les positions ouvertes à cette même bougie.
    if (open.length) {
      const stillOpen = [];
      for (const pos of open) {
        // GARDE TEMPORELLE : pas d'éval SL/TP sur une bougie antérieure à
        // l'entrée (re-traversée après un recul). Pour une entrée MARCHÉ (au
        // close de sa bougie), la bougie d'entrée elle-même est aussi exclue ;
        // une position issue d'un pending fillé reste évaluée sur sa bougie de
        // fill (choix conservateur documenté).
        if (Number.isFinite(pos.entryTime) &&
            (bar.time < pos.entryTime || (bar.time === pos.entryTime && pos.entryKind !== 'pending'))) {
          stillOpen.push(pos);
          continue;
        }
        const isLong = pos.side === 'long';
        const hitSL = pos.sl != null && (isLong ? bar.low <= pos.sl : bar.high >= pos.sl);
        const hitTP = pos.tp != null && (isLong ? bar.high >= pos.tp : bar.low <= pos.tp);
        if (hitSL)      closed.push(_btClosePos(pos, pos.sl, bar.time, pos.sl === pos.entry ? 'be' : 'sl', commission));
        else if (hitTP) closed.push(_btClosePos(pos, pos.tp, bar.time, 'tp', commission));
        else            stillOpen.push(pos);
      }
      open = stillOpen;
    }
  }
  return { open, pending, closed };
}

// P&L latent d'une position au prix donné (brut, sans commission).
function _btMarkToMarket(pos, price) {
  if (!pos || !Number.isFinite(price)) return 0;
  const dir = pos.side === 'long' ? 1 : -1;
  const cs = pos.cs > 0 ? pos.cs : 1;
  const v = (price - pos.entry) * pos.qty * dir * cs;
  return Number.isFinite(v) ? v : 0;
}

// Construit l'objet trade clôturé. Conventions :
//   pnl NET de commission ; commission = montant déduit sur CET événement ;
//   r calculé sur les prix BRUTS (avant commission), null sans SL initial.
//   reason ∈ 'sl' | 'tp' | 'manual' | 'partial' | 'be'.
function _btClosePos(pos, exit, exitTime, reason, commission) {
  const dir = pos.side === 'long' ? 1 : -1;
  const cs = pos.cs > 0 ? pos.cs : 1;
  const fee = Number.isFinite(commission) && commission > 0 ? commission : 0;
  const gross = (exit - pos.entry) * pos.qty * dir * cs;
  const pnl = (Number.isFinite(gross) ? gross : 0) - fee;
  let r = null;
  // R-multiple sur le SL INITIAL (sl0, figé à l'ouverture) : un break-even ou
  // un trailing ne faussent pas le R. Fallback sur le SL courant pour les
  // positions issues d'un snapshot v1 (sans sl0).
  const slInit = ('sl0' in pos) ? pos.sl0 : pos.sl;
  if (slInit != null) {
    const risk = Math.abs(pos.entry - slInit);
    if (risk > 0) r = ((exit - pos.entry) * dir) / risk;
  }
  return {
    id: pos.id,
    side: pos.side,
    qty: pos.qty,
    entry: pos.entry,
    exit,
    sl: pos.sl,
    tp: pos.tp,
    tf: pos.tf || null,
    entryTime: pos.entryTime,
    exitTime: exitTime,
    pnl: Number.isFinite(pnl) ? pnl : 0,
    commission: fee,
    r: r != null && Number.isFinite(r) ? r : null,
    reason: reason || 'manual',
  };
}

// ── Statistiques de session (helper PUR, utilisable hors du hook) ──────────
// btSessionStats(closedTrades, startBalance) → agrégats du récap de session.
// Conventions : winrate ∈ [0,1] ; avgLoss NÉGATIF (moyenne des pnl perdants) ;
// grossLoss POSITIF (somme des pertes en valeur absolue) ; expectancyR =
// winrate × avgR(gagnants) + (1 − winrate) × avgR(perdants), sur les trades
// ayant un R ; maxDrawdown calculé sur la courbe cumulée des pnl des trades
// clôturés en partant de startBalance. Champs null quand incalculables.
function btSessionStats(closedTrades, startBalance) {
  const trades = (Array.isArray(closedTrades) ? closedTrades : [])
    .filter(t => t && Number.isFinite(Number(t.pnl)));
  const base = Number(startBalance) > 0 ? Number(startBalance) : 0;
  const n = trades.length;
  if (!n) {
    return {
      n: 0, wins: 0, losses: 0, winrate: null, netPnl: 0,
      grossWin: 0, grossLoss: 0, profitFactor: null,
      avgR: null, expectancyR: null, avgWin: null, avgLoss: null,
      best: null, worst: null, maxDrawdown: null, maxDrawdownPct: null,
    };
  }
  let wins = 0, losses = 0, netPnl = 0, grossWin = 0, grossLoss = 0;
  let best = -Infinity, worst = Infinity;
  let eq = base, peak = base, maxDD = 0, maxDDPct = 0;
  const rAll = [], rWins = [], rLoss = [];
  for (const t of trades) {
    const pnl = Number(t.pnl);
    netPnl += pnl;
    if (pnl > 0) { wins++; grossWin += pnl; }
    else if (pnl < 0) { losses++; grossLoss += -pnl; }
    if (pnl > best) best = pnl;
    if (pnl < worst) worst = pnl;
    const r = _btNum(t.r);
    if (r != null) {
      rAll.push(r);
      if (r > 0) rWins.push(r);
      else if (r < 0) rLoss.push(r);
    }
    // Drawdown sur la courbe cumulée trade par trade.
    eq += pnl;
    if (eq > peak) peak = eq;
    const dd = peak - eq;
    if (dd > maxDD) {
      maxDD = dd;
      maxDDPct = peak > 0 ? (dd / peak) * 100 : 0;
    }
  }
  const mean = (arr) => arr.reduce((s, v) => s + v, 0) / arr.length;
  const winrate = wins / n;
  const avgR = rAll.length ? mean(rAll) : null;
  let expectancyR = null;
  if (rAll.length) {
    expectancyR = winrate * (rWins.length ? mean(rWins) : 0)
                + (1 - winrate) * (rLoss.length ? mean(rLoss) : 0);
  }
  return {
    n, wins, losses, winrate, netPnl,
    grossWin, grossLoss,
    profitFactor: grossLoss > 0 ? grossWin / grossLoss : null,
    avgR, expectancyR,
    avgWin: wins ? grossWin / wins : null,
    avgLoss: losses ? -(grossLoss / losses) : null,
    best, worst,
    maxDrawdown: maxDD,
    maxDrawdownPct: maxDDPct,
  };
}

Object.assign(window, { useBacktest, btSessionStats });
