// Tempo — Backtest (bar-replay) : PAGE
// ─────────────────────────────────────────────────────────────────────────
// Expose BacktestPage({ state }) (desktop) et BacktestMobile({ state }) (mobile).
// Style FX Replay / TradingView Replay : sélecteur d'actif groupé, boutons
// d'unité de temps, barre de replay (début / lecture-pause / vitesses / saut à
// une date / +1 bougie / curseur), graphique central, panneau d'ordre
// (marché / limite / stop, quantité par le risque, réglages écart et
// commission), P&L, positions ouvertes (break-even / partiel / édition SL-TP),
// ordres en attente, résultats + courbe de P&L cumulé, sauvegardes
// reprenables, et des TRADES DE BACKTEST 100 % séparés du journal réel (avec
// export explicite, à la demande, vers ce dernier).
//
// Charte (docs/charte-ui.md §2) : le compte de backtest est en USD, la devise
// de cotation des actifs (P&L = écart de prix × quantité × taille de contrat,
// voir backtest-data.jsx). Tous les montants passent par btMoney →
// fmtMoneyCur(v, 'USD'), sans conversion vers la devise du profil, et l'écran
// le dit (« Compte de backtest en USD »). Dates et heures dans le fuseau du
// profil (localDayInTz, tzTimeIn, zonedToUtcISO).
//
// Dépendances (chargées avant ce fichier) :
//   supabase.jsx        → todayISO, sbErrorMessage
//   format-prefs.jsx    → fmtMoneyCur, currencySymbol, fmtNum, fmtPct, fmtR, fmtPF,
//                         fmtPrice, fmtLots, parseDecimalFr, INVALID_NUM, fmtDayShort,
//                         fmtDateTime, fmtWeekdayShort, localDayInTz, tzTimeIn,
//                         zonedToUtcISO, tzHourLabel, FR_MONTHS_SHORT
//   core.jsx            → Ico, PageHeader, useTheme, NAV_LABELS
//   backtest-data.jsx   → BT_ASSETS, BT_CATEGORIES, BT_TIMEFRAMES, loadBtBars, btAsset,
//                         btBarsRange, btDailyBarDay…
//   backtest-engine.jsx → useBacktest, btSessionStats
//   backtest-amchart.jsx→ BacktestAMChart
//   trades-api.jsx      → bulkInsertTrades (export vers le journal réel)
// ─────────────────────────────────────────────────────────────────────────

// ── Helpers de formatage locaux (délèguent à format-prefs.jsx) ──────────────
// Devise du compte de backtest : celle de cotation des actifs, jamais celle du
// profil (aucune conversion n'est faite).
const BT_CURRENCY = 'USD';
const BT_NBSP = '\u00A0'; // espace insécable (avant « : », dans « », entre jour et mois)
function btMoney(v, signed = false) {
  return fmtMoneyCur(Number(v) || 0, BT_CURRENCY, signed);
}
// Couleur d'un montant ou d'un R signé : vert, rouge, neutre (--fg) pour zéro.
// La valeur est d'abord arrondie comme à l'affichage (2 décimales pour l'USD
// et pour le R) : « 0,00 R » ou « 0,00 $ » reste neutre, jamais vert ou rouge.
function btToneColor(v, dec = 2) {
  const f = Math.pow(10, dec);
  const n = Math.round((Number(v) || 0) * f) / f;
  return n > 0 ? 'var(--green)' : n < 0 ? 'var(--red)' : 'var(--fg)';
}
// Jour civil d'une bougie. En intraday, c'est le jour vécu dans le fuseau du
// profil. Une bougie journalière est une date civile : sa date est donnée par
// btDailyBarDay (backtest-data.jsx), règle unique partagée avec l'axe du
// graphique (backtest-amchart.jsx, _amBarMs).
function btBarDay(unix, tf) {
  if (tf === '1d') return btDailyBarDay(unix);
  return localDayInTz(unix * 1000);
}
// Libellé de la bougie courante, année toujours présente (l'historique couvre
// plusieurs années) : « Mer. 13 mai 2026 · 14:00 », « Lun. 22 juin 2026 ».
function btBarLabel(unix, tf) {
  if (!unix) return '—';
  const day = btBarDay(unix, tf);
  const [y, m, d] = day.split('-').map(Number);
  return btCapitalize(fmtWeekdayShort(day)) + ' ' + d + BT_NBSP + FR_MONTHS_SHORT[m - 1] + ' ' + y
    + (tf !== '1d' ? BT_NBSP + '· ' + tzTimeIn(unix * 1000) : '');
}
// Date d'un trade (colonne Date) : « 13 mai 2025 · 14:00 » en intraday, le jour
// seul pour une bougie journalière.
function btTradeTimeLabel(unix, tf) {
  if (!unix) return '—';
  return tf === '1d' ? fmtDayShort(btBarDay(unix, tf)) : fmtDateTime(unix * 1000);
}
// Libellés lisibles d'un actif et d'une unité de temps (« EUR/USD », « 1 h »).
function btAssetLabel(assetId) {
  const a = btAsset(assetId);
  return (a && a.label) || assetId || '—';
}
function btTfLabel(tf) {
  const t = btTimeframe(tf);
  return (t && t.label) || tf || '—';
}
// Compte avec pluriel correct : « 0 trade », « 1 trade », « 2 trades ».
function btCount(n, singular, plural) {
  const v = Number(n) || 0;
  return fmtNum(v, 0) + ' ' + (v > 1 ? plural : singular);
}
function btCapitalize(s) { return s ? s.charAt(0).toUpperCase() + s.slice(1) : s; }
// Saisie numérique : virgule ou point (parseDecimalFr). null si vide, NaN si
// illisible, sinon le nombre. Un séparateur décimal en fin de saisie est
// ignoré : « 1, » vaut 1 pendant la frappe (aucun message « illisible » avant
// les décimales) comme à la validation, et « , » seul vaut vide.
function btParseNum(raw) {
  const v = parseDecimalFr(typeof raw === 'string' ? raw.replace(/[.,]\s*$/, '') : raw);
  return v === INVALID_NUM ? NaN : v;
}
// Valeur numérique → texte d'un champ de saisie, avec la virgule décimale.
function btInputNum(v) {
  const n = Number(v);
  if (v == null || v === '' || !Number.isFinite(n)) return '';
  return String(n).replace('.', ',');
}
// Erreur affichable : nos messages français passent tels quels, toute autre
// erreur passe par sbErrorMessage (le brut part dans console.warn).
function btErr(message) { return { message, fr: true }; }
function btErrText(err, fallback) {
  if (!err) return fallback;
  if (err.fr) return err.message;
  try { console.warn('[Tempo] backtest :', err); } catch (e) { /* console indisponible */ }
  return (typeof sbErrorMessage === 'function' ? sbErrorMessage(err) : '') || fallback;
}
const BT_NET_ERROR = 'Problème réseau — vérifie ta connexion.';
// Arrondi à 2 décimales (quantités, moitiés de position…).
function btRound2(v) { return Math.round(Number(v) * 100) / 100; }
// Arrondi à d décimales pour les nombres ENREGISTRÉS (pas de bruit de calcul
// flottant en base : 0,009999999999999998, −253,99999999999866). null si la
// valeur est absente ou illisible ; jamais « −0 ».
function btRoundTo(v, d) {
  if (v == null || v === '') return null;
  const f = Math.pow(10, d);
  const n = Math.round(Number(v) * f) / f;
  return Number.isFinite(n) ? n + 0 : null;
}
// Nom de sauvegarde par défaut : « EUR/USD 1 h — 27 sept. ».
function btDefaultSessionName(assetId, tf) {
  return btAssetLabel(assetId) + ' ' + btTfLabel(tf) + ' — ' + fmtDayShort(todayISO());
}
// Signature du travail en cours (trades clôturés, positions, ordres, P&L
// réalisé) : sert à savoir s'il reste du travail non sauvegardé. Accepte l'état
// du moteur comme un instantané sauvegardé (même forme).
function btWorkSig(w) {
  if (!w) return '';
  const closed = Array.isArray(w.closedTrades) ? w.closedTrades : [];
  const pos = Array.isArray(w.positions) ? w.positions : [];
  const pend = Array.isArray(w.pendingOrders) ? w.pendingOrders : [];
  return closed.length + '|' + (Number(w.realizedPnl) || 0) + '|'
    + pos.filter(Boolean).map(p => p.id + ':' + p.qty + ':' + p.sl + ':' + p.tp).join(',') + '|'
    + pend.filter(Boolean).map(o => o.id).join(',');
}
// Un instantané (brouillon local ou sauvegarde en base) n'est repris que s'il
// est lisible de bout en bout : forme attendue ET chaque position, ordre et
// trade exploitable. Sinon la page planterait au rendu (position nulle) ou
// afficherait des montants illisibles.
function btValidState(st) {
  const obj = (o) => !!o && typeof o === 'object' && !Array.isArray(o);
  const num = (v) => v != null && v !== '' && typeof v !== 'boolean' && Number.isFinite(Number(v));
  const side = (s) => s === 'long' || s === 'short';
  const lvl = (v) => v == null || num(v); // SL / TP : absent ou nombre
  return obj(st) && st.v === 1 && num(st.currentTime)
    && Array.isArray(st.positions) && Array.isArray(st.pendingOrders) && Array.isArray(st.closedTrades)
    && st.positions.every(p => obj(p) && p.id != null && side(p.side) && num(p.qty) && Number(p.qty) > 0 && num(p.entry) && lvl(p.sl) && lvl(p.tp))
    && st.pendingOrders.every(o => obj(o) && o.id != null && side(o.side) && (o.type === 'limit' || o.type === 'stop') && num(o.qty) && Number(o.qty) > 0 && num(o.price) && lvl(o.sl) && lvl(o.tp))
    && st.closedTrades.every(t => obj(t) && side(t.side) && num(t.pnl) && num(t.entry) && num(t.exit));
}
function btHasWork(w) {
  return !!w && ((w.closedTrades || []).length > 0 || (w.positions || []).length > 0 || (w.pendingOrders || []).length > 0);
}
// Phrase de perte pour la confirmation de remise à zéro, accordée :
// « Le trade non enregistré sera perdu. », « Les 3 trades et la position
// ouverte non enregistrés seront perdus. »
function btLossSentence(nTrades, nPos, nOrders) {
  const items = [];
  if (nTrades) items.push({ txt: nTrades === 1 ? 'le trade clôturé' : 'les ' + fmtNum(nTrades, 0) + ' trades clôturés', pl: nTrades > 1, fem: false });
  if (nPos) items.push({ txt: nPos === 1 ? 'la position ouverte' : 'les ' + fmtNum(nPos, 0) + ' positions ouvertes', pl: nPos > 1, fem: true });
  if (nOrders) items.push({ txt: nOrders === 1 ? 'l’ordre en attente' : 'les ' + fmtNum(nOrders, 0) + ' ordres en attente', pl: nOrders > 1, fem: false });
  if (!items.length) return '';
  const pl = items.length > 1 || items[0].pl;
  const fem = items.every(i => i.fem);
  const agree = (fem ? 'e' : '') + (pl ? 's' : '');
  const list = items.length === 1 ? items[0].txt
    : items.slice(0, -1).map(i => i.txt).join(', ') + ' et ' + items[items.length - 1].txt;
  return btCapitalize(list) + ' non enregistré' + agree
    + (pl ? ' seront perdu' : ' sera perdu') + agree + '.';
}

// ── Fermeture d'un popover au clic extérieur (réutilisé partout) ────────────
function useBtClickOutside(ref, active, onClose) {
  React.useEffect(() => {
    if (!active) return;
    const h = (e) => { if (ref.current && !ref.current.contains(e.target)) onClose(); };
    document.addEventListener('mousedown', h);
    return () => document.removeEventListener('mousedown', h);
  }, [active]);
}

// ── Clavier d'un popover (actif, « Aller à une date », réglages d'exécution) ──
// Échap le referme et rend le focus à son bouton (le champ focalisé est
// détruit : sans cela le focus retombe sur <body>). Tab hors du panneau le
// referme aussi, pour qu'il ne recouvre pas les champs atteints ensuite.
// Renvoie closeToButton() : à appeler quand un choix fait au clavier ou à la
// souris referme le panneau.
function useBtPopoverKeys(open, setOpen, boxRef, btnRef) {
  const closeToButton = React.useCallback(() => {
    setOpen(false);
    const b = btnRef.current;
    if (b && typeof b.focus === 'function') b.focus();
  }, [setOpen, btnRef]);
  React.useEffect(() => {
    if (!open) return;
    const onKey = (e) => { if (e.key === 'Escape') closeToButton(); };
    // Le focus part vers un élément extérieur (Tab, Maj+Tab) : fermeture, sans
    // reprendre le focus. relatedTarget nul (clic sur une zone non focalisable,
    // sélecteur de date natif, changement d'onglet) : on ne ferme pas.
    const onOut = (e) => {
      const box = boxRef.current, to = e.relatedTarget;
      if (box && to && box.contains(e.target) && !box.contains(to)) setOpen(false);
    };
    document.addEventListener('keydown', onKey);
    document.addEventListener('focusout', onOut);
    return () => { document.removeEventListener('keydown', onKey); document.removeEventListener('focusout', onOut); };
  }, [open, closeToButton, setOpen, boxRef]);
  return closeToButton;
}

// ── Segmented control générique (type d'ordre, mode de sizing…) ─────────────
function BtSegmented({ options, value, onChange, ariaLabel }) {
  // Style unique de l'application : SegmentedTabs (core.jsx).
  return <SegmentedTabs options={options} value={value} onChange={onChange} kind="choice" size="xs" stretch ariaLabel={ariaLabel} />;
}

// ═════════════════════════════════════════════════════════════════════════
//  RÉGLAGES D'EXÉCUTION (spread / commission) — persistés par actif
// ═════════════════════════════════════════════════════════════════════════
// Défauts : spread = champ `spread` de l'actif (catalogue) s'il existe, sinon 0 ;
// commission = 0. Persistés dans localStorage 'tempo:bt:execCfg:{assetId}'.
function btLoadExecCfg(assetId) {
  const a = btAsset(assetId);
  const def = { spread: (a && Number.isFinite(Number(a.spread)) && Number(a.spread) > 0) ? Number(a.spread) : 0, commission: 0 };
  try {
    const raw = localStorage.getItem('tempo:bt:execCfg:' + assetId);
    if (!raw) return def;
    const p = JSON.parse(raw);
    return {
      spread: Number.isFinite(Number(p.spread)) && Number(p.spread) >= 0 ? Number(p.spread) : def.spread,
      commission: Number.isFinite(Number(p.commission)) && Number(p.commission) >= 0 ? Number(p.commission) : 0,
    };
  } catch (e) { return def; }
}
function btStoreExecCfg(assetId, cfg) {
  try { localStorage.setItem('tempo:bt:execCfg:' + assetId, JSON.stringify(cfg)); } catch (e) { /* stockage plein / privé : non bloquant */ }
}

// ── Brouillon local du travail en cours ─────────────────────────────────────
// Le replay (positions, ordres, trades clôturés, bougie courante) vit dans des
// hooks démontés dès qu'on quitte la page par le menu, qu'on recharge ou que
// la mise en page bascule entre ordinateur et mobile. Un brouillon est donc
// conservé dans le navigateur, par compte : 'tempo:bt:draft:{id utilisateur}'
// (le préfixe 'tempo:' est purgé à la déconnexion, voir app.jsx). Contenu :
// { v: 1, asset, tf, state (instantané du moteur), sessionId, sessionName,
//   savedSig }. Il est repris au retour sur la page, là où on s'était arrêté.
const BT_DRAFT_PREFIX = 'tempo:bt:draft:';
function btReadDraft(userId) {
  if (!userId) return null;
  const key = BT_DRAFT_PREFIX + userId;
  try {
    const raw = localStorage.getItem(key);
    if (!raw) return null;
    const d = JSON.parse(raw);
    const st = d && d.state;
    const ok = !!d && d.v === 1 && !!btAsset(d.asset) && !!btTimeframe(d.tf)
      && btValidState(st) && btHasWork(st);
    if (!ok) { localStorage.removeItem(key); return null; }
    return d;
  } catch (e) {
    // Stockage indisponible ou contenu illisible : pas de brouillon, et un
    // contenu illisible est écarté pour ne pas être relu à chaque ouverture.
    try { localStorage.removeItem(key); } catch (_) {}
    return null;
  }
}
function btWriteDraft(userId, draft) {
  if (!userId) return;
  try { localStorage.setItem(BT_DRAFT_PREFIX + userId, JSON.stringify(draft)); } catch (e) { /* stockage plein / privé : non bloquant */ }
}
function btClearDraft(userId) {
  if (!userId) return;
  try { localStorage.removeItem(BT_DRAFT_PREFIX + userId); } catch (e) { /* stockage indisponible */ }
}

// Durée d'une bougie de l'unité de temps, en secondes (0 si inconnue).
function btTfSeconds(tf) {
  return ((btTimeframe(tf) || {}).minutes || 0) * 60;
}
// Messages affichés quand l'historique d'une unité de temps ne couvre pas la
// date du replay (les fichiers ne remontent pas tous aussi loin : le 1 j
// commence en 2021, le 1 h en 2023, les unités plus courtes en 2026).
function btCoverageRefusedText(tf, bars, atUnix, atTf) {
  const r = btBarsRange(bars);
  return 'L’historique ' + btTfLabel(tf) + ' ne couvre que la période du ' + fmtDayShort(btBarDay(r.first, tf))
    + ' au ' + fmtDayShort(btBarDay(r.last, tf)) + '. Ton replay est au ' + fmtDayShort(btBarDay(atUnix, atTf))
    + BT_NBSP + ': reste sur une unité de temps qui couvre cette date, ou fais un nouveau départ.';
}
// Le replay est DANS cet historique, mais sur ses toutes premières bougies :
// la première bougie de l'unité demandée n'est pas encore terminée.
function btCoverageEarlyText(tf, bars) {
  return 'La première bougie ' + btTfLabel(tf) + ' de l’historique (' + fmtDayShort(btBarDay(bars[0].time, tf))
    + ') n’est pas encore terminée à la date de ton replay' + BT_NBSP
    + ': avance de quelques bougies, puis repasse en ' + btTfLabel(tf) + '.';
}
function btCoverageRestartText(tf, bars) {
  const r = btBarsRange(bars);
  return 'L’historique ' + btTfLabel(tf) + ' commence le ' + fmtDayShort(btBarDay(r.first, tf))
    + BT_NBSP + ': ton replay repart d’un point de cette période.';
}

// ═════════════════════════════════════════════════════════════════════════
//  ÉTAT PARTAGÉ DE LA PAGE (chargement bougies + sélection actif/TF + session)
// ═════════════════════════════════════════════════════════════════════════
// userId : identifiant du compte connecté (clé du brouillon local).
function useBacktestPage(userId) {
  // Brouillon lu une seule fois, au montage : il fixe l'actif, l'unité de temps
  // et la sauvegarde liée, puis l'instantané est restauré une fois les bougies
  // chargées (pendingRestoreRef, plus bas).
  const draftRef = React.useRef(undefined);
  if (draftRef.current === undefined) draftRef.current = btReadDraft(userId);
  const draft0 = draftRef.current;

  const [assetId, setAssetIdRaw] = React.useState(draft0 ? draft0.asset : 'EURUSD');
  const [tf, setTfRaw] = React.useState(draft0 ? draft0.tf : '1h');
  const [bars, setBars] = React.useState([]);
  const [loading, setLoading] = React.useState(true);
  const [loadError, setLoadError] = React.useState(false);
  // Incrémenté par « Réessayer » après un échec de chargement des bougies
  // (loadBtBars libère son cache en cas d'échec, y compris celui d'une unité
  // dérivée dont l'unité de base a échoué : la nouvelle tentative refait la
  // requête).
  const [barsReload, setBarsReload] = React.useState(0);
  // Message d'information au-dessus du graphique (historique d'une unité de
  // temps qui ne couvre pas la date du replay). Effacé au prochain changement
  // d'actif, d'unité de temps ou nouveau départ.
  const [notice, setNotice] = React.useState(null);

  // epoch : incrémenté par « Nouveau départ » → change resetKey → reset complet
  // du moteur (positions/trades/point de départ aléatoire). Le switch de TF ne
  // touche PAS resetKey : le moteur re-mappe le playhead par le temps et
  // PRÉSERVE positions / ordres / trades — c'est LE point clé du multi-TF.
  const [epoch, setEpoch] = React.useState(0);
  const resetKey = assetId + ':' + epoch;

  // Session Supabase en cours (null = jamais sauvegardée). Permet de METTRE À
  // JOUR la même ligne quand on re-sauvegarde une session reprise.
  const [sessionId, setSessionId] = React.useState((draft0 && draft0.sessionId) || null);
  const [sessionName, setSessionName] = React.useState((draft0 && draft0.sessionName) || null);

  // Réglages d'exécution par actif (spread / commission), synchrones au render.
  const [execCfgMap, setExecCfgMap] = React.useState({});
  const execCfg = React.useMemo(() => execCfgMap[assetId] || btLoadExecCfg(assetId), [execCfgMap, assetId]);
  const setExecCfg = React.useCallback((cfg) => {
    setExecCfgMap(m => ({ ...m, [assetId]: cfg }));
    btStoreExecCfg(assetId, cfg);
  }, [assetId]);

  // Valeurs du rendu courant, lisibles depuis les callbacks stables et depuis
  // le chargement asynchrone des bougies.
  const ctxRef = React.useRef({ assetId, tf });
  ctxRef.current.assetId = assetId;
  ctxRef.current.tf = tf;
  const btRef = React.useRef(null);
  // Actif et unité de temps des bougies actuellement posées dans le moteur.
  const loadedRef = React.useRef(null);

  // Restauration en attente : { state, asset, timeframe, draft }. Reprise d'une
  // sauvegarde (draft absent) : on ne restaure QUE si les bougies chargées
  // correspondent à ce contexte, et toute action qui change le contexte
  // (actif, TF, nouvelle session) l'invalide. Brouillon (draft: true) : c'est
  // le travail en cours ; il survit à un échec de chargement et à un
  // changement d'unité de temps, et n'est abandonné que par un nouveau départ.
  const pendingRestoreRef = React.useRef(draft0
    ? { state: draft0.state, asset: draft0.asset, timeframe: draft0.tf, draft: true }
    : null);
  // Le brouillon n'est ni écrit ni effacé tant que celui lu au montage n'a pas
  // été restauré : l'état vide du montage ne doit pas l'écraser.
  const draftArmedRef = React.useRef(!draft0);
  const draftTimerRef = React.useRef(null);
  const draftLastRef = React.useRef(0);

  React.useEffect(() => {
    let live = true;
    setLoading(true);
    setLoadError(false);
    (async () => {
      const data = await loadBtBars(assetId, tf);
      if (!live) return;
      if (!data.length) {
        setBars(data);
        setLoading(false);
        setLoadError(true);
        return;
      }
      // L'historique de cette unité de temps couvre-t-il la date du replay ?
      // Décidé ICI, avant de poser les bougies dans le moteur. Deux cas à
      // contrôler : un changement d'unité sur le replay en cours, et un
      // brouillon encore en attente demandé dans une autre unité que la sienne.
      // (Reprise d'une sauvegarde ou brouillon dans son unité : la restauration
      // fixe elle-même la date.)
      const eng = btRef.current;
      const pr = pendingRestoreRef.current;
      const was = loadedRef.current;
      let known = null, hasWork = false, backTf = null, atUnix = null, atTf = null;
      if (pr) {
        if (pr.draft && pr.timeframe !== tf) {
          const st = pr.state;
          known = Number.isFinite(Number(st.knownUntil)) ? Number(st.knownUntil) : Number(st.currentTime);
          hasWork = true;
          backTf = pr.timeframe;
          atUnix = Number.isFinite(Number(st.lastTime)) ? Number(st.lastTime) : Number(st.currentTime);
          atTf = st.lastTf || pr.timeframe;
        }
      } else if (eng && was && was.asset === assetId && was.tf !== tf) {
        known = eng.getKnownTime();
        hasWork = btHasWork(eng);
        backTf = was.tf;
        atUnix = eng.lastTime != null ? eng.lastTime : known;
        atTf = eng.lastTf || was.tf;
      }
      if (known != null && Number.isFinite(known) && data[0].time + btTfSeconds(tf) > known) {
        if (hasWork) {
          // Du travail est en cours : on refuse cette unité plutôt que de
          // téléporter le replay (et le P&L des positions) à une autre époque.
          // Retour à l'unité précédente, dont les bougies sont en cache.
          // Deux messages : replay antérieur à cet historique, ou replay sur
          // ses toutes premières bougies (première bougie pas encore close).
          setNotice({ text: data[0].time >= known ? btCoverageRefusedText(tf, data, atUnix, atTf) : btCoverageEarlyText(tf, data) });
          setTfRaw(backTf);
          return;
        }
        // Rien à perdre. Replay antérieur à cet historique : le moteur repart
        // d'un point aléatoire (backtest-engine.jsx, re-mapping), et on le dit.
        // Replay sur ses toutes premières bougies : il se place sur la première
        // bougie de cette unité, à la même date — rien à annoncer.
        if (data[0].time >= known) setNotice({ text: btCoverageRestartText(tf, data) });
      }
      loadedRef.current = { asset: assetId, tf };
      setBars(data);
      setLoading(false);
      setLoadError(false);
    })();
    return () => { live = false; };
  }, [assetId, tf, barsReload]);

  const decimals = btAssetDecimals(assetId);
  const contract = btAssetContract(assetId);
  const bt = useBacktest(bars, {
    startBalance: 100000,
    contract,
    resetKey,
    spread: execCfg.spread,
    commission: execCfg.commission,
    // v2.1 : durée d'une bougie (anti look-ahead au switch de TF) + TF courant
    // (estampillage des trades pour le journal multi-TF).
    barSeconds: btTfSeconds(tf),
    tf,
  });
  btRef.current = bt;

  // Le graphique et les commandes ne sont utilisables que lorsque les bougies
  // de l'actif et de l'unité affichés sont chargées ET positionnées par le
  // moteur. `frozen` neutralise ordres, clôtures et sauvegarde entre-temps.
  const chartReady = !loading && !loadError && bars.length > 0 && bt.ready;
  const frozen = !chartReady;

  // Switch d'actif / TF : pause IMMÉDIATE de la lecture.
  React.useEffect(() => { bt.pause(); }, [assetId, tf]); // bt.pause : callback stable

  // Travail non sauvegardé : signature du travail au moment de la dernière
  // sauvegarde (ou de la reprise). '' = rien de sauvegardé dans ce contexte.
  const savedSigRef = React.useRef((draft0 && typeof draft0.savedSig === 'string') ? draft0.savedSig : '');
  const [savedTick, setSavedTick] = React.useState(0); // une sauvegarde vient d'aboutir → brouillon à réécrire
  const dirty = btHasWork(bt) && btWorkSig(bt) !== savedSigRef.current;
  // Appelé après une sauvegarde réussie, avec la signature du travail sauvegardé.
  const markSaved = React.useCallback((sig) => { savedSigRef.current = sig || ''; setSavedTick(t => t + 1); }, []);

  // ── Brouillon : écriture ────────────────────────────────────────────────
  const draftCtxRef = React.useRef({});
  draftCtxRef.current = { chartReady, assetId, tf, sessionId, sessionName };
  // Écrit le brouillon tout de suite (ou l'efface s'il n'y a plus de travail).
  // Sans effet tant que le brouillon du montage n'est pas restauré, ni pendant
  // un chargement (l'instantané ne correspondrait pas à l'unité affichée).
  const writeDraftNow = React.useCallback(() => {
    if (draftTimerRef.current) { clearTimeout(draftTimerRef.current); draftTimerRef.current = null; }
    const c = draftCtxRef.current, eng = btRef.current;
    if (!userId || !draftArmedRef.current || !c.chartReady || !eng) return;
    const snap = eng.getSnapshot();
    draftLastRef.current = Date.now();
    if (!btHasWork(snap)) { btClearDraft(userId); return; }
    btWriteDraft(userId, {
      v: 1, asset: c.assetId, tf: c.tf, state: snap,
      sessionId: c.sessionId || null, sessionName: c.sessionName || null, savedSig: savedSigRef.current,
    });
  }, [userId]);
  // Abandon explicite du travail (nouveau départ, retour au début, autre
  // actif, reprise d'une sauvegarde) : le brouillon disparaît aussitôt.
  const dropDraft = React.useCallback(() => {
    if (draftTimerRef.current) { clearTimeout(draftTimerRef.current); draftTimerRef.current = null; }
    draftArmedRef.current = true;
    btClearDraft(userId);
  }, [userId]);
  // Écriture à chaque changement du travail, de la bougie courante, de l'actif,
  // de l'unité ou de la sauvegarde liée — au plus une fois par seconde (une
  // écriture différée prend l'état le plus récent).
  const workSig = btWorkSig(bt);
  const hasWorkNow = btHasWork(bt);
  const barTime = bt.currentBar ? bt.currentBar.time : null;
  React.useEffect(() => {
    if (!userId || !draftArmedRef.current || !chartReady) return;
    if (!hasWorkNow) { writeDraftNow(); return; } // plus de travail : brouillon effacé
    if (draftTimerRef.current) return;
    const wait = Math.max(0, 1000 - (Date.now() - draftLastRef.current));
    draftTimerRef.current = setTimeout(() => { draftTimerRef.current = null; writeDraftNow(); }, wait);
  }, [userId, chartReady, hasWorkNow, workSig, barTime, assetId, tf, sessionId, sessionName, savedTick, writeDraftNow]);
  // Dernière écriture en quittant la page (menu, rechargement, onglet fermé ou
  // mis en arrière-plan, bascule ordinateur ↔ mobile). Purge locale de la
  // déconnexion (événement 'tempo:purge', app.jsx) : plus aucune écriture, la
  // page va être démontée et son nettoyage ne doit pas réécrire le brouillon.
  React.useEffect(() => {
    const onHide = () => { if (document.visibilityState === 'hidden') writeDraftNow(); };
    const onPurge = () => {
      if (draftTimerRef.current) { clearTimeout(draftTimerRef.current); draftTimerRef.current = null; }
      draftArmedRef.current = false;
    };
    window.addEventListener('pagehide', writeDraftNow);
    document.addEventListener('visibilitychange', onHide);
    window.addEventListener('tempo:purge', onPurge);
    return () => {
      window.removeEventListener('pagehide', writeDraftNow);
      document.removeEventListener('visibilitychange', onHide);
      window.removeEventListener('tempo:purge', onPurge);
      writeDraftNow();
    };
  }, [writeDraftNow]);

  // Vide les bougies et affiche le chargement DANS LE MÊME RENDU que le
  // changement d'actif ou d'unité : le moteur n'a alors plus aucune bougie
  // (aucun ordre ni clôture possible sur celles de l'ancien contexte) et, au
  // changement d'actif, le point de départ aléatoire est tiré sur le NOUVEL
  // actif.
  const clearBars = React.useCallback(() => {
    setBars([]);
    setLoading(true);
    setLoadError(false);
  }, []);

  const reloadBars = React.useCallback(() => {
    clearBars();
    setBarsReload(k => k + 1);
  }, [clearBars]);

  // Changement d'actif par l'utilisateur : nouveau départ sur cet actif
  // (on oublie l'id de la sauvegarde reprise pour ne pas l'écraser).
  const setAssetId = React.useCallback((id) => {
    if (id === ctxRef.current.assetId) return;
    pendingRestoreRef.current = null; // toute reprise en attente devient caduque
    dropDraft();
    savedSigRef.current = '';
    setNotice(null);
    clearBars();
    setAssetIdRaw(id);
    setSessionId(null);
    setSessionName(null);
  }, [clearBars, dropDraft]);

  // Changement de TF par l'utilisateur : invalide une reprise de sauvegarde en
  // attente (pas un brouillon : c'est le travail en cours, il suit).
  const setTf = React.useCallback((t) => {
    if (t === ctxRef.current.tf) return;
    writeDraftNow(); // état cohérent, avant de vider les bougies
    if (pendingRestoreRef.current && !pendingRestoreRef.current.draft) pendingRestoreRef.current = null;
    setNotice(null);
    clearBars();
    setTfRaw(t);
  }, [clearBars, writeDraftNow]);

  // « Nouveau départ » : reset complet du moteur via resetKey.
  const newSession = React.useCallback(() => {
    pendingRestoreRef.current = null;
    dropDraft();
    savedSigRef.current = '';
    setNotice(null);
    setEpoch(e => e + 1);
    setSessionId(null);
    setSessionName(null);
  }, [dropDraft]);

  // Reprise d'une sauvegarde : on recharge actif + TF si besoin, puis on
  // restaure l'instantané une fois les bougies chargées. Le travail restauré
  // est déjà sauvegardé : sa signature devient la référence.
  const resumeSession = React.useCallback((row) => {
    if (!row || !btValidState(row.state)) return; // illisible : le travail en cours n'est pas touché
    dropDraft(); // le travail remplacé est abandonné ; le brouillon suivra l'état repris
    savedSigRef.current = btWorkSig(row.state);
    setNotice(null);
    setSessionId(row.id);
    setSessionName(row.name || null);
    const rowAsset = row.asset || assetId;
    const rowTf = row.timeframe || tf;
    const sameData = rowAsset === assetId && rowTf === tf;
    if (sameData && chartReady) {
      pendingRestoreRef.current = null;
      bt.restoreSnapshot(row.state);
    } else {
      pendingRestoreRef.current = { state: row.state, asset: rowAsset, timeframe: rowTf };
      if (!sameData) {
        clearBars();
        setAssetIdRaw(rowAsset);
        setTfRaw(rowTf);
      } else if (loadError) {
        reloadBars(); // mêmes bougies, mais leur chargement avait échoué : nouvelle tentative
      }
    }
  }, [assetId, tf, chartReady, loadError, bt, clearBars, reloadBars, dropDraft]);

  React.useEffect(() => {
    const pr = pendingRestoreRef.current;
    if (!pr) return;
    // Échec de chargement : la reprise d'une sauvegarde est abandonnée ; un
    // brouillon reste en attente (« Réessayer » ou une autre unité de temps).
    if (loadError) { if (!pr.draft) pendingRestoreRef.current = null; return; }
    if (loading || !bars.length) return;
    // Reprise d'une sauvegarde : seulement sur les bougies de la sauvegarde
    // (l'utilisateur a pu changer d'actif / TF pendant le chargement). Un
    // brouillon se restaure sur l'unité chargée, quelle qu'elle soit : sa
    // couverture a été vérifiée au chargement.
    pendingRestoreRef.current = null;
    if (pr.asset === assetId && (pr.timeframe === tf || pr.draft)) bt.restoreSnapshot(pr.state);
    if (pr.draft) draftArmedRef.current = true;
  }, [loading, loadError, bars]); // eslint-disable-line react-hooks/exhaustive-deps

  // « Revenir au début » : le moteur repart de zéro sur la première bougie
  // (positions, ordres et trades effacés) — c'est un nouveau départ, qui ne
  // doit plus mettre à jour la sauvegarde reprise.
  const goToStart = () => {
    pendingRestoreRef.current = null;
    dropDraft();
    savedSigRef.current = '';
    setNotice(null);
    bt.goToStart();
    setSessionId(null);
    setSessionName(null);
  };

  // ── Remise à zéro protégée ──────────────────────────────────────────────
  // « Nouveau départ », « Revenir au début », changement d'actif et reprise
  // d'une sauvegarde remplacent le travail en cours. S'il reste du travail non
  // sauvegardé, une confirmation en ligne (BtResetConfirm) le dit d'abord ;
  // sinon l'action est immédiate.
  // pendingReset = { kind: 'new' | 'start' | 'asset' | 'resume', payload, then }.
  const [pendingReset, setPendingReset] = React.useState(null);
  const runReset = (req) => {
    if (!req) return;
    if (req.kind === 'new') newSession();
    else if (req.kind === 'start') goToStart();
    else if (req.kind === 'asset') setAssetId(req.payload);
    else if (req.kind === 'resume') resumeSession(req.payload);
    if (typeof req.then === 'function') req.then();
  };
  const requestReset = (req) => {
    if (!req) return;
    if (req.kind === 'asset' && req.payload === assetId) return; // même actif : rien à faire
    if (!dirty) { setPendingReset(null); runReset(req); return; }
    bt.pause(); // la question porte sur un état figé
    setPendingReset(req);
  };
  const confirmReset = () => { const req = pendingReset; setPendingReset(null); runReset(req); };
  const cancelReset = React.useCallback(() => setPendingReset(null), []);
  // Échap referme la confirmation (Annuler).
  React.useEffect(() => {
    if (!pendingReset) return;
    const onKey = (e) => { if (e.key === 'Escape') cancelReset(); };
    document.addEventListener('keydown', onKey);
    return () => document.removeEventListener('keydown', onKey);
  }, [pendingReset, cancelReset]);

  return {
    assetId, setAssetId, tf, setTf, bars, loading, loadError, reloadBars, decimals, contract, bt,
    chartReady, frozen, notice, dismissNotice: () => setNotice(null),
    execCfg, setExecCfg, newSession, sessionId, setSessionId, sessionName, setSessionName, resumeSession,
    resetKey, // « génération » de contexte (actif + epoch) — garde des sauvegardes async
    dirty, markSaved, requestReset, pendingReset, confirmReset, cancelReset,
    everSaved: savedSigRef.current !== '', // une sauvegarde couvre déjà une partie du travail
  };
}

// ═════════════════════════════════════════════════════════════════════════
//  SÉLECTEUR D'ACTIF (groupé Forex / Métaux / Indices)
// ═════════════════════════════════════════════════════════════════════════
function BtAssetSelect({ value, onChange }) {
  const [open, setOpen] = React.useState(false);
  const ref = React.useRef(null);
  const btnRef = React.useRef(null);
  const current = btAsset(value);

  useBtClickOutside(ref, open, () => setOpen(false));
  // Échap referme la liste et rend le focus au bouton, comme les autres menus.
  const closeToButton = useBtPopoverKeys(open, setOpen, ref, btnRef);

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button ref={btnRef} className="btn tap" onClick={() => setOpen(o => !o)} aria-haspopup="true" aria-expanded={open} aria-label={'Actif : ' + (current ? current.label : '—')} style={{ minWidth: 168, justifyContent: 'space-between' }}>
        <span style={{ fontWeight: 600 }}>{current ? current.label : '—'}</span>
        <span style={{ display: 'flex', color: 'var(--fg-3)' }}>{Ico.chevDown}</span>
      </button>
      {open && (
        <div className="card" style={{
          position: 'absolute', top: 'calc(100% + 6px)', left: 0, zIndex: 50,
          minWidth: 220, padding: 6, boxShadow: '0 8px 28px rgba(0,0,0,0.14)',
        }}>
          {BT_CATEGORIES.map(cat => (
            <div key={cat}>
              <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.04em', padding: '8px 10px 4px' }}>{cat}</div>
              {BT_ASSETS.filter(a => a.category === cat).map(a => {
                const on = a.id === value;
                return (
                  <button key={a.id} className="tap" onClick={() => { closeToButton(); onChange(a.id); }} style={{
                    width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
                    background: on ? 'var(--blue-soft)' : 'transparent',
                    color: on ? 'var(--blue-600)' : 'var(--fg)',
                    borderRadius: 8, padding: '8px 10px', fontSize: 13, fontWeight: on ? 600 : 500,
                    display: 'flex', alignItems: 'center', justifyContent: 'space-between',
                  }}>
                    {a.label}
                    {on && <span style={{ display: 'flex' }}>{Ico.check}</span>}
                  </button>
                );
              })}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Boutons d'unité de temps ────────────────────────────────────────────────
function BtTfButtons({ value, onChange }) {
  return <SegmentedTabs options={BT_TIMEFRAMES} value={value} onChange={onChange} kind="choice" size="sm" ariaLabel="Unité de temps" title="Unité de temps" />;
}

// ═════════════════════════════════════════════════════════════════════════
//  BARRE DE REPLAY (début / play-pause / vitesses / saut à date / scrubber)
// ═════════════════════════════════════════════════════════════════════════
const BT_SPEEDS = [1, 2, 5, 10, 30, 60];
const BtReplayIco = {
  start: <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M4 3v10M13 3L7 8l6 5z"/></svg>,
  play:  <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><path d="M4 2.5l9 5.5-9 5.5z"/></svg>,
  pause: <svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor"><rect x="3.5" y="3" width="3.2" height="10" rx="1"/><rect x="9.3" y="3" width="3.2" height="10" rx="1"/></svg>,
  step:  <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round"><path d="M3 3l6 5-6 5zM12 3v10"/></svg>,
  cal:   <svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round"><rect x="2" y="3" width="12" height="11" rx="2"/><path d="M2 6.5h12M5.5 1.5v3M10.5 1.5v3"/></svg>,
};

// Instant d'une bougie → valeurs d'inputs (YYYY-MM-DD / HH:MM), dans le fuseau
// du profil (jamais celui du navigateur). Une bougie journalière garde sa date
// civile (btBarDay).
function btUnixToYmd(unix, tf) { return btBarDay(unix, tf); }
function btUnixToHm(unix) { return tzTimeIn(unix * 1000); }

// ── Bouton calendrier + popover « Aller à une date » ────────────────────────
function BtJumpButton({ bt, tf, iconBtnStyle }) {
  const [open, setOpen] = React.useState(false);
  const [date, setDate] = React.useState('');
  const [time, setTime] = React.useState('00:00');
  const ref = React.useRef(null);
  useBtClickOutside(ref, open, () => setOpen(false));

  const first = bt.bars && bt.bars.length ? bt.bars[0] : null;
  const last = bt.bars && bt.bars.length ? bt.bars[bt.bars.length - 1] : null;
  const intraday = tf !== '1d'; // en intraday on propose aussi l'heure

  const toggle = () => {
    if (!open && bt.currentBar) {
      // Pré-remplit avec la position courante du replay.
      setDate(btUnixToYmd(bt.currentBar.time, tf));
      setTime(btUnixToHm(bt.currentBar.time));
    }
    setOpen(o => !o);
  };

  // Échap referme le popover et rend le focus au bouton.
  const btnRef = React.useRef(null);
  const closeToButton = useBtPopoverKeys(open, setOpen, ref, btnRef);

  const go = (e) => {
    if (e && e.preventDefault) e.preventDefault();
    if (!date || !first || !last) return;
    let t;
    if (intraday) {
      // Date et heure SAISIES dans le fuseau du profil → instant UTC.
      const iso = zonedToUtcISO(date, time || '00:00');
      if (!iso) return;
      t = Math.floor(Date.parse(iso) / 1000);
    } else {
      // Bougie journalière : la dernière bougie dont la date civile (btBarDay)
      // est ≤ à la date choisie — la bougie de ce jour, ou la séance précédente
      // si le marché était fermé ; la première bougie si la date précède
      // l'historique. Les dates des bougies sont strictement croissantes.
      const all = bt.bars;
      let lo = 0, hi = all.length - 1, res = 0;
      while (lo <= hi) {
        const mid = (lo + hi) >> 1;
        if (btBarDay(all[mid].time, tf) <= date) { res = mid; lo = mid + 1; } else { hi = mid - 1; }
      }
      t = all[res].time;
    }
    if (!Number.isFinite(t)) return;
    // Borné à l'historique disponible.
    t = Math.min(Math.max(t, first.time), last.time);
    bt.jumpToTime(t);
    closeToButton();
  };

  return (
    <div ref={ref} style={{ position: 'relative' }}>
      <button ref={btnRef} className="tap tap-44" title="Aller à une date" aria-label="Aller à une date" aria-expanded={open} onClick={toggle} disabled={!first} style={iconBtnStyle}>
        {BtReplayIco.cal}
      </button>
      {open && first && last && (
        <form className="card" onSubmit={go} style={{
          position: 'absolute', bottom: 'calc(100% + 8px)', left: 0, zIndex: 60,
          minWidth: 230, padding: 14, boxShadow: '0 8px 28px rgba(0,0,0,0.14)',
          display: 'flex', flexDirection: 'column', gap: 10,
        }}>
          <div style={{ fontSize: 12.5, fontWeight: 600 }}>Aller à une date</div>
          <input className="input" type="date" value={date} aria-label="Date"
            min={btUnixToYmd(first.time, tf)} max={btUnixToYmd(last.time, tf)}
            onChange={e => setDate(e.target.value)} />
          {intraday && (
            <input className="input" type="time" value={time} aria-label="Heure" onChange={e => setTime(e.target.value)} />
          )}
          <button type="submit" className="btn btn-blue tap" style={{ justifyContent: 'center', fontSize: 12.5, fontWeight: 600 }}>
            Sauter
          </button>
          <div style={{ fontSize: 10.5, color: 'var(--fg-3)', lineHeight: 1.45 }}>
            {intraday ? btCapitalize(tzHourLabel()) + '. ' : ''}Les SL et TP sont exécutés pendant le saut.
          </div>
        </form>
      )}
    </div>
  );
}

// onStart : « Revenir au début » (remise à zéro protégée, voir requestReset).
function BtReplayBar({ bt, tf, onStart }) {
  const max = Math.max(0, bt.nBars - 1);
  const iconBtn = (extra) => ({
    width: 36, height: 36, borderRadius: 9, border: '1px solid var(--line)',
    background: 'var(--bg-card)', color: 'var(--fg-2)', cursor: 'pointer',
    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
    ...extra,
  });
  const barTime = bt.currentBar ? bt.currentBar.time : null;
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12, minWidth: 0 }}>
      <div style={{ display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <button className="tap tap-44" title="Revenir au début de l’historique (repart de zéro)" aria-label="Revenir au début" onClick={onStart || bt.goToStart} style={iconBtn()}>{BtReplayIco.start}</button>
          <button className="tap tap-44" title={bt.playing ? 'Pause' : 'Lecture'} aria-label={bt.playing ? 'Pause' : 'Lecture'} onClick={() => bt.playing ? bt.pause() : bt.play()}
            style={iconBtn({ width: 42, height: 42, background: 'var(--blue)', color: '#fff', border: '1px solid var(--blue)' })}>
            {bt.playing ? BtReplayIco.pause : BtReplayIco.play}
          </button>
          <button className="tap tap-44" title="Bougie suivante" aria-label="Bougie suivante" onClick={bt.stepForward} style={iconBtn()}>{BtReplayIco.step}</button>
          <BtJumpButton bt={bt} tf={tf} iconBtnStyle={iconBtn()} />
        </div>

        {/* Vitesses de lecture */}
        <SegmentedTabs kind="choice" size="xs" ariaLabel="Vitesse de lecture" value={bt.speed} onChange={bt.setSpeed}
          options={BT_SPEEDS.map(s => ({ id: s, label: '×' + s, title: 'Vitesse ×' + s }))} />

        {/* Bougie courante : date avec l'année, heure dans le fuseau du profil */}
        <span className="num" title={'Bougie courante, ' + tzHourLabel()} style={{ marginLeft: 'auto', fontSize: 12.5, fontWeight: 600, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>
          {barTime ? btBarLabel(barTime, tf) : '—'}
        </span>
      </div>

      {/* Curseur de l'historique + compteur de bougies */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
        <input type="range" min={0} max={max} value={Math.min(bt.playhead, max)} aria-label="Position dans l’historique"
          onChange={e => bt.jumpTo(Number(e.target.value))} className="tap-44"
          style={{ flex: 1, minWidth: 0, accentColor: 'var(--blue)', cursor: 'pointer' }} />
        <span className="num" style={{ fontSize: 11.5, color: 'var(--fg-3)', whiteSpace: 'nowrap', textAlign: 'right' }}>
          {'Bougie ' + fmtNum(Math.min(bt.playhead, max) + 1, 0) + ' sur ' + fmtNum(bt.nBars, 0)}
        </span>
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  PANNEAU D'ORDRE v2 (marché / limite / stop + sizing par risque + réglages)
// ═════════════════════════════════════════════════════════════════════════

// Quantité par risque : montantRisque / ((|prixRef − sl| + spreadAdd) × contract),
// arrondie à 2 décimales, minimum 0.01. null si incalculable. spreadAdd
// (optionnel) : spread ajouté à la distance pour un ordre MARCHÉ — l'entrée
// effective (close ± spread) s'éloigne toujours du SL, quel que soit le sens.
function btCalcRiskQty(refPrice, sl, amount, contract, spreadAdd) {
  const p = Number(refPrice), s = Number(sl), a = Number(amount), c = Number(contract);
  if (!Number.isFinite(p) || !Number.isFinite(s) || !Number.isFinite(a) || a <= 0) return null;
  const dist = Math.abs(p - s) + (Number(spreadAdd) > 0 ? Number(spreadAdd) : 0);
  if (!(dist > 0) || !(c > 0)) return null;
  const q = a / (dist * c);
  if (!Number.isFinite(q) || q <= 0) return null;
  return Math.max(0.01, btRound2(q));
}

// ── Popover engrenage : écart (spread) + commission ─────────────────────────
function BtExecSettings({ execCfg, setExecCfg }) {
  const [open, setOpen] = React.useState(false);
  const [spreadStr, setSpreadStr] = React.useState('');
  const [commStr, setCommStr] = React.useState('');
  const ref = React.useRef(null);
  useBtClickOutside(ref, open, () => setOpen(false));

  // Échap referme le popover et rend le focus au bouton.
  const btnRef = React.useRef(null);
  useBtPopoverKeys(open, setOpen, ref, btnRef);

  const toggle = () => {
    if (!open) {
      // Champs pré-remplis depuis la config courante à l'ouverture (virgule décimale).
      setSpreadStr(btInputNum(execCfg.spread));
      setCommStr(btInputNum(execCfg.commission));
    }
    setOpen(o => !o);
  };
  // Saisie : virgule ou point ; champ vidé = 0 ; valeur illisible ou négative
  // = réglage inchangé, et le champ le signale.
  const valid = (v) => { const n = btParseNum(v); return n === null || (Number.isFinite(n) && n >= 0); };
  const onSpread = (v) => {
    setSpreadStr(v);
    const n = btParseNum(v);
    if (valid(v)) setExecCfg({ ...execCfg, spread: n === null ? 0 : n });
  };
  const onComm = (v) => {
    setCommStr(v);
    const n = btParseNum(v);
    if (valid(v)) setExecCfg({ ...execCfg, commission: n === null ? 0 : n });
  };
  const spreadBad = !valid(spreadStr);
  const commBad = !valid(commStr);

  const fieldLabel = { fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.02em', marginBottom: 5, display: 'block' };
  const active = execCfg.spread > 0 || execCfg.commission > 0;
  const title = 'Réglages d’exécution (écart et commission)';

  return (
    <div ref={ref} style={{ position: 'relative', display: 'flex' }}>
      <button ref={btnRef} className="tap tap-44" title={title} aria-label={title} aria-expanded={open} onClick={toggle} style={{
        width: 28, height: 28, borderRadius: 8, border: '1px solid var(--line)', cursor: 'pointer',
        background: active ? 'var(--blue-soft)' : 'var(--bg-card)',
        color: active ? 'var(--blue-600)' : 'var(--fg-3)',
        display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      }}>
        <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
          <circle cx="8" cy="8" r="2.4"/>
          <path d="M8 1.6v2.1M8 12.3v2.1M1.6 8h2.1M12.3 8h2.1M3.5 3.5l1.5 1.5M11 11l1.5 1.5M12.5 3.5L11 5M5 11l-1.5 1.5"/>
        </svg>
      </button>
      {open && (
        <div className="card" style={{
          position: 'absolute', top: 'calc(100% + 8px)', right: 0, zIndex: 60,
          width: 240, padding: 14, boxShadow: '0 8px 28px rgba(0,0,0,0.14)',
          display: 'flex', flexDirection: 'column', gap: 12,
        }}>
          <div style={{ fontSize: 12.5, fontWeight: 600 }}>Réglages d’exécution</div>
          <div>
            <label style={fieldLabel}>Écart (spread), en unités de prix</label>
            <input className="input" type="text" inputMode="decimal" value={spreadStr} aria-invalid={spreadBad}
              aria-label="Écart (spread), en unités de prix" onChange={e => onSpread(e.target.value)} />
          </div>
          <div>
            <label style={fieldLabel}>Commission par clôture ({currencySymbol(BT_CURRENCY)})</label>
            <input className="input" type="text" inputMode="decimal" value={commStr} aria-invalid={commBad}
              aria-label={'Commission par clôture (' + currencySymbol(BT_CURRENCY) + ')'} onChange={e => onComm(e.target.value)} />
          </div>
          {(spreadBad || commBad) && (
            <div style={{ fontSize: 11, color: 'var(--red-text)' }}>Saisis un nombre positif ou nul.</div>
          )}
          <div style={{ fontSize: 10.5, color: 'var(--fg-3)', lineHeight: 1.5 }}>
            L’écart s’applique à l’entrée des ordres au marché uniquement. La commission est déduite à chaque clôture, totale ou partielle.
          </div>
        </div>
      )}
    </div>
  );
}

// frozen : bougies en cours de chargement (ou indisponibles) → Achat / Vente
// neutralisés, le panneau reste en place.
function BtOrderPanel({ bt, decimals, assetId, execCfg, setExecCfg, frozen }) {
  const contract = btAssetContract(assetId);
  const unit = btAssetUnit(assetId);
  const [orderType, setOrderType] = React.useState('market'); // 'market' | 'limit' | 'stop'
  const [price, setPrice] = React.useState('');
  const [sl, setSl] = React.useState('');
  const [tp, setTp] = React.useState('');
  const [sizeMode, setSizeMode] = React.useState('qty');      // 'qty' | 'riskUsd' | 'riskPct'
  const [qty, setQty] = React.useState('1');
  const [riskUsd, setRiskUsd] = React.useState('500');
  const [riskPct, setRiskPct] = React.useState('1');
  const [err, setErr] = React.useState('');
  // Le message d'erreur disparaît dès que la saisie est corrigée.
  React.useEffect(() => { setErr(''); }, [price, sl, tp, qty, riskUsd, riskPct]);

  // Saisies lues avec parseDecimalFr : null si vide, NaN si illisible.
  const spread = Number(execCfg.spread) || 0;
  const slNum = btParseNum(sl);
  const tpNum = btParseNum(tp);
  const priceNum = btParseNum(price);
  const qtyNum = btParseNum(qty);
  const slOk = slNum != null && Number.isFinite(slNum);

  // Prix de référence pour l'affichage. Pour un ordre MARCHÉ, la distance au SL
  // intègre l'écart (l'entrée effective close ± écart s'éloigne toujours du
  // SL, quel que soit le sens) : quantité et risque réel affichés correspondent
  // exactement à l'ordre réellement exécuté au clic.
  const refDisplay = orderType === 'market' ? bt.currentPrice : priceNum;
  const refOk = refDisplay != null && Number.isFinite(refDisplay);
  const spreadAdd = orderType === 'market' ? spread : 0;
  // Montant risqué, en USD (devise du compte de backtest).
  const riskAmount = sizeMode === 'riskUsd' ? btParseNum(riskUsd)
    : sizeMode === 'riskPct' ? (bt.balance * btParseNum(riskPct)) / 100
    : null;
  const liveQty = (sizeMode !== 'qty' && slOk && refOk)
    ? btCalcRiskQty(refDisplay, slNum, riskAmount, contract, spreadAdd)
    : null;
  // Risque réel de la config affichée (qty × distance effective au SL × contrat).
  const shownQty = sizeMode === 'qty' ? qtyNum : liveQty;
  const riskReal = (Number.isFinite(shownQty) && shownQty > 0 && slOk && refOk)
    ? shownQty * (Math.abs(refDisplay - slNum) + spreadAdd) * contract
    : null;
  const unitLabel = (n) => unit + (n > 1 ? 's' : '');
  const priceExample = fmtPrice(bt.currentPrice, decimals);

  const submit = (side) => {
    setErr('');
    if (orderType !== 'market' && !(priceNum != null && Number.isFinite(priceNum))) {
      setErr('Renseigne un prix pour un ordre ' + (orderType === 'limit' ? 'limite' : 'stop') + ', par exemple ' + priceExample + '.');
      return;
    }
    if (Number.isNaN(slNum)) { setErr('SL illisible. Saisis un prix, par exemple ' + priceExample + '.'); return; }
    if (Number.isNaN(tpNum)) { setErr('TP illisible. Saisis un prix, par exemple ' + priceExample + '.'); return; }
    let q;
    if (sizeMode === 'qty') {
      q = qtyNum;
    } else {
      if (!slOk) {
        setErr('Renseigne un SL pour dimensionner par le risque.');
        return;
      }
      // Distance de risque EFFECTIVE : marché = |close − sl| + écart (identique
      // au récap affiché) ; ordre en attente = |prix de l'ordre − sl|.
      q = orderType === 'market'
        ? btCalcRiskQty(bt.currentPrice, slNum, riskAmount, contract, spread)
        : btCalcRiskQty(priceNum, slNum, riskAmount, contract);
      if (q == null) {
        setErr('Impossible de calculer la quantité — vérifie le SL et le montant de risque.');
        return;
      }
    }
    if (!Number.isFinite(q) || q <= 0) { setErr('Saisis une quantité supérieure à 0.'); return; }
    const res = bt.placeOrder({
      side,
      qty: q,
      type: orderType,
      price: orderType === 'market' ? undefined : priceNum,
      sl: slOk ? slNum : null,
      tp: tpNum == null ? null : tpNum,
    });
    if (res && res.error) setErr(res.error);
  };

  const fieldLabel = { fontSize: 11, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.02em', marginBottom: 5, display: 'block' };
  const disabled = !!frozen || bt.nBars === 0;
  // Juste après un passage à une unité de temps supérieure, le prix connu est
  // plus récent que la dernière bougie affichée (voir backtest-engine.jsx).
  const priceHint = bt.priceAhead
    ? 'Dernier prix connu' + BT_NBSP + ': la bougie en cours de cette unité de temps n’est pas encore affichée.'
    : undefined;
  const btnLabel = orderType === 'market' ? '' : orderType === 'limit' ? ' limite' : ' stop';
  const riskCurLabel = 'Risque (' + currencySymbol(BT_CURRENCY) + ')';
  // Boutons pleins Achat / Vente : tokens de remplissage stables dans les deux
  // thèmes (--buy-bg / --sell-bg, charte §9), texte blanc ≥ 4,5:1.
  const sideBtn = (bg) => ({
    padding: '13px 12px', minHeight: 44, borderRadius: 10, border: 'none', cursor: disabled ? 'not-allowed' : 'pointer',
    background: bg, color: '#fff', fontSize: 13.5, fontWeight: 700, letterSpacing: '.01em',
    textTransform: 'uppercase', opacity: disabled ? 0.55 : 1,
  });

  return (
    <div className="card" style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 13 }}>
      {/* Prix courant + réglages d'exécution */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 10 }}>
        <span title={priceHint} style={{ fontSize: 12.5, fontWeight: 600, color: 'var(--fg-2)', cursor: priceHint ? 'help' : undefined, textDecoration: priceHint ? 'underline dotted' : undefined, textUnderlineOffset: 3 }}>Prix courant</span>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
          <span className="num" title={priceHint} style={{ fontSize: 18, fontWeight: 700, letterSpacing: '-.02em', whiteSpace: 'nowrap' }}>{fmtPrice(bt.currentPrice, decimals)}</span>
          <BtExecSettings execCfg={execCfg} setExecCfg={setExecCfg} />
        </div>
      </div>

      {/* Type d'ordre */}
      <BtSegmented value={orderType} onChange={(v) => { setOrderType(v); setErr(''); }} options={[
        { id: 'market', label: 'Marché' },
        { id: 'limit', label: 'Limite' },
        { id: 'stop', label: 'Stop' },
      ]} />

      {/* Prix (limite / stop) + SL / TP */}
      <div style={{ display: 'grid', gridTemplateColumns: orderType === 'market' ? '1fr 1fr' : '1fr 1fr 1fr', gap: 10 }}>
        {orderType !== 'market' && (
          <div>
            <label style={fieldLabel}>Prix</label>
            <input className="input" type="text" inputMode="decimal" aria-label="Prix" placeholder={priceExample} value={price} onChange={e => setPrice(e.target.value)} />
          </div>
        )}
        <div>
          <label style={fieldLabel}>SL</label>
          <input className="input" type="text" inputMode="decimal" aria-label="SL" placeholder="—" value={sl} onChange={e => setSl(e.target.value)} />
        </div>
        <div>
          <label style={fieldLabel}>TP</label>
          <input className="input" type="text" inputMode="decimal" aria-label="TP" placeholder="—" value={tp} onChange={e => setTp(e.target.value)} />
        </div>
      </div>

      {/* Mode de dimensionnement */}
      <BtSegmented value={sizeMode} onChange={(v) => { setSizeMode(v); setErr(''); }} options={[
        { id: 'qty', label: 'Quantité' },
        { id: 'riskUsd', label: riskCurLabel },
        { id: 'riskPct', label: 'Risque (%)' },
      ]} />

      {sizeMode === 'qty' ? (
        <div>
          <label style={fieldLabel}>Quantité ({unit}s)</label>
          <input className="input" type="text" inputMode="decimal" aria-label={'Quantité (' + unit + 's)'} value={qty} onChange={e => setQty(e.target.value)} />
        </div>
      ) : (
        <div>
          <label style={fieldLabel}>{sizeMode === 'riskUsd' ? riskCurLabel : 'Risque (% du compte)'}</label>
          <input className="input" type="text" inputMode="decimal"
            aria-label={sizeMode === 'riskUsd' ? riskCurLabel : 'Risque (% du compte)'}
            value={sizeMode === 'riskUsd' ? riskUsd : riskPct}
            onChange={e => sizeMode === 'riskUsd' ? setRiskUsd(e.target.value) : setRiskPct(e.target.value)} />
        </div>
      )}

      {/* Récap : quantité calculée + risque réel, ou aide si SL manquant */}
      {sizeMode !== 'qty' && !slOk ? (
        <div style={{ fontSize: 11.5, color: 'var(--fg-3)', background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 8, padding: '8px 10px' }}>
          {sl.trim() ? 'SL illisible. Saisis un prix, par exemple ' + priceExample + '.' : 'Renseigne un SL pour calculer la quantité par le risque.'}
        </div>
      ) : (sizeMode !== 'qty' || riskReal != null) && (
        <div style={{ fontSize: 11.5, color: 'var(--fg-2)', background: 'var(--bg-elev)', border: '1px solid var(--line)', borderRadius: 8, padding: '8px 10px', display: 'flex', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
          {sizeMode !== 'qty' && (
            <span style={{ whiteSpace: 'nowrap' }}>Quantité : <b className="num">{liveQty != null ? fmtLots(liveQty) + ' ' + unitLabel(liveQty) : '—'}</b></span>
          )}
          <span style={{ whiteSpace: 'nowrap' }}>Risque réel : <b className="num" style={{ color: 'var(--fg)' }}>{riskReal != null ? btMoney(riskReal) : '—'}</b></span>
        </div>
      )}

      {/* Erreur de validation (locale ou renvoyée par le moteur) */}
      {err && (
        <div role="alert" style={{ fontSize: 11.5, color: 'var(--red-text)', background: 'var(--red-soft)', borderRadius: 8, padding: '8px 10px' }}>{err}</div>
      )}

      {/* Boutons Achat / Vente */}
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 10 }}>
        <button className="tap" onClick={() => submit('long')} disabled={disabled} style={sideBtn('var(--buy-bg, #15803d)')}>Achat{btnLabel}</button>
        <button className="tap" onClick={() => submit('short')} disabled={disabled} style={sideBtn('var(--sell-bg, #dc2626)')}>Vente{btnLabel}</button>
      </div>
    </div>
  );
}

// ── Bandeau compte (solde / valeur du compte / P&L réalisé / latent) ────────
// Grille 2×2 sur toutes les largeurs : la colonne de droite fait 340 px sur
// ordinateur, et un montant n'est jamais tronqué (charte §9).
function BtAccountStrip({ bt }) {
  const cells = [
    { label: 'Solde', value: btMoney(bt.balance) },
    { label: 'Valeur du compte', value: btMoney(bt.equity) },
    { label: 'P&L réalisé', value: btMoney(bt.realizedPnl, true), color: btToneColor(bt.realizedPnl) },
    { label: 'P&L latent', value: btMoney(bt.unrealizedPnl, true), color: btToneColor(bt.unrealizedPnl) },
  ];
  return (
    <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
      <div title="Les montants du backtest restent en dollars, la devise de cotation des actifs, sans conversion vers la devise de ton profil."
        style={{ padding: '8px 14px', fontSize: 11, color: 'var(--fg-3)', borderBottom: '1px solid var(--line)' }}>
        Compte de backtest en USD
      </div>
      <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr' }}>
        {cells.map((c, i) => (
          <div key={c.label} style={{
            padding: '12px 14px', minWidth: 0,
            borderRight: i % 2 === 0 ? '1px solid var(--line)' : 'none',
            borderTop: i >= 2 ? '1px solid var(--line)' : 'none',
          }}>
            <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.02em', marginBottom: 6 }}>{c.label}</div>
            <div className="num" title={c.value} style={{ fontSize: 15, fontWeight: 700, letterSpacing: '-.015em', color: c.color || 'var(--fg)', whiteSpace: 'nowrap' }}>{c.value}</div>
          </div>
        ))}
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  POSITIONS OUVERTES v2 (break-even / clôture partielle / édition SL-TP)
// ═════════════════════════════════════════════════════════════════════════
// Pastille de sens : « Achat » / « Vente » (+ « limite » / « stop »), les
// capitales viennent du CSS.
// Pastille de sens d'un trade clôturé : la même que le tableau Trades du journal
// (pages-2.jsx, colonne « Sens ») — « A » vert / « V » rouge (charte §5).
// BtSidePill, en toutes lettres, reste réservé aux positions et aux ordres.
function BtSideBadge({ side }) {
  const sell = side === 'short' || side === 'sell';
  return (
    <span className="pill" title={sell ? 'Vente' : 'Achat'} aria-label={sell ? 'Vente' : 'Achat'} style={{
      background: sell ? 'var(--red-soft)' : 'var(--green-soft)',
      color: sell ? 'var(--red-text)' : 'var(--green-text)',
      border: 'none', padding: '2px 0', fontSize: 10.5, fontWeight: 700, borderRadius: 4, width: 22, justifyContent: 'center', flexShrink: 0,
    }}>{sell ? 'V' : 'A'}</span>
  );
}

function BtSidePill({ side, type, fontSize = 11 }) {
  const long = side === 'long';
  const label = (long ? 'Achat' : 'Vente') + (type === 'limit' ? ' limite' : type === 'stop' ? ' stop' : '');
  return (
    <span className="pill" style={{
      background: long ? 'var(--green-soft)' : 'var(--red-soft)',
      color: long ? 'var(--green-text)' : 'var(--red-text)',
      fontWeight: 700, fontSize, textTransform: 'uppercase', whiteSpace: 'nowrap', width: 'fit-content',
    }}>{label}</span>
  );
}

// frozen : bougies en cours de chargement → actions neutralisées.
function BtPositionRow({ bt, p, decimals, isFirst, frozen }) {
  const [editing, setEditing] = React.useState(false);
  const [slV, setSlV] = React.useState('');
  const [tpV, setTpV] = React.useState('');
  const [err, setErr] = React.useState('');
  // Le message d'erreur disparaît dès que la saisie est corrigée, ou que le
  // prix a bougé (un break-even refusé peut être redemandé).
  React.useEffect(() => { setErr(''); }, [slV, tpV, bt.currentPrice]);

  const dir = p.side === 'long' ? 1 : -1;
  const up = (bt.currentPrice - p.entry) * p.qty * dir * (p.cs > 0 ? p.cs : 1);

  // Moitié de position, arrondie à 2 décimales — désactivée si trop petite.
  const half = btRound2(p.qty / 2);
  const canHalf = half > 0 && half < p.qty;

  const startEdit = () => {
    setSlV(btInputNum(p.sl));
    setTpV(btInputNum(p.tp));
    setErr('');
    setEditing(true);
  };
  const applyEdit = (e) => {
    if (e && e.preventDefault) e.preventDefault();
    const slN = btParseNum(slV);
    const tpN = btParseNum(tpV);
    if (Number.isNaN(slN) || Number.isNaN(tpN)) {
      setErr((Number.isNaN(slN) ? 'SL' : 'TP') + ' illisible. Saisis un prix, par exemple ' + fmtPrice(bt.currentPrice, decimals) + '.');
      return;
    }
    if (frozen) return;
    const res = bt.modifyPosition(p.id, { sl: slN, tp: tpN });
    if (res && res.error) { setErr(res.error); return; }
    setErr('');
    setEditing(false);
  };
  const doBe = () => {
    const res = bt.breakEven(p.id);
    if (res && res.error) setErr(res.error); else setErr('');
  };
  const doHalf = () => {
    if (!canHalf) return;
    const res = bt.closePartial(p.id, half);
    if (res && res.error) setErr(res.error); else setErr('');
  };

  const miniBtn = (extra) => ({
    border: '1px solid var(--line)', background: 'var(--bg-card)', color: 'var(--fg-2)',
    borderRadius: 7, padding: '4px 8px', fontSize: 11, fontWeight: 600, cursor: 'pointer',
    ...extra,
    ...(frozen ? { opacity: 0.55, cursor: 'not-allowed' } : null),
  });

  return (
    <div style={{ padding: '11px 14px', borderTop: isFirst ? 'none' : '1px solid var(--line)', display: 'flex', flexDirection: 'column', gap: 8 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
          <BtSidePill side={p.side} />
          <div style={{ minWidth: 0 }}>
            <div className="num" style={{ fontSize: 12.5, fontWeight: 600 }}>{fmtPrice(p.entry, decimals)} <span style={{ color: 'var(--fg-3)', fontWeight: 500 }}>× {fmtLots(p.qty)}</span></div>
            <div style={{ fontSize: 10.5, color: 'var(--fg-3)' }}>
              {p.sl != null ? 'SL ' + fmtPrice(p.sl, decimals) : 'SL —'} · {p.tp != null ? 'TP ' + fmtPrice(p.tp, decimals) : 'TP —'}
            </div>
          </div>
        </div>
        <span className="num" style={{ fontSize: 13, fontWeight: 700, color: btToneColor(up), flexShrink: 0, whiteSpace: 'nowrap' }}>{btMoney(up, true)}</span>
      </div>

      {/* Actions : break-even / clôture partielle / édition SL-TP / clôture totale */}
      <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
        <button className="tap" title="Ramène le SL au prix d’entrée, une fois la position gagnante" onClick={doBe} disabled={!!frozen} style={miniBtn()}>Break-even</button>
        <button className="tap" title={canHalf ? 'Clôture la moitié de la position (quantité ' + fmtLots(half) + ')' : 'Position trop petite pour une clôture partielle'}
          aria-label="Clôturer la moitié"
          onClick={doHalf} disabled={!canHalf || !!frozen} style={miniBtn({ opacity: canHalf ? 1 : 0.55, cursor: canHalf ? 'pointer' : 'not-allowed' })}>½</button>
        <button className="tap" title="Modifier le SL et le TP" aria-expanded={editing} disabled={!!frozen} onClick={() => editing ? setEditing(false) : startEdit()}
          style={miniBtn(editing ? { background: 'var(--blue-soft)', color: 'var(--blue-600)', border: '1px solid transparent' } : {})}>SL/TP</button>
        <div style={{ flex: 1 }} />
        <button className="tap" onClick={() => bt.closePosition(p.id)} disabled={!!frozen} style={miniBtn({ padding: '4px 10px' })}>Clôturer</button>
      </div>

      {/* Édition en ligne SL / TP (Entrée enregistre, Échap annule) */}
      {editing && (
        <form onSubmit={applyEdit} onKeyDown={e => { if (e.key === 'Escape') { e.stopPropagation(); setEditing(false); setErr(''); } }}
          style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
          <input className="input" type="text" inputMode="decimal" placeholder="SL" aria-label="SL" value={slV} onChange={e => setSlV(e.target.value)} style={{ flex: 1, minWidth: 0, padding: '6px 8px', fontSize: 12 }} />
          <input className="input" type="text" inputMode="decimal" placeholder="TP" aria-label="TP" value={tpV} onChange={e => setTpV(e.target.value)} style={{ flex: 1, minWidth: 0, padding: '6px 8px', fontSize: 12 }} />
          <button type="submit" className="tap" disabled={!!frozen} style={miniBtn({ background: 'var(--blue)', color: '#fff', border: '1px solid var(--blue)', padding: '6px 10px' })}>Enregistrer</button>
        </form>
      )}
      {err && <div role="alert" style={{ fontSize: 11, color: 'var(--red-text)' }}>{err}</div>}
    </div>
  );
}

function BtPositionsList({ bt, decimals, frozen }) {
  if (!bt.positions.length) {
    return (
      <div className="card" style={{ padding: '18px 16px', textAlign: 'center', color: 'var(--fg-3)', fontSize: 12.5 }}>
        Aucune position ouverte.
      </div>
    );
  }
  return (
    <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '12px 14px', borderBottom: '1px solid var(--line)' }}>
        <CardTitle style={{ fontSize: 12.5, letterSpacing: 'normal' }}>Positions ouvertes ({fmtNum(bt.positions.length, 0)})</CardTitle>
        <button className="tap" onClick={bt.closeAll} disabled={!!frozen} style={{
          border: '1px solid var(--line)', background: 'var(--bg-card)', color: 'var(--fg-2)',
          borderRadius: 7, padding: '5px 10px', fontSize: 11.5, fontWeight: 600,
          cursor: frozen ? 'not-allowed' : 'pointer', opacity: frozen ? 0.55 : 1,
        }}>Tout clôturer</button>
      </div>
      {bt.positions.map((p, i) => (
        <BtPositionRow key={p.id} bt={bt} p={p} decimals={decimals} isFirst={i === 0} frozen={frozen} />
      ))}
    </div>
  );
}

// ── Ordres en attente (limite / stop) ───────────────────────────────────────
function BtPendingList({ bt, decimals, frozen }) {
  if (!bt.pendingOrders || !bt.pendingOrders.length) return null;
  return (
    <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
      <CardTitle style={{ padding: '12px 14px', borderBottom: '1px solid var(--line)', fontSize: 12.5, letterSpacing: 'normal' }}>
        Ordres en attente ({fmtNum(bt.pendingOrders.length, 0)})
      </CardTitle>
      {bt.pendingOrders.map((o, i) => (
        <div key={o.id} style={{
          display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10,
          padding: '10px 14px', borderTop: i ? '1px solid var(--line)' : 'none',
        }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
            <BtSidePill side={o.side} type={o.type} fontSize={10.5} />
            <div style={{ minWidth: 0 }}>
              <div className="num" style={{ fontSize: 12.5, fontWeight: 600 }}>{fmtPrice(o.price, decimals)} <span style={{ color: 'var(--fg-3)', fontWeight: 500 }}>× {fmtLots(o.qty)}</span></div>
              <div style={{ fontSize: 10.5, color: 'var(--fg-3)' }}>
                {o.sl != null ? 'SL ' + fmtPrice(o.sl, decimals) : 'SL —'} · {o.tp != null ? 'TP ' + fmtPrice(o.tp, decimals) : 'TP —'}
              </div>
            </div>
          </div>
          <button className="tap" onClick={() => bt.cancelOrder(o.id)} disabled={!!frozen} style={{
            border: '1px solid var(--line)', background: 'var(--bg-card)', color: 'var(--fg-2)',
            borderRadius: 7, padding: '5px 10px', fontSize: 11.5, fontWeight: 600, flexShrink: 0,
            cursor: frozen ? 'not-allowed' : 'pointer', opacity: frozen ? 0.55 : 1,
          }}>Annuler</button>
        </div>
      ))}
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  RÉSULTATS + COURBE DE P&L CUMULÉ (SVG inline pur, pas de lib)
// ═════════════════════════════════════════════════════════════════════════

// Path SVG « lissé » : quadratiques passant par les milieux des segments.
function _btSmoothPath(pts) {
  if (!pts.length) return '';
  if (pts.length < 3) return 'M' + pts.map(p => p[0] + ',' + p[1]).join(' L');
  let d = 'M' + pts[0][0] + ',' + pts[0][1];
  for (let i = 1; i < pts.length - 1; i++) {
    const mx = (pts[i][0] + pts[i + 1][0]) / 2;
    const my = (pts[i][1] + pts[i + 1][1]) / 2;
    d += ' Q' + pts[i][0] + ',' + pts[i][1] + ' ' + mx + ',' + my;
  }
  d += ' L' + pts[pts.length - 1][0] + ',' + pts[pts.length - 1][1];
  return d;
}

function BtEquityCurve({ curve, startBalance }) {
  if (!curve || curve.length < 2) {
    return (
      <div style={{ height: 120, display: 'flex', alignItems: 'center', justifyContent: 'center', textAlign: 'center', padding: '0 12px', color: 'var(--fg-2)', fontSize: 11.5, background: 'var(--bg-elev)', borderRadius: 10 }}>
        La courbe de P&L cumulé apparaîtra après ton premier trade clôturé.
      </div>
    );
  }
  const W = 320, H = 120, pad = 6;
  const n = curve.length;
  let min = Infinity, max = -Infinity;
  for (const p of curve) {
    const e = Number(p.equity);
    if (e < min) min = e;
    if (e > max) max = e;
  }
  if (!(max > min)) { max = min + Math.max(1, Math.abs(min) * 0.001); } // courbe plate : évite la division par 0
  // Abscisse par le temps si possible, sinon espacement uniforme (plusieurs
  // clôtures sur la même bougie → temps identiques).
  const t0 = curve[0].time, t1 = curve[n - 1].time;
  const byTime = Number.isFinite(t0) && Number.isFinite(t1) && t1 > t0;
  const x = (p, i) => pad + (byTime ? (p.time - t0) / (t1 - t0) : i / (n - 1)) * (W - 2 * pad);
  const y = (p) => pad + (1 - (p.equity - min) / (max - min)) * (H - 2 * pad);
  const pts = curve.map((p, i) => [Math.round(x(p, i) * 10) / 10, Math.round(y(p) * 10) / 10]);
  const line = _btSmoothPath(pts);
  const area = line + ' L' + pts[pts.length - 1][0] + ',' + (H - 1) + ' L' + pts[0][0] + ',' + (H - 1) + ' Z';
  const net = curve[n - 1].equity - (Number.isFinite(Number(startBalance)) ? Number(startBalance) : curve[0].equity);
  const stroke = net >= 0 ? 'var(--green)' : 'var(--red)';
  const fill = net >= 0 ? 'var(--green-soft)' : 'var(--red-soft)';
  return (
    <svg viewBox={'0 0 ' + W + ' ' + H} preserveAspectRatio="none" style={{ width: '100%', height: 120, display: 'block' }} aria-hidden="true">
      <path d={area} fill={fill} stroke="none" />
      <path d={line} fill="none" stroke={stroke} strokeWidth="1.8" strokeLinejoin="round" strokeLinecap="round" vectorEffect="non-scaling-stroke" />
    </svg>
  );
}

// Carte « Résultats » du travail en cours (trades clôturés du replay).
function BtSessionStatsCard({ bt }) {
  const s = React.useMemo(
    () => (typeof btSessionStats === 'function' ? btSessionStats(bt.closedTrades, bt.startBalance) : null),
    [bt.closedTrades, bt.startBalance]
  );

  // Agrégats calculés une seule fois, par btSessionStats (backtest-engine.jsx).
  // Profit factor : « ∞ » sans perte (Infinity n'est jamais remplacé par 0),
  // « — » sans trade ou sans gain ni perte.
  const pf = (s && s.n) ? s.profitFactor : null;
  // Espérance par trade = P&L moyen par trade (même définition que les
  // Statistiques du journal), en USD.
  const expectancy = (s && s.n) ? s.expectancy : null;
  const nuls = s ? s.nuls : 0;
  const hasDd = !!s && Math.round(Number(s.maxDrawdown) * 100) > 0; // « — » sous 0,01 $
  const ddPct = hasDd && s.maxDrawdownPct != null && Number.isFinite(Number(s.maxDrawdownPct)) ? Number(s.maxDrawdownPct) : null;
  const best = s && Math.round(Number(s.best) * 100) > 0 ? Number(s.best) : null;     // aucun gain → « — »
  const worst = s && Math.round(Number(s.worst) * 100) < 0 ? Number(s.worst) : null;  // aucune perte → « — »

  // Une case : libellé au-dessus, valeur en dessous (jamais tronquée).
  const cell = (label, value, color, sub) => (
    <div style={{ minWidth: 0 }}>
      <div style={{ fontSize: 11, color: 'var(--fg-3)', fontWeight: 500, marginBottom: 2 }}>{label}</div>
      <div className="num" style={{ fontSize: 12.5, fontWeight: 700, color: color || 'var(--fg)' }}>{value}</div>
      {sub ? <div className="num" style={{ fontSize: 10.5, color: 'var(--fg-3)', marginTop: 1 }}>{sub}</div> : null}
    </div>
  );
  const nw = (txt) => <span style={{ whiteSpace: 'nowrap' }}>{txt}</span>;

  return (
    <div className="card" style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 12 }}>
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: 10 }}>
        <CardTitle style={{ fontSize: 12.5, letterSpacing: 'normal' }}>Résultats</CardTitle>
        {s && s.n > 0 && (
          <span className="num" title="P&L net" style={{ fontSize: 12.5, fontWeight: 700, color: btToneColor(s.netPnl), whiteSpace: 'nowrap' }}>{btMoney(s.netPnl, true)}</span>
        )}
      </div>

      <BtEquityCurve curve={bt.equityCurve} startBalance={bt.startBalance} />

      {s && s.n > 0 ? (
        <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) minmax(0, 1fr)', columnGap: 16, rowGap: 10 }}>
          {/* « Trades » = le nombre, partout (onglet Trades de backtest, journal) ;
              la répartition G / P accompagne le taux de réussite, comme au Tableau de bord. */}
          {cell('Trades', nw(fmtNum(s.n, 0)))}
          {cell('Taux de réussite', nw(fmtPct((Number(s.winrate) || 0) * 100, 1)), undefined,
            nw(fmtNum(s.wins, 0) + ' G · ' + fmtNum(s.losses, 0) + ' P' + (nuls > 0 ? ' · ' + btCount(nuls, 'nul', 'nuls') : '')))}
          {cell('Profit factor', nw(fmtPF(pf, s.n)))}
          {cell('R moyen', nw(fmtR(s.avgR)), s.avgR != null ? btToneColor(s.avgR) : undefined)}
          {cell('Espérance par trade', nw(expectancy != null ? btMoney(expectancy, true) : '—'), expectancy != null ? btToneColor(expectancy) : undefined)}
          {cell('Drawdown max', hasDd
            ? <>{nw(btMoney(-Number(s.maxDrawdown)))}{ddPct != null ? <> {nw('(' + fmtPct(-ddPct, 1) + ')')}</> : null}</>
            : '—', hasDd ? 'var(--red)' : undefined)}
          {cell('Meilleur trade', nw(best != null ? btMoney(best, true) : '—'), best != null ? 'var(--green)' : undefined)}
          {cell('Pire trade', nw(worst != null ? btMoney(worst, true) : '—'), worst != null ? 'var(--red)' : undefined)}
        </div>
      ) : (
        <div style={{ fontSize: 11.5, color: 'var(--fg-3)', textAlign: 'center' }}>Aucun trade clôturé pour l’instant.</div>
      )}
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  SAUVEGARDE → base (backtest_sessions / backtest_trades)
// ═════════════════════════════════════════════════════════════════════════
// À l'écran, une ligne de backtest_sessions s'appelle une « sauvegarde »
// (charte §5 : « session » est réservé aux sessions de marché).
// v2 : inclut l'instantané du moteur (colonne jsonb `state`) + un nom lisible.
// Si sessionId est fourni (sauvegarde reprise), la MÊME ligne est mise à jour,
// et ses trades sont RAPPROCHÉS de l'état courant : une ligne déjà enregistrée
// qui correspond à un trade du replay est laissée intacte — elle garde son id,
// qui sert d'external_id à l'export vers le journal (un ré-export après une
// mise à jour ne duplique donc rien). Seuls les trades nouveaux sont insérés,
// seules les lignes sans trade correspondant sont supprimées.

// Lecture de toutes les lignes d'une requête, page par page : PostgREST ne
// renvoie jamais plus de 1 000 lignes par requête. build(withCount) renvoie la
// requête triée dans un ordre stable (id en dernier critère), sans range.
// Renvoie { data, error, total } ; total = nombre de lignes annoncé par le
// serveur (à défaut, le nombre de lignes lues).
const BT_PAGE_SIZE = 1000;
const BT_MAX_PAGES = 50; // garde-fou : 50 000 lignes
async function btFetchAll(build) {
  const all = [];
  const seen = new Set();
  let total = null;
  for (let page = 0, offset = 0; page < BT_MAX_PAGES; page++) {
    const { data, error, count } = await build(page === 0).range(offset, offset + BT_PAGE_SIZE - 1);
    if (error) return { data: null, error, total: null };
    if (page === 0 && Number.isFinite(count)) total = count;
    const rows = data || [];
    for (const r of rows) {
      if (r && r.id != null) {
        if (seen.has(r.id)) continue;
        seen.add(r.id);
      }
      all.push(r);
    }
    offset += rows.length;
    // Fin : page vide, total atteint, ou (total inconnu) page incomplète.
    if (!rows.length) break;
    if (total != null ? offset >= total : rows.length < BT_PAGE_SIZE) break;
  }
  return { data: all, error: null, total: total != null ? Math.max(total, all.length) : all.length };
}

// Clé de contenu d'un trade de backtest (ligne de backtest_trades), normalisée
// pour comparer une ligne relue en base à une ligne à enregistrer : instants
// en millisecondes, quantité à 6 décimales, prix à 8, P&L au centime. Ne
// contient PAS l'id du moteur : une clôture partielle et la clôture finale
// d'une même position le partagent.
function btTradeRowKey(r) {
  const num = (v, d) => { const n = btRoundTo(v, d); return n == null ? '' : n.toFixed(d); };
  const at = (v) => { const t = Date.parse(v); return Number.isFinite(t) ? String(t) : ''; };
  return [at(r.entry_at), at(r.exit_at), r.side || '', r.reason || '', r.partial ? '1' : '0',
    num(r.qty, 6), num(r.entry_price, 8), num(r.exit_price, 8), num(r.pnl, 2)].join('|');
}

async function btSaveSession({ assetId, tf, bt, sessionId, name }) {
  if (!window.sb) return { error: btErr(BT_NET_ERROR) };
  // FIGE l'état AVANT tout await : pause de la lecture puis snapshot unique.
  // Sinon le moteur continuerait d'avancer pendant l'aller-retour réseau et les
  // agrégats (trade_count, net_pnl, trades insérés…) divergeraient du snapshot.
  if (typeof bt.pause === 'function') bt.pause();
  const snapshot = typeof bt.getSnapshot === 'function' ? bt.getSnapshot() : null;
  const closed = snapshot ? snapshot.closedTrades : bt.closedTrades;
  const openPos = snapshot ? snapshot.positions : bt.positions;
  const pendings = snapshot ? snapshot.pendingOrders : (bt.pendingOrders || []);
  const realized = snapshot ? (Number(snapshot.realizedPnl) || 0) : bt.realizedPnl;
  const hasContent = closed.length || openPos.length || pendings.length;
  if (!hasContent) return { error: btErr('Rien à sauvegarder : aucun trade, aucune position ni aucun ordre.') };

  const { data: userData } = await window.sb.auth.getUser();
  const user_id = userData && userData.user && userData.user.id;
  if (!user_id) return { error: btErr('Tu dois être connecté.') };

  // Montants enregistrés au centime (les instantanés d'avant l'arrondi du
  // moteur peuvent porter du bruit de calcul flottant).
  const netPnl = btRoundTo(closed.reduce((s, t) => s + (Number(t.pnl) || 0), 0), 2);
  const wins = closed.filter(t => t.pnl > 0).length;

  const sessionRow = {
    asset: assetId,
    timeframe: tf,
    start_balance: bt.startBalance,
    end_balance: btRoundTo(bt.startBalance + realized, 2),
    net_pnl: netPnl,
    trade_count: closed.length,
    win_count: wins,
    name: name || btDefaultSessionName(assetId, tf),
    state: snapshot,
    updated_at: new Date().toISOString(),
  };

  // 1) crée OU met à jour la session. En cas de mise à jour, on lit d'abord les
  // trades déjà enregistrés pour les rapprocher de l'état courant (étape 2).
  let session_id = sessionId || null;
  let updated = false;
  let oldRows = [];
  if (session_id) {
    const upd = await window.sb.from('backtest_sessions').update(sessionRow).eq('id', session_id).select().maybeSingle();
    if (upd.error) return { error: upd.error };
    if (upd.data) {
      updated = true;
      const sid = session_id;
      const olds = await btFetchAll(() => window.sb.from('backtest_trades').select('*').eq('session_id', sid)
        .order('exit_at', { ascending: true }).order('id', { ascending: true }));
      if (olds.error) return { error: olds.error, session_id };
      oldRows = olds.data || [];
    } else {
      session_id = null; // la session a été supprimée entre-temps → on en recrée une.
    }
  }
  if (!session_id) {
    const ins = await window.sb.from('backtest_sessions').insert({ user_id, ...sessionRow }).select().single();
    if (ins.error) return { error: ins.error };
    session_id = ins.data.id;
  }

  // 2) trades clôturés rattachés à la session. Nombres arrondis à
  // l'enregistrement : quantité à 6 décimales (le champ Quantité accepte
  // 0,005), montants au centime, R à 4 décimales.
  const tradeRows = closed.map(t => ({
    user_id,
    session_id,
    asset: assetId,
    // Unité de la bougie qui horodate la SORTIE (la colonne Date du tableau
    // « Trades de backtest » affiche l'heure de sortie : pour un trade ouvert
    // en 1 j et clôturé en 1 h, c'est une heure, pas une date de bougie
    // journalière). À défaut : unité d'ouverture, puis unité courante.
    timeframe: t.exitTf || t.tf || tf,
    side: t.side,
    qty: btRoundTo(t.qty, 6),
    entry_price: t.entry,
    exit_price: t.exit,
    sl: t.sl,
    tp: t.tp,
    pnl: btRoundTo(t.pnl, 2),
    r_multiple: btRoundTo(t.r, 4),
    reason: t.reason,
    commission: btRoundTo(t.commission, 2) || 0,
    partial: !!t.partial,
    entry_at: new Date(t.entryTime * 1000).toISOString(),
    exit_at: new Date(t.exitTime * 1000).toISOString(),
  }));

  // Rapprochement en multi-ensemble avec les lignes déjà enregistrées : des
  // trades jumeaux (deux positions identiques fermées par « Tout clôturer »)
  // sont appariés un pour un. Une ligne appariée n'est ni réinsérée ni
  // supprimée : elle garde son id.
  const stock = new Map(); // clé de contenu → lignes existantes pas encore appariées
  for (const r of oldRows) {
    const k = btTradeRowKey(r);
    if (stock.has(k)) stock.get(k).push(r); else stock.set(k, [r]);
  }
  const toInsert = [];
  for (const row of tradeRows) {
    const twins = stock.get(btTradeRowKey(row));
    if (twins && twins.length) twins.shift();
    else toInsert.push(row);
  }
  const staleIds = [];
  stock.forEach(list => list.forEach(r => staleIds.push(r.id)));

  if (toInsert.length) {
    const tradesRes = await window.sb.from('backtest_trades').insert(toInsert);
    // session_id est renvoyé MÊME en erreur : le re-clic passera par le chemin
    // « mise à jour » au lieu de créer une session dupliquée / orpheline.
    if (tradesRes.error) return { error: tradesRes.error, session_id };
  }

  // 3) SEULEMENT après l'insert réussi : suppression des lignes qui ne
  // correspondent plus à aucun trade du replay (insert-then-delete : au pire
  // des lignes en trop temporaires, jamais de perte de trades en base sur un
  // échec réseau). Par lots, pour garder des requêtes courtes.
  for (let i = 0; i < staleIds.length; i += 200) {
    const del = await window.sb.from('backtest_trades').delete().in('id', staleIds.slice(i, i + 200));
    if (del.error) return { error: del.error, session_id }; // un re-clic les résorbe
  }
  return { data: { session_id, count: tradeRows.length, updated } };
}

// frozen : bougies en cours de chargement (ou indisponibles) → pas de sauvegarde.
function BtSaveButton({ assetId, tf, bt, sessionId, sessionName, resetKey, onSaved, frozen }) {
  const [status, setStatus] = React.useState(null); // null | 'saving' | 'ok' | 'err'
  const [msg, setMsg] = React.useState('');
  const [name, setName] = React.useState('');
  const busyRef = React.useRef(false); // garde anti double-clic (re-entrée)
  // Signature du travail auquel se rapporte le message affiché (succès ou
  // échec) : dès que le travail n'est plus celui-là (nouveau départ, retour au
  // début, autre actif, reprise, nouveau trade), le message disparaît.
  const msgSigRef = React.useRef(null);

  // « Génération » du contexte : si l'actif, l'epoch (« Nouveau départ ») ou
  // la sauvegarde courante change PENDANT l'await, on n'applique pas onSaved —
  // sinon un nouveau départ vierge serait re-lié à la ligne qui vient d'être
  // enregistrée (et écraserait ses trades à la prochaine mise à jour).
  const genRef = React.useRef('');
  genRef.current = String(resetKey || '') + '|' + String(sessionId || '');

  // Reprise d'une sauvegarde nommée → pré-remplit le champ nom.
  React.useEffect(() => { setName(sessionName || ''); }, [sessionId, sessionName]);

  const hasContent = btHasWork(bt);
  const disabled = status === 'saving' || !hasContent || !!frozen;

  const sigNow = btWorkSig(bt);
  React.useEffect(() => {
    if ((status === 'ok' || status === 'err') && msgSigRef.current !== sigNow) { setStatus(null); setMsg(''); }
  }, [sigNow, status]);
  // Nouveau contexte (autre actif, nouveau départ) : aucun message ne survit.
  React.useEffect(() => {
    setStatus(st => (st === 'saving' ? st : null));
    setMsg('');
  }, [resetKey]);

  const save = async (e) => {
    if (e && e.preventDefault) e.preventDefault();
    if (busyRef.current || !hasContent || frozen) return;
    busyRef.current = true;
    const gen = genRef.current;
    const sig = btWorkSig(bt); // travail figé par la sauvegarde (le moteur est mis en pause)
    msgSigRef.current = sig;
    setStatus('saving'); setMsg('');
    const finalName = name.trim() || btDefaultSessionName(assetId, tf);
    let res;
    try {
      res = await btSaveSession({ assetId, tf, bt, sessionId, name: finalName });
    } catch (err) {
      res = { error: err || btErr(BT_NET_ERROR) };
    } finally {
      busyRef.current = false;
    }
    const sameCtx = genRef.current === gen; // le contexte n'a pas changé pendant l'await
    if (res.error) {
      setStatus('err');
      setMsg(btErrText(res.error, 'Échec de la sauvegarde. Réessaie dans un instant.'));
      // Sauvegarde créée mais trades en échec : on adopte quand même son id pour
      // que le re-clic passe par la mise à jour au lieu de créer un doublon.
      if (sameCtx && res.session_id && res.session_id !== sessionId && onSaved) onSaved(res.session_id, finalName, null);
    } else {
      setStatus('ok');
      const n = btCount(res.data.count, 'trade', 'trades');
      setMsg(res.data.updated ? 'Sauvegarde mise à jour (' + n + ').' : 'Sauvegarde enregistrée (' + n + ').');
      if (sameCtx && onSaved) onSaved(res.data.session_id, finalName, sig);
    }
  };

  return (
    <form onSubmit={save} style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
      <input className="input" type="text" value={name} onChange={e => setName(e.target.value)}
        placeholder={btDefaultSessionName(assetId, tf)} title="Nom de la sauvegarde" aria-label="Nom de la sauvegarde" style={{ fontSize: 12.5 }} />
      <button type="submit" className="btn btn-blue tap" disabled={disabled} style={{ justifyContent: 'center', padding: '11px 14px', fontSize: 13, fontWeight: 600, opacity: disabled ? 0.55 : 1 }}>
        {status === 'saving' ? 'Sauvegarde…' : sessionId ? 'Mettre à jour la sauvegarde' : 'Sauvegarder'}
      </button>
      {msg && (
        <div role="status" style={{ fontSize: 11.5, color: status === 'err' ? 'var(--red-text)' : 'var(--green-text)', textAlign: 'center' }}>{msg}</div>
      )}
    </form>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  SAUVEGARDES (liste / reprise / export vers le journal / suppression)
// ═════════════════════════════════════════════════════════════════════════
// Erreur de chargement ≠ liste vide : en cas d'échec, les lignes déjà chargées
// sont conservées et l'écran affiche un bandeau avec « Réessayer ».
function useBtSessions(refreshKey) {
  const [data, setData] = React.useState({ loading: true, error: null, sessions: [] });
  const [bump, setBump] = React.useState(0);
  React.useEffect(() => {
    let live = true;
    (async () => {
      if (!window.sb) {
        console.warn('[Tempo] sauvegardes de backtest : client indisponible.');
        setData(d => ({ ...d, loading: false, error: 'offline' }));
        return;
      }
      setData(d => ({ ...d, loading: true, error: null }));
      try {
        // Toutes les sauvegardes, page par page (pas de plafond silencieux).
        const res = await btFetchAll((withCount) => window.sb.from('backtest_sessions')
          .select('*', withCount ? { count: 'exact' } : undefined)
          .order('created_at', { ascending: false }).order('id', { ascending: false }));
        if (!live) return;
        if (res.error) {
          console.warn('[Tempo] chargement des sauvegardes de backtest :', res.error);
          setData(d => ({ ...d, loading: false, error: 'load' }));
          return;
        }
        setData({ loading: false, error: null, sessions: res.data || [] });
      } catch (e) {
        console.warn('[Tempo] chargement des sauvegardes de backtest :', e);
        if (live) setData(d => ({ ...d, loading: false, error: 'load' }));
      }
    })();
    return () => { live = false; };
  }, [refreshKey, bump]);
  return { ...data, reload: () => setBump(b => b + 1) };
}

// Export d'une sauvegarde de backtest vers le JOURNAL RÉEL (table trades).
// Action EXPLICITE de l'utilisateur — seule passerelle backtest → journal réel.
// imported_from = 'backtest' + external_id = id du trade de backtest :
// bulkInsertTrades déduplique, un re-export ne crée donc pas de doublons —
// y compris après « Mettre à jour la sauvegarde », qui conserve l'id des
// trades déjà enregistrés (btSaveSession).
// Les montants restent en USD (aucune conversion) : la confirmation le dit et
// la note de chaque trade exporté le rappelle.
// Devise du journal (profil) ; l'export n'est possible que vers un journal en
// dollars : Tempo ne convertit pas les montants, et des P&L en USD affichés en
// € ou en ¥ fausseraient le P&L réel (charte §10).
function btJournalCurrency() { return window.__appCurrency || 'USD'; }
function btExportBlockedText() {
  const cur = btJournalCurrency();
  const sym = typeof currencySymbol === 'function' ? currencySymbol(cur) : cur;
  return 'Ton journal est en ' + sym + '\u00A0: l’export n’est possible que vers un journal en dollars (Tempo ne convertit pas les montants).';
}

async function btExportSessionToJournal(session) {
  if (btJournalCurrency() !== 'USD') return { error: btErr(btExportBlockedText()) };
  if (!window.sb) return { error: btErr(BT_NET_ERROR) };
  if (typeof bulkInsertTrades !== 'function') {
    console.warn('[Tempo] export du backtest : bulkInsertTrades indisponible.');
    return { error: btErr('Journal momentanément indisponible.') };
  }
  const res = await btFetchAll(() => window.sb.from('backtest_trades').select('*').eq('session_id', session.id)
    .order('exit_at', { ascending: true }).order('id', { ascending: true }));
  if (res.error) return { error: res.error };
  const trades = res.data || [];
  if (!trades.length) return { error: btErr('Aucun trade à exporter dans cette sauvegarde.') };
  const rows = trades.map(t => ({
    symbol: t.asset,
    direction: t.side,
    entry: t.entry_price,
    exit_price: t.exit_price,
    // Quantité à 6 décimales, P&L au centime : pas de bruit de calcul flottant
    // (0.009999999999999998, 73.00000000001194) dans le journal.
    lots: Number.isFinite(Number(t.qty)) ? btRoundTo(t.qty, 6) : t.qty,
    pnl: Number.isFinite(Number(t.pnl)) ? Math.round(Number(t.pnl) * 100) / 100 : t.pnl,
    r_multiple: t.r_multiple,
    executed_at: t.exit_at || t.entry_at,
    notes: 'Backtest ' + btAssetLabel(t.asset) + ' ' + btTfLabel(t.timeframe) + ' — montants en USD',
    imported_from: 'backtest',
    external_id: t.id,
  }));
  const ins = await bulkInsertTrades(rows);
  if (ins.error) return { error: ins.error };
  return { data: { count: (ins.data || []).length, skipped: ins.skipped || 0 } };
}

// onExported : appelé après un export qui a ajouté des trades (recharge du journal).
function BtSessionRow({ row, onResume, onDeleted, onExported, isFirst }) {
  // mode : null | 'confirmExport' | 'confirmDelete'
  const [mode, setMode] = React.useState(null);
  const [busy, setBusy] = React.useState(false);
  const [msg, setMsg] = React.useState(null); // {tone:'ok'|'err', text}
  const busyRef = React.useRef(false);

  // Retour du focus clavier : quand une confirmation se referme (Annuler, ou
  // export terminé), ses boutons disparaissent et le focus tomberait sur la
  // page. Il revient au bouton d'origine, sauf si l'utilisateur a déjà visé
  // autre chose (même règle que les cartes de stratégie). Après une suppression
  // réussie, la ligne n'existe plus : rien à faire.
  const exportBtnRef = React.useRef(null);
  const deleteBtnRef = React.useRef(null);
  const prevModeRef = React.useRef(null);
  React.useEffect(() => {
    const prev = prevModeRef.current;
    prevModeRef.current = mode;
    if (mode !== null || !prev) return;
    const target = prev === 'confirmExport' ? exportBtnRef.current : deleteBtnRef.current;
    const active = document.activeElement;
    if (target && (!active || active === document.body)) {
      try { target.focus({ preventScroll: true }); } catch (e) {}
    }
  }, [mode]);

  const iso = row.updated_at || row.created_at;
  const dateLabel = iso ? fmtDayShort(iso) : '—';
  const displayName = row.name || (btAssetLabel(row.asset) + ' ' + btTfLabel(row.timeframe));

  const doExport = async () => {
    if (busyRef.current) return;
    busyRef.current = true;
    setBusy(true); setMsg(null);
    let res;
    try {
      res = await btExportSessionToJournal(row);
    } catch (e) {
      res = { error: e || btErr(BT_NET_ERROR) };
    } finally {
      busyRef.current = false;
      setBusy(false); setMode(null);
    }
    if (res.error) setMsg({ tone: 'err', text: btErrText(res.error, 'Échec de l’export. Réessaie dans un instant.') });
    else if (res.data.count === 0) setMsg({ tone: 'ok', text: 'Déjà exportée : aucun nouveau trade.' });
    else {
      // Le journal (Trades, Accueil, Statistiques) montre aussitôt les trades exportés.
      if (typeof onExported === 'function') {
        try { await onExported(); } catch (e) { console.warn('[Tempo] export du backtest : rechargement du journal', e); }
      }
      const c = res.data.count, k = res.data.skipped;
      setMsg({ tone: 'ok', text: btCount(c, 'trade exporté', 'trades exportés') + ' vers ton journal'
        + (k ? ', ' + btCount(k, 'déjà présent', 'déjà présents') : '') + ' (montants en USD).' });
    }
  };

  const doDelete = async () => {
    if (busyRef.current) return;
    if (!window.sb) { setMsg({ tone: 'err', text: BT_NET_ERROR }); return; }
    busyRef.current = true;
    setBusy(true); setMsg(null);
    let res;
    try {
      res = await window.sb.from('backtest_sessions').delete().eq('id', row.id);
    } catch (e) {
      res = { error: e || btErr(BT_NET_ERROR) };
    } finally {
      busyRef.current = false;
      setBusy(false); setMode(null);
    }
    if (res && res.error) setMsg({ tone: 'err', text: btErrText(res.error, 'Échec de la suppression. Réessaie dans un instant.') });
    else onDeleted();
  };

  const miniBtn = (extra) => ({
    border: '1px solid var(--line)', background: 'var(--bg-card)', color: 'var(--fg-2)',
    borderRadius: 7, padding: '5px 10px', fontSize: 11.5, fontWeight: 600, cursor: 'pointer',
    opacity: busy ? 0.55 : 1,
    ...extra,
  });
  const nTrades = Number(row.trade_count) || 0;

  return (
    <div style={{ padding: '12px 14px', borderTop: isFirst ? 'none' : '1px solid var(--line)', display: 'flex', flexDirection: 'column', gap: 8 }}>
      <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
        <div style={{ minWidth: 0 }}>
          <div title={displayName} style={{ fontSize: 13, fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
            {displayName}
          </div>
          <div style={{ fontSize: 11, color: 'var(--fg-3)' }}>
            {btAssetLabel(row.asset) + ' · ' + btTfLabel(row.timeframe) + ' · ' + btCount(nTrades, 'trade', 'trades') + ' · ' + dateLabel}
          </div>
        </div>
        <span className="num" title="P&L net" style={{ fontSize: 13.5, fontWeight: 700, color: btToneColor(row.net_pnl), flexShrink: 0, whiteSpace: 'nowrap' }}>{btMoney(row.net_pnl, true)}</span>
      </div>

      {mode === 'confirmExport' ? (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 11.5, color: 'var(--fg-2)' }}>Exporter {btCount(nTrades, 'trade', 'trades')} vers ton journal ? Les montants restent en USD, sans conversion.</span>
          <button className="tap" onClick={doExport} disabled={busy} style={miniBtn({ background: 'var(--blue)', color: '#fff', border: '1px solid var(--blue)' })}>{busy ? 'Export…' : 'Exporter'}</button>
          <button className="tap" onClick={() => setMode(null)} disabled={busy} style={miniBtn()}>Annuler</button>
        </div>
      ) : mode === 'confirmDelete' ? (
        <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
          <span style={{ fontSize: 11.5, color: 'var(--fg-2)' }}>Supprimer la sauvegarde « {displayName} »{nTrades === 0 ? '' : nTrades === 1 ? ' et son trade de backtest' : ' et ses ' + fmtNum(nTrades, 0) + ' trades de backtest'} ? Les trades déjà exportés restent dans ton journal.</span>
          <button className="tap" onClick={doDelete} disabled={busy} style={miniBtn({ background: 'var(--sell-bg, #dc2626)', color: '#fff', border: '1px solid var(--sell-bg, #dc2626)' })}>{busy ? 'Suppression…' : 'Supprimer'}</button>
          <button className="tap" onClick={() => setMode(null)} disabled={busy} style={miniBtn()}>Annuler</button>
        </div>
      ) : (
        <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
          <button className="tap" onClick={() => {
              if (!btValidState(row.state)) { setMode(null); setMsg({ tone: 'err', text: 'Cette sauvegarde est illisible : reprise impossible. Ton travail en cours n’est pas modifié.' }); return; }
              setMsg(null); onResume(row);
            }} disabled={!row.state}
            title={row.state ? 'Recharge l’actif et l’unité de temps, puis restaure positions, ordres et trades' : 'Sauvegarde enregistrée avec une ancienne version : reprise impossible'}
            style={miniBtn(row.state ? { background: 'var(--blue-soft)', color: 'var(--blue-600)', border: '1px solid transparent' } : { opacity: 0.55, cursor: 'not-allowed' })}>
            Reprendre
          </button>
          <button className="tap" ref={exportBtnRef} onClick={() => {
              // Journal hors dollars : on explique au lieu de proposer un export faussé.
              if (btJournalCurrency() !== 'USD') { setMode(null); setMsg({ tone: 'info', text: btExportBlockedText() }); return; }
              setMsg(null); setMode('confirmExport');
            }} disabled={!nTrades}
            title={nTrades ? undefined : 'Aucun trade clôturé à exporter'}
            style={miniBtn(!nTrades ? { opacity: 0.55, cursor: 'not-allowed' } : {})}>Exporter vers le journal</button>
          <div style={{ flex: 1 }} />
          <button className="tap" ref={deleteBtnRef} onClick={() => { setMsg(null); setMode('confirmDelete'); }} style={miniBtn({ color: 'var(--red-text)' })}>Supprimer</button>
        </div>
      )}

      {msg && <div role="status" style={{ fontSize: 11.5, lineHeight: 1.45, color: msg.tone === 'err' ? 'var(--red-text)' : msg.tone === 'info' ? 'var(--fg-2)' : 'var(--green-text)' }}>{msg.text}</div>}
    </div>
  );
}

// Bandeau d'erreur de chargement (données déjà chargées conservées).
function BtLoadBanner({ text, onRetry }) {
  return (
    <div role="alert" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', padding: '10px 14px', borderRadius: 10, background: 'var(--red-soft)', color: 'var(--red-text)', fontSize: 12.5 }}>
      <span>{text}</span>
      <button className="btn tap" onClick={onRetry} style={{ fontSize: 12.5 }}>Réessayer</button>
    </div>
  );
}

// État vide : format unique de l'application (EmptyState, core.jsx).
function BtEmptyState({ title, text, action, onAction }) {
  return (
    <div className="card">
      <EmptyState icon={Ico.backtest} title={title} text={text} compact
        primary={action && onAction ? { label: action, onClick: onAction } : null}/>
    </div>
  );
}

function BtSessionsPanel({ refreshKey, onResume, onGoReplay, onExported }) {
  const { loading, error, sessions, reload } = useBtSessions(refreshKey);

  if (loading && !sessions.length) {
    return <div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--fg-3)', fontSize: 13 }}>Chargement des sauvegardes…</div>;
  }
  const errText = 'Impossible de charger tes sauvegardes. Vérifie ta connexion.';
  if (error && !sessions.length) {
    return (
      <div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--fg-3)', fontSize: 13, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
        <span>{errText}</span>
        <button className="btn tap" onClick={reload} style={{ fontSize: 12.5 }}>Réessayer</button>
      </div>
    );
  }
  if (!sessions.length) {
    return (
      <BtEmptyState title="Aucune sauvegarde"
        text="Sauvegarde ton backtest pour le retrouver ici, le reprendre plus tard ou l’exporter vers ton journal."
        action="Ouvrir le backtest" onAction={onGoReplay} />
    );
  }
  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
      {error && <BtLoadBanner text={errText} onRetry={reload} />}
      <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
        <div style={{ padding: '12px 14px', borderBottom: '1px solid var(--line)', fontSize: 12.5, fontWeight: 600 }}>
          Sauvegardes ({fmtNum(sessions.length, 0)})
        </div>
        {sessions.map((s, i) => (
          <BtSessionRow key={s.id} row={s} isFirst={i === 0} onResume={onResume} onDeleted={reload} onExported={onExported} />
        ))}
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  TRADES DE BACKTEST (lecture backtest_trades + stats locales)
//  100 % indépendant du journal réel (table 'trades').
// ═════════════════════════════════════════════════════════════════════════
// Erreur de chargement ≠ liste vide : les trades déjà chargés sont conservés.
// Tous les trades sont lus (page par page) : les indicateurs portent sur
// l'ensemble, jamais sur les 500 ou 1 000 plus récents.
function useBacktestJournal(refreshKey) {
  const [data, setData] = React.useState({ loading: true, error: null, trades: [] });
  const [bump, setBump] = React.useState(0); // reload manuel (bouton « Réessayer »)
  React.useEffect(() => {
    let live = true;
    (async () => {
      if (!window.sb) {
        console.warn('[Tempo] trades de backtest : client indisponible.');
        setData(d => ({ ...d, loading: false, error: 'offline' }));
        return;
      }
      setData(d => ({ ...d, loading: true, error: null }));
      try {
        const res = await btFetchAll((withCount) => window.sb.from('backtest_trades')
          .select('*', withCount ? { count: 'exact' } : undefined)
          .order('exit_at', { ascending: false }).order('id', { ascending: false }));
        if (!live) return;
        if (res.error) {
          console.warn('[Tempo] chargement des trades de backtest :', res.error);
          setData(d => ({ ...d, loading: false, error: 'load' }));
          return;
        }
        setData({ loading: false, error: null, trades: res.data || [] });
      } catch (e) {
        console.warn('[Tempo] chargement des trades de backtest :', e);
        if (live) setData(d => ({ ...d, loading: false, error: 'load' }));
      }
    })();
    return () => { live = false; };
  }, [refreshKey, bump]);
  return { ...data, reload: () => setBump(b => b + 1) };
}

// Agrégats des trades de backtest. Le R moyen compte les R à 0 : dans le
// backtest, le R est calculé par le moteur (null sans SL, exclu ici) et 0 est
// une vraie sortie à break-even — même règle que la carte « Résultats »
// (btSessionStats).
function btJournalStats(trades) {
  const n = trades.length;
  if (!n) return { n: 0, wins: 0, losses: 0, winRate: null, netPnl: 0, profitFactor: null, avgR: null, hasR: false };
  let wins = 0, losses = 0, gross = 0, lossGross = 0, netPnl = 0, rSum = 0, rCount = 0;
  for (const t of trades) {
    const p = Number(t.pnl) || 0;
    netPnl += p;
    if (p > 0) { wins++; gross += p; } else if (p < 0) { losses++; lossGross += -p; }
    const r = Number(t.r_multiple);
    if (t.r_multiple != null && Number.isFinite(r)) { rSum += r; rCount++; }
  }
  const profitFactor = lossGross > 0 ? gross / lossGross : (gross > 0 ? Infinity : null);
  return {
    n, wins, losses, winRate: (wins / n) * 100, netPnl, profitFactor,
    avgR: rCount ? rSum / rCount : null, hasR: rCount > 0,
  };
}

// Colonnes du tableau, dans l'ordre de la charte (§5, Trade) : Date · Symbole
// · Sens · Entrée · Sortie · Quantité · R · P&L. En-tête et lignes partagent
// la même grille.
const BT_JOURNAL_COLS = 'minmax(118px, 1.3fr) minmax(96px, 1.2fr) 44px minmax(76px, 1fr) minmax(76px, 1fr) 64px 76px minmax(96px, auto)';
// Lignes montées dans le tableau, par tranches : la liste est dans une zone
// défilante, inutile d'y monter des milliers de lignes d'un coup. Les
// indicateurs, eux, portent toujours sur TOUS les trades.
const BT_JOURNAL_STEP = 500;

// compact (mobile) : une carte par trade au lieu du tableau à 8 colonnes, pour
// que le P&L reste visible sans défilement horizontal.
function BtJournal({ refreshKey, onGoReplay, compact }) {
  const { loading, error, trades, reload } = useBacktestJournal(refreshKey);
  const stats = React.useMemo(() => btJournalStats(trades), [trades]);
  const [shownMax, setShownMax] = React.useState(BT_JOURNAL_STEP);
  const shown = React.useMemo(() => (trades.length > shownMax ? trades.slice(0, shownMax) : trades), [trades, shownMax]);

  if (loading && !trades.length) {
    return <div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--fg-3)', fontSize: 13 }}>Chargement des trades de backtest…</div>;
  }
  const errText = 'Impossible de charger tes trades de backtest. Vérifie ta connexion.';
  if (error && !trades.length) {
    return (
      <div className="card" style={{ padding: 32, textAlign: 'center', color: 'var(--fg-3)', fontSize: 13, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
        <span>{errText}</span>
        <button className="btn tap" onClick={reload} style={{ fontSize: 12.5 }}>Réessayer</button>
      </div>
    );
  }
  if (!trades.length) {
    return (
      <BtEmptyState title="Aucun trade de backtest"
        text="Rejoue un scénario, clôture des trades, puis sauvegarde ton backtest pour les retrouver ici."
        action="Ouvrir le backtest" onAction={onGoReplay} />
    );
  }

  const nuls = stats.n - stats.wins - stats.losses;
  const Kpi = ({ label, value, sub, color }) => (
    <div className="card" style={{ padding: '14px 16px', minWidth: 0 }}>
      <div style={{ fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.02em', marginBottom: 8 }}>{label}</div>
      <div className="num" style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-.02em', color: color || 'var(--fg)', whiteSpace: 'nowrap' }}>{value}</div>
      {sub && <div className="num" style={{ fontSize: 11, color: 'var(--fg-3)', marginTop: 4 }}>{sub}</div>}
    </div>
  );
  const head = { fontSize: 10.5, fontWeight: 600, color: 'var(--fg-3)', textTransform: 'uppercase', letterSpacing: '.02em' };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
      {error && <BtLoadBanner text={errText} onRetry={reload} />}
      <div style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>Compte de backtest en USD. Ces trades restent séparés de ton journal.</div>
      <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(150px, 1fr))', gap: 12 }}>
        {/* Répartition G / P sous le taux de réussite, comme au Tableau de bord
            et dans le panneau Résultats du Backtest. */}
        <Kpi label="Trades" value={fmtNum(stats.n, 0)} />
        <Kpi label="Taux de réussite" value={fmtPct(stats.winRate, 1)}
          sub={fmtNum(stats.wins, 0) + ' G · ' + fmtNum(stats.losses, 0) + ' P' + (nuls > 0 ? ' · ' + btCount(nuls, 'nul', 'nuls') : '')} />
        <Kpi label="P&L net" value={btMoney(stats.netPnl, true)} color={btToneColor(stats.netPnl)} />
        <Kpi label="Profit factor" value={fmtPF(stats.profitFactor, stats.n)} />
        <Kpi label="R moyen" value={stats.hasR ? fmtR(stats.avgR) : '—'} color={stats.hasR ? btToneColor(stats.avgR) : undefined} />
      </div>

      <div className="card" style={{ padding: 0, overflow: 'hidden' }}>
        {compact ? (
          <div className="scroll" style={{ maxHeight: 520, overflowY: 'auto' }}>
            {shown.map((t, i) => {
              const exitUnix = t.exit_at ? Math.floor(Date.parse(t.exit_at) / 1000) : null;
              const dec = btAssetDecimals(t.asset);
              return (
                <div key={t.id || i} style={{ padding: '10px 14px', borderTop: i ? '1px solid var(--line)' : 'none', display: 'flex', flexDirection: 'column', gap: 4, fontSize: 12 }}>
                  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
                    <div style={{ display: 'flex', alignItems: 'center', gap: 8, minWidth: 0 }}>
                      <BtSideBadge side={t.side} />
                      <span style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
                        {btAssetLabel(t.asset).split(' · ')[0]} <span style={{ color: 'var(--fg-3)', fontWeight: 500 }}>{btTfLabel(t.timeframe)}</span>
                        {t.partial ? <span className="pill" title="Clôture partielle" style={{ marginLeft: 6, fontSize: 9.5, padding: '2px 6px', background: 'var(--blue-soft)', color: 'var(--blue-600)', fontWeight: 700 }}>½</span> : null}
                      </span>
                    </div>
                    <span className="num" style={{ fontWeight: 700, color: btToneColor(t.pnl), whiteSpace: 'nowrap' }}>{btMoney(t.pnl, true)}</span>
                  </div>
                  <div className="num" style={{ display: 'flex', justifyContent: 'space-between', gap: 8, fontSize: 11, color: 'var(--fg-3)' }}>
                    <span style={{ whiteSpace: 'nowrap' }}>{Number.isFinite(exitUnix) ? btTradeTimeLabel(exitUnix, t.timeframe) : '—'}</span>
                    <span style={{ whiteSpace: 'nowrap', color: 'var(--fg-2)' }}>{t.r_multiple != null ? fmtR(t.r_multiple) : 'R —'}</span>
                  </div>
                  {/* Libellé et valeur insécables : la ligne ne se coupe qu'aux « · ». */}
                  <div className="num" style={{ fontSize: 11, color: 'var(--fg-3)' }}>
                    {'Entrée' + BT_NBSP + fmtPrice(t.entry_price, dec) + ' · Sortie' + BT_NBSP + fmtPrice(t.exit_price, dec) + ' · Quantité' + BT_NBSP + fmtLots(t.qty)}
                  </div>
                </div>
              );
            })}
          </div>
        ) : (
          <div className="scroll" style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch' }}>
            <div style={{ minWidth: 740 }}>
              <div style={{ display: 'grid', gridTemplateColumns: BT_JOURNAL_COLS, columnGap: 10, padding: '10px 14px', borderBottom: '1px solid var(--line)', ...head }}>
                <span>Date</span><span>Symbole</span><span>Sens</span><span>Entrée</span><span>Sortie</span><span style={{ textAlign: 'right' }}>Quantité</span><span style={{ textAlign: 'right' }}>R</span><span style={{ textAlign: 'right' }}>P&L</span>
              </div>
              <div className="scroll" style={{ maxHeight: 420, overflowY: 'auto' }}>
                {shown.map((t, i) => {
                  const exitUnix = t.exit_at ? Math.floor(Date.parse(t.exit_at) / 1000) : null;
                  const dec = btAssetDecimals(t.asset);
                  return (
                    <div key={t.id || i} style={{ display: 'grid', gridTemplateColumns: BT_JOURNAL_COLS, columnGap: 10, padding: '10px 14px', borderTop: i ? '1px solid var(--line)' : 'none', alignItems: 'center', fontSize: 12 }}>
                      {/* Date de sortie du trade (bougie simulée), fuseau du profil. */}
                      <span className="num" style={{ color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{Number.isFinite(exitUnix) ? btTradeTimeLabel(exitUnix, t.timeframe) : '—'}</span>
                      <span style={{ fontWeight: 600, whiteSpace: 'nowrap' }}>
                        {btAssetLabel(t.asset).split(' · ')[0]} <span style={{ color: 'var(--fg-3)', fontWeight: 500 }}>{btTfLabel(t.timeframe)}</span>
                        {t.partial ? <span className="pill" title="Clôture partielle" style={{ marginLeft: 6, fontSize: 9.5, padding: '2px 6px', background: 'var(--blue-soft)', color: 'var(--blue-600)', fontWeight: 700 }}>½</span> : null}
                      </span>
                      <BtSideBadge side={t.side} />
                      <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtPrice(t.entry_price, dec)}</span>
                      <span className="mono" style={{ fontSize: 11.5, color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtPrice(t.exit_price, dec)}</span>
                      <span className="mono" style={{ fontSize: 11.5, textAlign: 'right', color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtLots(t.qty)}</span>
                      <span className="num" style={{ textAlign: 'right', color: 'var(--fg-2)', whiteSpace: 'nowrap' }}>{fmtR(t.r_multiple)}</span>
                      <span className="num" style={{ textAlign: 'right', fontWeight: 700, color: btToneColor(t.pnl), whiteSpace: 'nowrap' }}>{btMoney(t.pnl, true)}</span>
                    </div>
                  );
                })}
              </div>
            </div>
          </div>
        )}
        {trades.length > shown.length && (
          <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', padding: '10px 14px', borderTop: '1px solid var(--line)' }}>
            <span className="num" style={{ fontSize: 11.5, color: 'var(--fg-3)' }}>
              {fmtNum(shown.length, 0) + ' trades affichés sur ' + fmtNum(trades.length, 0) + ', les plus récents d’abord. Les indicateurs portent sur tous les trades.'}
            </span>
            <button className="btn tap" onClick={() => setShownMax(m => m + BT_JOURNAL_STEP)} style={{ fontSize: 12.5 }}>Afficher plus</button>
          </div>
        )}
      </div>
    </div>
  );
}

// ── Onglets Backtest / Sauvegardes / Trades de backtest ─────────────────────
function BtTabs({ tab, setTab, compact }) {
  const tabs = [
    { id: 'replay', label: 'Backtest' },
    { id: 'sessions', label: 'Sauvegardes' },
    { id: 'journal', label: 'Trades de backtest' },
  ];
  return <SegmentedTabs options={tabs} value={tab} onChange={setTab} size={compact ? 'sm' : 'md'} ariaLabel="Sections du backtest" idPrefix="bt" />;
}

// Panneau de l'onglet choisi, relié à son onglet (aria-controls posé par
// SegmentedTabs avec idPrefix="bt"). Les panneaux des deux autres onglets
// existent, vides et masqués (TabPanelStubs, core.jsx). Simple bloc : la mise
// en page du contenu ne change pas.
const BT_TAB_IDS = [{ id: 'replay' }, { id: 'sessions' }, { id: 'journal' }];
function BtTabPanel({ tab, children }) {
  return (
    <React.Fragment>
      <TabPanelStubs idPrefix="bt" options={BT_TAB_IDS} value={tab} />
      <div role="tabpanel" id={'bt-panel-' + tab} aria-labelledby={'bt-tab-' + tab}>{children}</div>
    </React.Fragment>
  );
}

// ── Confirmation en ligne avant remise à zéro (nouveau départ, changement
// d'actif, reprise d'une sauvegarde) quand du travail n'est pas sauvegardé.
// Modèle : confirmation de suppression des sauvegardes. Échap = Annuler.
function BtResetConfirm({ page }) {
  const req = page.pendingReset;
  const confirmRef = React.useRef(null);
  React.useEffect(() => { if (req && confirmRef.current) confirmRef.current.focus(); }, [req]);
  if (!req) return null;
  const bt = page.bt;
  // Déjà sauvegardé une fois : seules les modifications suivantes sont perdues.
  const loss = page.everSaved
    ? 'Les modifications faites depuis ta dernière sauvegarde seront perdues.'
    : btLossSentence(bt.closedTrades.length, bt.positions.length, (bt.pendingOrders || []).length);
  let question, action;
  if (req.kind === 'asset') {
    question = 'Passer à ' + btAssetLabel(req.payload) + ' ?';
    action = 'Changer d’actif';
  } else if (req.kind === 'start') {
    question = 'Revenir au début de l’historique ?';
    action = 'Revenir au début';
  } else if (req.kind === 'resume') {
    const nm = (req.payload && req.payload.name) || (req.payload ? btAssetLabel(req.payload.asset) + ' ' + btTfLabel(req.payload.timeframe) : '');
    question = 'Reprendre « ' + nm + ' » ?';
    action = 'Reprendre';
  } else {
    question = 'Repartir de zéro ?';
    action = 'Nouveau départ';
  }
  const btn = { borderRadius: 8, padding: '7px 12px', fontSize: 12.5, fontWeight: 600, cursor: 'pointer' };
  return (
    <div role="alertdialog" aria-label={question} style={{
      display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 14,
      padding: '12px 14px', borderRadius: 12, border: '1px solid var(--line-2)', background: 'var(--bg-elev)',
    }}>
      <span style={{ fontSize: 12.5, color: 'var(--fg)', flex: '1 1 260px', lineHeight: 1.45 }}>
        <b>{question}</b> {loss}
      </span>
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
        <button ref={confirmRef} className="tap" onClick={page.confirmReset}
          style={{ ...btn, background: 'var(--sell-bg, #dc2626)', color: '#fff', border: '1px solid var(--sell-bg, #dc2626)' }}>{action}</button>
        <button className="tap" onClick={page.cancelReset}
          style={{ ...btn, background: 'var(--bg-card)', color: 'var(--fg)', border: '1px solid var(--line)' }}>Annuler</button>
      </div>
    </div>
  );
}

// ── Spinner de chargement des bougies ───────────────────────────────────────
function BtLoading({ label }) {
  return (
    <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 14, height: '100%', minHeight: 280, color: 'var(--fg-3)' }}>
      <div style={{ width: 30, height: 30, borderRadius: '50%', border: '3px solid var(--line)', borderTopColor: 'var(--blue)', animation: 'bt-spin .8s linear infinite' }} />
      <span style={{ fontSize: 13 }}>{label || 'Chargement des bougies…'}</span>
      <style>{'@keyframes bt-spin{to{transform:rotate(360deg)}}'}</style>
    </div>
  );
}

// Message d'information au-dessus du graphique (historique d'une unité de
// temps qui ne couvre pas la date du replay). Fermable ; effacé de lui-même au
// prochain changement d'actif, d'unité de temps ou nouveau départ.
function BtNotice({ notice, onClose }) {
  if (!notice) return null;
  return (
    <div style={{
      display: 'flex', alignItems: 'flex-start', gap: 10, padding: '10px 12px', borderRadius: 10,
      border: '1px solid var(--line-2)', background: 'var(--bg-elev)', color: 'var(--fg)', fontSize: 12.5, lineHeight: 1.45,
    }}>
      <span role="status" style={{ flex: 1, minWidth: 0 }}>{notice.text}</span>
      <button type="button" className="tap tap-44" onClick={onClose} aria-label="Fermer" title="Fermer" style={{
        width: 24, height: 24, flexShrink: 0, borderRadius: 6, border: 'none', background: 'transparent',
        color: 'var(--fg-2)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
      }}>{Ico.x}</button>
    </div>
  );
}

// Titre de la page : table unique NAV_LABELS (core.jsx, charte §5).
function btPageTitle() {
  const nav = (typeof NAV_LABELS !== 'undefined' && NAV_LABELS) || window.NAV_LABELS;
  return (nav && nav.backtest && nav.backtest.titre) || 'Backtest';
}

// Échec de chargement des bougies : message + « Réessayer ».
function BtBarsError({ onRetry }) {
  return (
    <div role="alert" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12, height: '100%', padding: 16, textAlign: 'center', color: 'var(--fg-3)', fontSize: 13 }}>
      <span>Données indisponibles pour cet actif et cette unité de temps.</span>
      <button className="btn tap" onClick={onRetry} style={{ fontSize: 12.5 }}>Réessayer</button>
    </div>
  );
}

// Actions communes aux deux mises en page (ordinateur et mobile).
function useBtPageActions(page, setTab) {
  const [journalKey, setJournalKey] = React.useState(0);
  const [sessionsKey, setSessionsKey] = React.useState(0);
  // Sauvegarde réussie : sig = signature du travail sauvegardé (null si la
  // sauvegarde n'est que partielle, le travail reste alors « non sauvegardé »).
  const onSaved = (session_id, name, sig) => {
    page.setSessionId(session_id);
    page.setSessionName(name);
    if (sig) page.markSaved(sig);
    setJournalKey(k => k + 1);
    setSessionsKey(k => k + 1);
  };
  return {
    journalKey, sessionsKey, onSaved,
    newStart: () => page.requestReset({ kind: 'new' }),
    goStart: () => page.requestReset({ kind: 'start' }),
    changeAsset: (id) => page.requestReset({ kind: 'asset', payload: id }),
    resume: (row) => page.requestReset({ kind: 'resume', payload: row, then: () => setTab('replay') }),
    goReplay: () => setTab('replay'),
  };
}

// ═════════════════════════════════════════════════════════════════════════
//  PAGE DESKTOP
// ═════════════════════════════════════════════════════════════════════════
function BacktestPage({ state }) {
  const page = useBacktestPage(state && state.user && state.user.id);
  const { assetId, tf, setTf, loading, loadError, decimals, bt, chartReady, frozen } = page;
  const { theme } = (typeof useTheme === 'function') ? useTheme() : { theme: 'light' };
  const [tab, setTab] = React.useState('replay');
  const act = useBtPageActions(page, setTab);

  return (
    <div style={{ width: '100%', height: '100%', overflow: 'auto', display: 'flex', flexDirection: 'column' }} className="scroll">
      <PageHeader title={btPageTitle()} />
      <div style={{ ...PAGE_BODY, padding: '18px 24px 40px', flex: 1 }}>
        {/* Onglets en tête, toujours à la même place (comme sur mobile). Les
            réglages du replay (actif, unité de temps, nouveau départ) ne
            s'affichent que dans l'onglet Backtest : ailleurs, ils auraient
            l'air de filtrer la liste sans la filtrer. */}
        <div style={{ marginBottom: tab === 'replay' ? 12 : 16 }}>
          <BtTabs tab={tab} setTab={setTab} />
        </div>
        {tab === 'replay' && (
          <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', marginBottom: 16 }}>
            <BtAssetSelect value={assetId} onChange={act.changeAsset} />
            <BtTfButtons value={tf} onChange={setTf} />
            <button className="btn tap" onClick={act.newStart} title="Réinitialise le compte et repart d’un point aléatoire de l’historique">
              <span style={{ display: 'flex' }}>{Ico.refresh}</span> Nouveau départ
            </button>
          </div>
        )}

        <BtResetConfirm page={page} />

        <BtTabPanel tab={tab}>
        {tab === 'replay' ? (
          <div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 340px', gap: 16, alignItems: 'start' }}>
            {/* Colonne gauche : graphique + replay */}
            <div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
              <BtNotice notice={page.notice} onClose={page.dismissNotice} />
              <div className="card" style={{ padding: 12 }}>
                <div style={{ position: 'relative', height: '60vh', minHeight: 360 }}>
                  {/* Le graphique n'est monté qu'une fois les bougies chargées ET
                      positionnées par le moteur (chartReady) : jamais sur une
                      seule bougie ni sur une tranche fausse des nouvelles bougies. */}
                  {loadError && !loading
                    ? <BtBarsError onRetry={page.reloadBars} />
                    : !chartReady
                      ? <BtLoading />
                      : <BacktestAMChart bars={bt.visibleBars} timeframe={tf}
                                         assetId={assetId}
                                         assetLabel={btAssetLabel(assetId)}
                                         theme={theme} positions={bt.positions}
                                         pendingOrders={bt.pendingOrders}
                                         closedTrades={bt.closedTrades} decimals={decimals} />}
                </div>
              </div>
              {chartReady && (
                <div className="card" style={{ padding: 14 }}>
                  <BtReplayBar bt={bt} tf={tf} onStart={act.goStart} />
                </div>
              )}
            </div>

            {/* Colonne droite : compte + ordre + attente + positions + résultats + sauvegarde.
                Elle reste en place pendant un chargement de bougies (pas de saut
                de mise en page), mais ordres, clôtures et sauvegarde sont
                neutralisés (frozen). */}
            <div style={{ display: 'flex', flexDirection: 'column', gap: 14, minWidth: 0 }}>
              <BtAccountStrip bt={bt} />
              <BtOrderPanel bt={bt} decimals={decimals} assetId={assetId} execCfg={page.execCfg} setExecCfg={page.setExecCfg} frozen={frozen} />
              <BtPendingList bt={bt} decimals={decimals} frozen={frozen} />
              <BtPositionsList bt={bt} decimals={decimals} frozen={frozen} />
              <BtSessionStatsCard bt={bt} />
              <BtSaveButton assetId={assetId} tf={tf} bt={bt} sessionId={page.sessionId} sessionName={page.sessionName} resetKey={page.resetKey} onSaved={act.onSaved} frozen={frozen} />
            </div>
          </div>
        ) : tab === 'sessions' ? (
          <BtSessionsPanel refreshKey={act.sessionsKey} onResume={act.resume} onGoReplay={act.goReplay}
          onExported={state && typeof state.refreshTrades === 'function' ? state.refreshTrades : undefined} />
        ) : (
          <BtJournal refreshKey={act.journalKey} onGoReplay={act.goReplay} />
        )}
        </BtTabPanel>
      </div>
    </div>
  );
}

// ═════════════════════════════════════════════════════════════════════════
//  PAGE MOBILE (version simplifiée mais fonctionnelle)
// ═════════════════════════════════════════════════════════════════════════
function BacktestMobile({ state }) {
  const page = useBacktestPage(state && state.user && state.user.id);
  const { assetId, tf, setTf, loading, loadError, decimals, bt, chartReady, frozen } = page;
  const { theme } = (typeof useTheme === 'function') ? useTheme() : { theme: 'light' };
  const [tab, setTab] = React.useState('replay');
  const act = useBtPageActions(page, setTab);

  return (
    <div style={{ padding: '14px 16px', paddingBottom: 'calc(28px + env(safe-area-inset-bottom, 0px))' }}>
      {/* Hauteur du graphique consciente du viewport dynamique iOS (dvh quand
          supporté, repli vh sinon) — évite le graphique coupé par la barre
          d'adresse Safari. */}
      <style>{'.bt-chart-m{position:relative;height:42vh;min-height:260px}@supports(height:100dvh){.bt-chart-m{height:42dvh}}'}</style>
      <h1 style={{ fontSize: 24, fontWeight: 700, letterSpacing: '-.025em', margin: '0 0 14px' }}>{btPageTitle()}</h1>

      <div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch', margin: '0 -16px 12px', padding: '0 16px' }} className="scroll">
        <div style={{ width: 'max-content' }}><BtTabs tab={tab} setTab={setTab} compact /></div>
      </div>

      <BtResetConfirm page={page} />

      <BtTabPanel tab={tab}>
      {tab === 'replay' ? (
        <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap' }}>
            <BtAssetSelect value={assetId} onChange={act.changeAsset} />
            <button className="btn tap" onClick={act.newStart} title="Réinitialise le compte et repart d’un point aléatoire de l’historique">
              <span style={{ display: 'flex' }}>{Ico.refresh}</span> Nouveau départ
            </button>
          </div>
          <div style={{ overflowX: 'auto', WebkitOverflowScrolling: 'touch', margin: '0 -16px', padding: '0 16px' }} className="scroll">
            <div style={{ width: 'max-content' }}><BtTfButtons value={tf} onChange={setTf} /></div>
          </div>

          <BtNotice notice={page.notice} onClose={page.dismissNotice} />

          <div className="card" style={{ padding: 8 }}>
            <div className="bt-chart-m">
              {loadError && !loading
                ? <BtBarsError onRetry={page.reloadBars} />
                : !chartReady
                  ? <BtLoading />
                  : <BacktestAMChart bars={bt.visibleBars} timeframe={tf}
                                     assetId={assetId}
                                     assetLabel={btAssetLabel(assetId)}
                                     theme={theme} positions={bt.positions}
                                     pendingOrders={bt.pendingOrders}
                                     closedTrades={bt.closedTrades} decimals={decimals} />}
            </div>
          </div>

          {chartReady && (
            <>
              <div className="card" style={{ padding: 12 }}><BtReplayBar bt={bt} tf={tf} onStart={act.goStart} /></div>
              <BtAccountStrip bt={bt} />
              <BtOrderPanel bt={bt} decimals={decimals} assetId={assetId} execCfg={page.execCfg} setExecCfg={page.setExecCfg} frozen={frozen} />
              <BtPendingList bt={bt} decimals={decimals} frozen={frozen} />
              <BtPositionsList bt={bt} decimals={decimals} frozen={frozen} />
              <BtSessionStatsCard bt={bt} />
              <BtSaveButton assetId={assetId} tf={tf} bt={bt} sessionId={page.sessionId} sessionName={page.sessionName} resetKey={page.resetKey} onSaved={act.onSaved} frozen={frozen} />
            </>
          )}
        </div>
      ) : tab === 'sessions' ? (
        <BtSessionsPanel refreshKey={act.sessionsKey} onResume={act.resume} onGoReplay={act.goReplay}
          onExported={state && typeof state.refreshTrades === 'function' ? state.refreshTrades : undefined} />
      ) : (
        <BtJournal refreshKey={act.journalKey} onGoReplay={act.goReplay} compact />
      )}
      </BtTabPanel>
    </div>
  );
}

Object.assign(window, { BacktestPage, BacktestMobile });
