// Tempo — Backtest (mode Replay) : graphique amCharts 5 Stock Chart
// ─────────────────────────────────────────────────────────────────────────
// Graphique du mode « Replay & trades » (les anciens graphiques KLineCharts,
// LightweightCharts et TradingView ont été retirés du dépôt).
// amCharts 5 Stock Chart apporte une barre d'outils intégrée : INDICATEURS
// (moyennes, RSI, MACD, Bollinger…) et DESSIN (lignes, Fibonacci, rectangles,
// texte…), listes restreintes et traduites (BT_AM_*). On conserve :
// le replay bougie-par-bougie (setAll), les lignes d'ordres (entrée / SL / TP),
// le thème clair/sombre, la locale FR.
//
// CDN à charger AVANT ce fichier (dans cet ordre), voir notes d'intégration :
//   https://cdn.amcharts.com/lib/version/5.21.0/index.js
//   https://cdn.amcharts.com/lib/version/5.21.0/xy.js
//   https://cdn.amcharts.com/lib/version/5.21.0/stock.js
//   https://cdn.amcharts.com/lib/version/5.21.0/themes/Animated.js
//   https://cdn.amcharts.com/lib/version/5.21.0/themes/Dark.js
//   https://cdn.amcharts.com/lib/version/5.21.0/locales/fr_FR.js
// → exposent : am5, am5xy, am5stock, am5themes_Animated, am5themes_Dark,
//   am5locales_fr_FR.
//
// Composant exposé (DROP-IN du chart Replay) :
//   BacktestAMChart({ bars, timeframe, assetLabel, assetId, theme, positions,
//                     pendingOrders, closedTrades, decimals })
//
// Props (moteur v2) :
//   bars          : bougies visibles { time:s, open, high, low, close, volume }
//   timeframe     : '1m'…'1d' → baseInterval (mis à jour SANS recréer le chart)
//   assetLabel    : libellé de l'actif (nom de la série)
//   assetId       : identifiant STABLE de l'actif — quand il change, le chart
//                   est détruit puis recréé proprement (root.dispose + toolbar
//                   vidée). Si absent, assetLabel sert de clé de secours.
//   theme         : 'light' | 'dark' → recréation du chart (thème amCharts)
//   positions     : positions OUVERTES [{id, side, qty, entry, sl, tp}] →
//                   lignes de niveaux (entrée bleue continue, SL rouge, TP vert)
//   pendingOrders : ordres EN ATTENTE [{id, type:'limit'|'stop', side, qty, price}]
//                   → ligne bleue pointillée + étiquette « Achat limite 1 · … »
//   closedTrades  : trades clôturés [{side, qty, entry, exit, entryTime,
//                   exitTime, pnl, …}] → marqueurs discrets (triangle à
//                   l'entrée, point à la sortie, infobulle sens, quantité,
//                   prix et P&L en USD)
//   decimals      : décimales de l'actif (numberFormat + labels de niveaux)
//
// Charte (docs/charte-ui.md) : tout le graphique est en français (barre
// d'outils, indicateurs, dessin, fenêtre de réglages, légende O H B C), les
// dates sont dans le fuseau du profil (root.timezone = window.__appTz), les
// mois viennent d'Intl (« 5 févr. »), les nombres de format-prefs.jsx
// (fmtPrice, fmtLots) et les montants du compte de backtest sont en USD
// (fmtMoneyCur(v, 'USD')).
// ─────────────────────────────────────────────────────────────────────────

// ── Français : libellés du Stock Chart amCharts ──────────────────────────
// La locale fr_FR d'amCharts ne couvre ni la barre d'outils (Indicators,
// Draw, Clear…), ni les indicateurs, ni la fenêtre de réglages. amCharts fait
// passer tous ces libellés par root.language.translateAny : on complète donc
// la locale. Seuls les indicateurs et outils proposés (listes plus bas) sont
// traduits ici, avec les libellés de leur fenêtre de réglages.
const BT_AM_FR = {
  // Barre d'outils et listes
  'Indicators': 'Indicateurs',
  'Draw': 'Dessin',
  'Search': 'Rechercher',
  'Settings': 'Réglages',
  'Close': 'Fermer',
  'Zoom Out': 'Dézoomer',
  'Selection': 'Choix',
  // Consignes lues par les lecteurs d'écran (ajoutées au nom des boutons)
  'Press ENTER or use arrow keys to navigate': 'Entrée pour ouvrir, flèches pour parcourir',
  'Press ENTER to toggle': 'Entrée pour activer ou désactiver',
  'Press ENTER to open': 'Entrée pour ouvrir',
  // Indicateurs (nom complet, puis nom court affiché dans la légende)
  'Moving Average': 'Moyenne mobile',
  'MA': 'MM',
  'Moving Average Cross': 'Croisement de moyennes mobiles',
  'MACross': 'Croisement MM',
  'Bollinger Bands': 'Bandes de Bollinger',
  'Bollinger': 'Bollinger',
  'Relative Strength Index': 'RSI (force relative)',
  'RSI': 'RSI',
  'MACD': 'MACD',
  'Stochastic Oscillator': 'Oscillateur stochastique',
  'Stochastic Osc': 'Stochastique',
  'Average True Range': 'ATR (amplitude moyenne)',
  'ATR': 'ATR',
  // Fenêtre de réglages d'un indicateur ou de la série
  'Period': 'Période',
  'Offset': 'Décalage',
  'Color': 'Couleur',
  'Field': 'Prix utilisé',
  'Type': 'Type',
  'Upper': 'Bande haute',
  'Average': 'Moyenne',
  'Lower': 'Bande basse',
  'Deviation': 'Écarts types',
  'SMA period': 'Période de la moyenne',
  'K period': 'Lissage %K',
  'Fast period': 'Période rapide',
  'Slow period': 'Période lente',
  'Fast MA period': 'Période rapide',
  'Slow MA period': 'Période lente',
  'Signal period': 'Période du signal',
  'Signal': 'Signal',
  'Overbought': 'Surachat',
  'Oversold': 'Survente',
  'Increasing': 'Hausse',
  'Decreasing': 'Baisse',
  'Increase': 'Hausse',
  'Decrease': 'Baisse',
  'Reset to default': 'Valeurs par défaut',
  'Save': 'Enregistrer',
  'Cancel': 'Annuler',
  // Dessin
  'Drawing tool': 'Outil de dessin',
  'Line': 'Ligne',
  'Trend Line': 'Ligne de tendance',
  'Horizontal Line': 'Ligne horizontale',
  'Horizontal Ray': 'Demi-droite horizontale',
  'Vertical Line': 'Ligne verticale',
  'Line Arrow': 'Flèche',
  'Rectangle': 'Rectangle',
  'Ellipse': 'Ellipse',
  'Parallel Channel': 'Canal parallèle',
  'Fibonacci': 'Retracement de Fibonacci',
  'Label': 'Texte',
  'Doodle': 'Main levée',
  'Arrows &amp; Icons': 'Flèches et icônes',
  'Snap icon to data': 'Aimanter l’icône aux bougies',
  'Line color': 'Couleur de la ligne',
  'Line thickness': 'Épaisseur de la ligne',
  'Line style': 'Style de la ligne',
  'Fill': 'Fond',
  'Fill color': 'Couleur du fond',
  'Text': 'Texte',
  'Text color': 'Couleur du texte',
  'Label font size': 'Taille du texte',
  'Bold': 'Gras',
  'Italic': 'Italique',
  'Label font family': 'Police',
  'Show line extension': 'Prolonger la ligne',
  'Select': 'Sélectionner',
  'Eraser': 'Gomme',
  'Clear': 'Tout effacer',
  'Clear all drawings': 'Effacer tous les dessins',
  // Nombres : espace fine insécable pour les milliers (« 5 278,50 »)
  '_thousandSeparator': ' ',
  // Dates (fuseau du profil via root.timezone) : « 14:30 », « 5 févr. »,
  // « févr. 2026 », infobulle « 5 févr. 2026 · 14:30 » (comme fmtDateTime).
  '_date_minute': 'HH:mm',
  '_date_minute_full': 'd MMM yyyy · HH:mm',
  '_date_hour': 'HH:mm',
  '_date_hour_full': 'd MMM yyyy · HH:mm',
  '_date_day': 'd MMM',
  '_date_day_full': 'd MMM yyyy',
  '_date_week': 'd MMM',
  '_date_week_full': 'd MMM yyyy',
  '_date_month': 'MMM',
  '_date_month_full': 'MMMM yyyy',
  '_date_year': 'yyyy',
};

// Indicateurs proposés (identifiants amCharts, noms français dans BT_AM_FR).
// Les indicateurs de volume ne sont pas proposés : les données n'ont pas de
// série de volume.
const BT_AM_INDICATORS = [
  'Moving Average', 'Moving Average Cross', 'Bollinger Bands',
  'Relative Strength Index', 'MACD', 'Stochastic Oscillator', 'Average True Range',
];

// Outils de dessin proposés (identifiants amCharts, noms dans BT_AM_FR).
const BT_AM_TOOLS = [
  'Line', 'Trend Line', 'Horizontal Line', 'Horizontal Ray', 'Vertical Line',
  'Line Arrow', 'Rectangle', 'Ellipse', 'Parallel Channel', 'Fibonacci',
  'Label', 'Doodle', 'Arrows &amp; Icons',
];

// Choix des listes déroulantes de la fenêtre de réglages : amCharts affiche la
// valeur brute (« close », « simple ») ; on la remplace par un libellé.
const BT_AM_FR_OPTIONS = {
  'open': 'Ouverture',
  'close': 'Clôture',
  'high': 'Plus haut',
  'low': 'Plus bas',
  'hl/2': '(H + B) / 2',
  'hlc/3': '(H + B + C) / 3',
  'hlcc/4': '(H + B + C + C) / 4',
  'ohlc/4': '(O + H + B + C) / 4',
  'simple': 'Simple',
  'weighted': 'Pondérée',
  'exponential': 'Exponentielle',
  'dema': 'Double exponentielle (DEMA)',
  'tema': 'Triple exponentielle (TEMA)',
};

// Clés amCharts des mois et des jours (ce ne sont pas des libellés affichés) :
// leurs traductions viennent d'Intl via format-prefs.jsx (FR_MONTHS_*,
// FR_WEEKDAYS_*, semaines du lundi au dimanche).
const BT_AM_MONTH_KEYS = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const BT_AM_MONTH_SHORT_KEYS = ['Jan', 'Feb', 'Mar', 'Apr', 'May(short)', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
const BT_AM_DAY_KEYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const BT_AM_DAY_SHORT_KEYS = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];

// Locale complète du graphique : fr_FR d'amCharts + BT_AM_FR + mois et jours.
function _amLocaleFr() {
  const loc = Object.assign({}, window.am5locales_fr_FR || {}, BT_AM_FR);
  const put = (keys, values) => {
    if (!Array.isArray(values) || values.length !== keys.length) return;
    keys.forEach((k, i) => { loc[k] = values[i]; });
  };
  put(BT_AM_MONTH_KEYS, window.FR_MONTHS_LONG);
  put(BT_AM_MONTH_SHORT_KEYS, window.FR_MONTHS_SHORT);
  put(BT_AM_DAY_KEYS, window.FR_WEEKDAYS_LONG);
  put(BT_AM_DAY_SHORT_KEYS, window.FR_WEEKDAYS_SHORT);
  return loc;
}

// Traduit les choix des listes déroulantes avant que la fenêtre de réglages ne
// les affiche (événement « initstarted »). Idempotent : un choix déjà traduit
// est un objet { value, text } que la table ne connaît plus.
function _amFrSettingOptions(settings) {
  if (!Array.isArray(settings)) return;
  settings.forEach((s) => {
    if (!s || s.type !== 'dropdown' || !Array.isArray(s.options)) return;
    s.options = s.options.map((o) => {
      if (typeof o === 'string' && BT_AM_FR_OPTIONS[o]) return { value: o, text: BT_AM_FR_OPTIONS[o] };
      if (o && typeof o === 'object' && BT_AM_FR_OPTIONS[o.text]) return Object.assign({}, o, { text: BT_AM_FR_OPTIONS[o.text] });
      return o;
    });
  });
}

// Légendes des indicateurs. Celles d'amCharts écrivent « MA (20,close,simple,0) »
// (valeurs brutes en anglais, virgules qu'on lit comme des décimales) et
// arrondissent tout à 2 décimales (une moyenne mobile d'EUR/USD à « 1,07 »).
// On garde les réglages chiffrés, séparés par « · », et les valeurs exprimées
// en prix prennent les décimales de l'actif. Les étiquettes de règle sont
// celles du thème par défaut du Stock Chart, pour le remplacer à l'identique.
function _amFrTheme(root, decimals) {
  const theme = window.am5.Theme.new(root);
  const n = (k) => '{' + k + ".formatNumber('#.')}";
  const p = (k) => '{' + k + ".formatNumber('" + _amNumberFormat(decimals) + "')}";
  const b = (color, v) => '[{' + color + '} bold]' + v + '[/]';
  const rule = (tags, settings) => {
    try { theme.rule('LineSeries', tags).setAll(settings); } catch (e) {}
  };
  rule(['movingaverage'], {
    legendLabelText: '{shortName} ' + n('period'),
    legendValueText: b('seriesColor', p('valueY')),
  });
  rule(['indicator', 'macross'], {
    legendLabelText: '{shortName} ' + n('period') + ' · ' + n('fastPeriod'),
    legendValueText: b('seriesColor', p('valueY')) + ' ' + b('fastColor', p('maf')),
  });
  rule(['indicator', 'bollingerbands'], {
    legendLabelText: '{shortName} ' + n('period') + ' · ' + "{standardDeviations.formatNumber('#.#')}",
    legendValueText: b('lowerColor', p('lower')) + ' ' + b('seriesColor', p('valueY')) + ' ' + b('upperColor', p('upper')),
  });
  rule(['rsi'], {
    legendLabelText: '{shortName} ' + n('period') + ' · ' + n('smaPeriod'),
  });
  rule(['macd'], {
    legendLabelText: '{shortName} ' + n('fastPeriod') + ' · ' + n('slowPeriod') + ' · ' + n('signalPeriod'),
    legendValueText: b('seriesColor', p('valueY')) + ' ' + b('signalColor', p('signal')) + ' ' + b('differenceColor', p('difference')),
  });
  rule(['stochastic'], {
    legendLabelText: '{shortName} ' + n('period') + ' · ' + n('kSmoothing') + ' · ' + n('dSmoothing'),
  });
  rule(['averagetruerange'], {
    legendLabelText: '{shortName} ' + n('period'),
    legendValueText: b('seriesColor', p('valueY')),
  });
  // Bouton « Dézoomer » : le thème par défaut d'amCharts fixe son nom à la
  // création du Root, AVANT que la locale ne soit posée (il resterait
  // « Zoom Out »).
  try { theme.rule('Button', ['zoom']).setAll({ ariaLabel: root.language.translateAny('Zoom Out') }); } catch (e) {}
  return theme;
}

// Légende de la série de prix : O H B C (ouverture, plus haut, plus bas,
// clôture) aux décimales de l'actif. Sur une ligne, ou sur 2 ou 4 lignes
// quand le graphique est étroit (mobile) : aucun prix n'est coupé au bord.
// Les retours à la ligne sont explicites : le retour automatique d'amCharts
// coupe à chaque espace, y compris entre la lettre et le prix, ou dans
// « 5 278,50 ».
function _amOhlcLegend(decimals, lines) {
  const f = (k) => '{' + k + ".formatNumber('" + _amNumberFormat(decimals) + "')}";
  const parts = ['O ' + f('openValueY'), 'H ' + f('highValueY'), 'B ' + f('lowValueY'), 'C ' + f('valueY')];
  if (lines === 4) return parts.join('\n');
  if (lines === 2) return parts[0] + '  ' + parts[1] + '\n' + parts[2] + '  ' + parts[3];
  return parts.join('  ');
}

// Nombre de lignes de la légende O H B C selon la largeur de la zone de tracé
// (px) : la ligne complète demande environ 430 px (pastille, nom de l'actif,
// quatre prix, bouton de réglages), deux prix par ligne environ 280 px.
function _amOhlcLegendLines(plotWidth) {
  if (!(plotWidth > 0)) return 1;
  if (plotWidth < 300) return 4;
  if (plotWidth < 460) return 2;
  return 1;
}

// ── Fuseau du profil ──────────────────────────────────────────────────────
// Même règle que le reste de l'app : window.__appTz, sinon Paris. Jamais le
// fuseau du navigateur.
const _amTzOk = {};
function _amTz() {
  const tz = (typeof window !== 'undefined' && window.__appTz) || 'Europe/Paris';
  if (!(tz in _amTzOk)) {
    try { new Intl.DateTimeFormat('fr-FR', { timeZone: tz }); _amTzOk[tz] = true; } catch (e) { _amTzOk[tz] = false; }
  }
  return _amTzOk[tz] ? tz : 'Europe/Paris';
}

// Instant affiché pour une bougie (en ms). En intraday, c'est l'instant réel :
// l'axe l'affiche dans le fuseau du profil (root.timezone). Une bougie
// journalière est une date civile, horodatée par la source à minuit Londres
// (23:00 UTC la veille en heure d'été, 00:00 UTC le jour même en heure
// d'hiver), à minuit New York (04:00 ou 05:00 UTC) ou, pour une séance
// écourtée de l'or et des indices, en cours de journée (13:30 ou 14:30 UTC) :
// projetée telle quelle, elle s'afficherait la veille dans un fuseau à l'ouest
// de Londres. On la place donc à minuit de sa date dans le fuseau du profil.
// Sa date est donnée par btDailyBarDay (backtest-data.jsx), la même règle que
// le libellé de la bougie courante.
const _amDayMsCache = {};
function _amBarMs(sec, timeframe, tz) {
  const ms = sec * 1000;
  if (timeframe !== '1d') return ms;
  const day = btDailyBarDay(sec);
  const key = tz + '|' + day;
  if (key in _amDayMsCache) return _amDayMsCache[key];
  let out = ms;
  try {
    const iso = typeof zonedToUtcISO === 'function' ? zonedToUtcISO(day, '00:00', tz) : null;
    const t = iso ? Date.parse(iso) : NaN;
    if (Number.isFinite(t)) out = t;
  } catch (e) {}
  _amDayMsCache[key] = out;
  return out;
}

// ── Lecture des CSS vars du thème ─────────────────────────────────────────
function _amCssColor(name, fallback) {
  try {
    const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
    return v || fallback;
  } catch (e) {
    return fallback;
  }
}

// Palette du chart dérivée des CSS vars (re-calculée à chaque (re)thème).
function _amPalette() {
  return {
    up:     _amCssColor('--green', '#16a34a'),
    down:   _amCssColor('--red', '#dc2626'),
    blue:   _amCssColor('--blue', '#3b82f6'),
    text:   _amCssColor('--fg-3', '#64748b'),
    fg:     _amCssColor('--fg', '#0f172a'),
    grid:   _amCssColor('--line', '#e2e8f0'),
    bg:     _amCssColor('--bg-card', '#ffffff'),
  };
}

// Convertit nos bougies { time:s } → format amCharts { Date:ms, OHLCV }.
function _amConvert(bars, timeframe, tz) {
  if (!Array.isArray(bars)) return [];
  const out = [];
  for (const b of bars) {
    if (!b || !Number.isFinite(b.time)) continue;
    out.push({
      Date:   _amBarMs(b.time, timeframe, tz),
      Open:   b.open,
      High:   b.high,
      Low:    b.low,
      Close:  b.close,
      Volume: b.volume != null ? b.volume : 0,
    });
  }
  return out;
}

// Fenêtre d'affichage façon FX Replay : on n'envoie pas les milliers de bougies
// de l'historique d'un coup (chart écrasé, inutilisable), mais une queue récente,
// et on zoome sur les ~BT_AM_WINDOW dernières — le reste reste pannable/zoomable.
const BT_AM_KEEP = 1500;   // bougies envoyées au chart (contexte pour paner en arrière)
const BT_AM_WINDOW = 160;  // bougies visibles par défaut
// Au-delà de ce nombre de bougies nouvelles d'un coup (saut à une date,
// curseur), on renvoie toute la fenêtre plutôt que de l'allonger.
const BT_AM_APPEND_MAX = 120;

// memo (optionnel) : objet conservé par le composant d'un appel à l'autre. Il
// décrit ce que la série contient déjà ; quand les nouvelles bougies ne font
// que PROLONGER les précédentes (lecture, bougie suivante), seules les
// bougies ajoutées partent au chart et les plus anciennes au-delà de
// BT_AM_KEEP sont retirées — au lieu de reconstruire 1 500 bougies à chaque
// avancée, ce qui plafonnait le débit de la lecture rapide.
function _amSetData(series, dateAxis, bars, timeframe, memo) {
  if (!series) return;
  const src = Array.isArray(bars) ? bars : [];
  const n = src.length;
  const tz = _amTz();
  let done = false;
  if (memo && memo.series === series && memo.tf === timeframe && memo.tz === tz
      && memo.n > 0 && n > memo.n && n - memo.n <= BT_AM_APPEND_MAX
      && src[0] === memo.first && src[memo.n - 1] === memo.last) {
    try {
      series.data.pushAll(_amConvert(src.slice(memo.n), timeframe, tz));
      for (let extra = series.data.length - BT_AM_KEEP; extra > 0; extra--) series.data.removeIndex(0);
      done = true;
    } catch (e) { done = false; }
  }
  if (!done) {
    // On découpe la queue AVANT de convertir : seules ces bougies partent au chart.
    const data = _amConvert(n > BT_AM_KEEP ? src.slice(n - BT_AM_KEEP) : src, timeframe, tz);
    try { series.data.setAll(data); } catch (e) {}
  }
  if (memo) {
    memo.series = series; memo.tf = timeframe; memo.tz = tz; memo.n = n;
    memo.first = n ? src[0] : null; memo.last = n ? src[n - 1] : null;
  }
  const shown = Math.min(n, BT_AM_KEEP);
  const applyZoom = () => {
    try {
      const n = shown;
      if (!dateAxis) return;
      if (n > BT_AM_WINDOW) dateAxis.zoom((n - BT_AM_WINDOW) / n, 1, 0);
      else dateAxis.zoom(0, 1, 0);
    } catch (e) {}
  };
  applyZoom();
  // L'extent de l'axe se recalcule après la validation des données : on ré-applique
  // le zoom au frame suivant pour être sûr que la fenêtre récente est bien cadrée.
  try { setTimeout(applyZoom, 0); } catch (e) {}
}

// timeframe ('5m','15m','30m','1h','1d') → baseInterval amCharts.
function _amBaseInterval(timeframe) {
  switch (timeframe) {
    case '1m':  return { timeUnit: 'minute', count: 1 };
    case '3m':  return { timeUnit: 'minute', count: 3 };
    case '5m':  return { timeUnit: 'minute', count: 5 };
    case '15m': return { timeUnit: 'minute', count: 15 };
    case '30m': return { timeUnit: 'minute', count: 30 };
    case '1h':  return { timeUnit: 'hour',   count: 1 };
    case '4h':  return { timeUnit: 'hour',   count: 4 };
    case '1d':  return { timeUnit: 'day',    count: 1 };
    default:    return { timeUnit: 'minute', count: 1 };
  }
}

// numberFormat amCharts à partir du nombre de décimales de l'actif.
function _amNumberFormat(decimals) {
  const d = Number.isFinite(decimals) ? decimals : 5;
  if (d <= 0) return '#,###';
  return '#,###.' + '0'.repeat(d);
}

function BacktestAMChart({ bars, timeframe, assetLabel, assetId, theme, positions, pendingOrders, closedTrades, decimals }) {
  const chartDivRef = React.useRef(null);
  const toolbarDivRef = React.useRef(null);

  // Références persistantes vers les objets amCharts utiles aux mises à jour.
  const rootRef = React.useRef(null);
  const valueSeriesRef = React.useRef(null);
  const valueAxisRef = React.useRef(null);
  const dateAxisRef = React.useRef(null);
  const markerSeriesRef = React.useRef(null); // série « fantôme » des marqueurs de trades
  const orderRangesRef = React.useRef([]); // axisRanges des lignes de niveaux
  const lastBarsRef = React.useRef(null);
  // Ce que la série de bougies contient déjà (voir _amSetData, memo).
  const sentRef = React.useRef({});
  // Légende O H B C : nombre de lignes en cours (1, 2 ou 4, selon la largeur)
  // et décimales en cours, lus par l'écouteur de redimensionnement du chart.
  const legendLinesRef = React.useRef(1);
  const decimalsRef = React.useRef(decimals);
  decimalsRef.current = decimals;

  const amMissing = typeof window === 'undefined' || !window.am5 || !window.am5stock || !window.am5xy;

  // Clé de recréation liée à l'ACTIF : changer d'actif détruit/recrée le chart
  // (dessins, indicateurs et zoom repartent de zéro — c'est voulu, l'échelle de
  // prix et l'historique n'ont plus rien à voir). assetLabel en secours.
  const assetKey = assetId != null ? assetId : assetLabel;

  // ── Création / re-création du chart (montage + thème + changement d'actif) ─
  React.useEffect(() => {
    if (amMissing || !chartDivRef.current || !toolbarDivRef.current) return;
    const toolbarEl = toolbarDivRef.current; // capturé pour un cleanup fiable
    const am5 = window.am5, am5xy = window.am5xy, am5stock = window.am5stock;
    const pal = _amPalette();
    const isDark = theme === 'dark' ||
      (typeof document !== 'undefined' && document.documentElement.getAttribute('data-theme') === 'dark');

    const root = am5.Root.new(chartDivRef.current);
    rootRef.current = root;
    try { root._logo && root._logo.dispose(); } catch (e) {}

    // Locale AVANT les thèmes et le Stock Chart : leurs libellés (noms des
    // indicateurs, formats de dates) sont traduits à la création.
    root.locale = _amLocaleFr();
    // Signe moins typographique (U+2212) : le formateur d'amCharts écrit
    // toujours « -0,002 » (axe et légende du MACD, par exemple), quel que soit
    // le format. On corrige sa sortie, pour ce graphique seulement.
    try {
      const nf = root.numberFormatter;
      const baseFormat = nf.format;
      nf.format = function () {
        const s = baseFormat.apply(nf, arguments);
        return typeof s === 'string' ? s.replace(/-(?=\d)/g, '−') : s;
      };
    } catch (e) {}
    // Dates et heures dans le fuseau du profil, pas dans celui du navigateur.
    // L'instance est protégée : sur un axe calé sur une seule bougie, certaines
    // versions d'amCharts (5.20.8) lui passent une date invalide, et
    // Intl.DateTimeFormat.formatToParts lève alors « RangeError: Invalid time
    // value » dans la boucle de rendu, qui ne repart plus (graphique figé). Une
    // date invalide est rendue telle quelle, avec un décalage nul.
    try {
      if (am5.Timezone) {
        const tzObj = am5.Timezone.new(_amTz());
        const convertLocal = tzObj.convertLocal.bind(tzObj);
        const offsetUTC = tzObj.offsetUTC.bind(tzObj);
        const bad = (d) => !(d instanceof Date) || isNaN(d.getTime());
        tzObj.convertLocal = (d) => (bad(d) ? d : convertLocal(d));
        tzObj.offsetUTC = (d) => (bad(d) ? 0 : offsetUTC(d));
        root.timezone = tzObj;
      }
    } catch (e) {
      console.warn('[Tempo] Backtest : fuseau du profil non appliqué au graphique', e);
    }
    // amCharts met une capitale au début de chaque date (« Mars 2024 »,
    // « Sept. ») : les mois restent en minuscules, comme partout (« sept. »).
    try { root.dateFormatter.set('capitalize', false); } catch (e) {}

    const themes = [window.am5themes_Animated.new(root)];
    if (isDark && window.am5themes_Dark) themes.push(window.am5themes_Dark.new(root));
    try { themes.push(_amFrTheme(root, decimals)); } catch (e) {}
    root.setThemes(themes);

    const stockChart = root.container.children.push(am5stock.StockChart.new(root, {}));

    // Fenêtre de réglages (indicateurs, série) : choix des listes en français.
    try {
      const settingsModal = stockChart.getPrivate('settingsModal');
      if (settingsModal) settingsModal.events.on('initstarted', (ev) => _amFrSettingOptions(ev && ev.settings));
    } catch (e) {}

    const mainPanel = stockChart.panels.push(am5stock.StockPanel.new(root, {
      wheelY: 'zoomX', panX: true, panY: true,
    }));

    const valueAxis = mainPanel.yAxes.push(am5xy.ValueAxis.new(root, {
      renderer: am5xy.AxisRendererY.new(root, { pan: 'zoom' }),
      tooltip: am5.Tooltip.new(root, {}),
      numberFormat: _amNumberFormat(decimals),
      extraTooltipPrecision: 2,
    }));
    valueAxisRef.current = valueAxis;

    const dateAxis = mainPanel.xAxes.push(am5xy.GaplessDateAxis.new(root, {
      baseInterval: _amBaseInterval(timeframe),
      renderer: am5xy.AxisRendererX.new(root, { pan: 'zoom', minorGridEnabled: true }),
      tooltip: am5.Tooltip.new(root, {}),
    }));
    dateAxisRef.current = dateAxis;

    const valueSeries = mainPanel.series.push(am5xy.CandlestickSeries.new(root, {
      name: assetLabel || 'Actif',
      clustered: false,
      valueXField: 'Date',
      valueYField: 'Close',
      highValueYField: 'High',
      lowValueYField: 'Low',
      openValueYField: 'Open',
      calculateAggregates: true,
      xAxis: dateAxis,
      yAxis: valueAxis,
      legendValueText: _amOhlcLegend(decimals, 1),
    }));
    valueSeriesRef.current = valueSeries;
    legendLinesRef.current = 1;

    // Couleurs bougies hausse/baisse depuis les CSS vars du thème.
    try {
      const upC = am5.color(pal.up), downC = am5.color(pal.down);
      valueSeries.columns.template.states.create('riseFromOpen', { fill: upC, stroke: upC });
      valueSeries.columns.template.states.create('dropFromOpen', { fill: downC, stroke: downC });
    } catch (e) {}

    stockChart.set('stockSeries', valueSeries);

    // Série « fantôme » des marqueurs de trades : LineSeries invisible
    // (strokeOpacity 0) dont les bullets matérialisent les entrées (triangle
    // vert long / rouge short) et les sorties (point cerclé) des trades clôturés.
    const markerSeries = mainPanel.series.push(am5xy.LineSeries.new(root, {
      xAxis: dateAxis, yAxis: valueAxis,
      valueXField: 'Date', valueYField: 'Value',
      stroke: am5.color(pal.blue),
    }));
    try { markerSeries.strokes.template.setAll({ strokeOpacity: 0 }); } catch (e) {}
    // Infobulle des marqueurs au style des infobulles d'axe (fond contrasté,
    // texte clair) : sans cela, elle prend le fond du point de sortie (couleur
    // de la carte) et son texte se mêle aux bougies.
    try {
      markerSeries.set('tooltip', am5.Tooltip.new(root, {
        getFillFromSprite: false, getStrokeFromSprite: false, autoTextColor: true,
      }));
    } catch (e) {}
    markerSeries.bullets.push(function (r, s, dataItem) {
      const ctx = (dataItem && dataItem.dataContext) || {};
      let sprite;
      try {
        const color = am5.color(ctx.color || pal.blue);
        sprite = ctx.kind === 'entry'
          // Entrée : triangle pointé vers le haut (long) ou le bas (short).
          ? am5.Triangle.new(root, {
              width: 9, height: 8, fill: color,
              rotation: ctx.dir === 'down' ? 180 : 0,
              tooltipText: ctx.tip || '',
            })
          // Sortie : point discret cerclé de la couleur du P&L.
          : am5.Circle.new(root, {
              radius: 3.5, fill: am5.color(pal.bg),
              stroke: color, strokeWidth: 1.5,
              tooltipText: ctx.tip || '',
            });
      } catch (e) { return undefined; }
      return am5.Bullet.new(root, { sprite });
    });
    markerSeriesRef.current = markerSeries;

    const valueLegend = mainPanel.plotContainer.children.push(am5stock.StockLegend.new(root, { stockChart }));
    valueLegend.data.setAll([valueSeries]);
    // Légende O H B C sur 1, 2 ou 4 lignes selon la largeur de la zone de
    // tracé (mobile, rotation du téléphone, fenêtre redimensionnée).
    try {
      mainPanel.plotContainer.events.on('boundschanged', () => {
        const lines = _amOhlcLegendLines(mainPanel.plotContainer.width());
        if (lines === legendLinesRef.current) return;
        legendLinesRef.current = lines;
        try { valueSeries.set('legendValueText', _amOhlcLegend(decimalsRef.current, lines)); } catch (e) {}
      });
    } catch (e) {}

    // Curseur (crosshair) en mode 'none' → le glissement PANE le chart (pas de zoom-sélection).
    mainPanel.set('cursor', am5xy.XYCursor.new(root, {
      behavior: 'none', yAxis: valueAxis, xAxis: dateAxis, snapToSeries: [valueSeries],
    }));

    // Barre de défilement horizontale (façon TradingView) : navigation fiable dans
    // le temps — on la glisse pour se déplacer, on tire ses bords pour zoomer.
    try {
      mainPanel.set('scrollbarX', am5.Scrollbar.new(root, { orientation: 'horizontal' }));
    } catch (e) {}

    // Barre d'outils : indicateurs + dessin, listes restreintes et en français.
    am5stock.StockToolbar.new(root, {
      container: toolbarDivRef.current,
      stockChart,
      controls: [
        am5stock.IndicatorControl.new(root, { stockChart, legend: valueLegend, indicators: BT_AM_INDICATORS }),
        am5stock.DrawingControl.new(root, { stockChart, tools: BT_AM_TOOLS, tool: BT_AM_TOOLS[0] }),
      ],
    });

    // Données initiales : fenêtre récente (pas toutes les bougies) + niveaux + marqueurs.
    const initBars = lastBarsRef.current != null ? lastBarsRef.current : bars;
    _amSetData(valueSeries, dateAxis, initBars, timeframe, sentRef.current);
    _amDrawLevels(valueAxis, root, orderRangesRef, positions, pendingOrders, decimals);
    _amUpdateTradeMarkers(markerSeries, initBars, closedTrades, decimals, timeframe);

    return () => {
      // root.dispose() libère TOUT ce qui a été créé sur ce root : chart,
      // séries (bougies + marqueurs), axisRanges, curseur, scrollbar et toolbar.
      try { root.dispose(); } catch (e) {}
      // amCharts injecte le DOM de la StockToolbar dans le conteneur externe :
      // on le vide à la main pour éviter l'empilement de toolbars au
      // re-thème / changement d'actif.
      try { toolbarEl.innerHTML = ''; } catch (e) {}
      rootRef.current = null;
      valueSeriesRef.current = null;
      valueAxisRef.current = null;
      dateAxisRef.current = null;
      markerSeriesRef.current = null;
      orderRangesRef.current = []; // les ranges appartiennent au root disposé
    };
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [amMissing, theme, assetKey]);

  // ── baseInterval quand le timeframe change (sans re-créer le chart) ───────
  React.useEffect(() => {
    const series = valueSeriesRef.current;
    if (!series) return;
    try {
      const dateAxis = series.get('xAxis');
      if (dateAxis) dateAxis.set('baseInterval', _amBaseInterval(timeframe));
    } catch (e) {}
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [timeframe]);

  // ── numberFormat quand l'actif (decimals) change ──────────────────────────
  React.useEffect(() => {
    const axis = valueAxisRef.current;
    if (!axis) return;
    try { axis.set('numberFormat', _amNumberFormat(decimals)); } catch (e) {}
    try { valueSeriesRef.current && valueSeriesRef.current.set('legendValueText', _amOhlcLegend(decimals, legendLinesRef.current)); } catch (e) {}
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [decimals]);

  // ── Données du replay (visibleBars) : à chaque changement, les bougies
  // ajoutées (avancée) ou toute la fenêtre (saut, recul, autre unité) ────────
  React.useEffect(() => {
    lastBarsRef.current = bars;
    const series = valueSeriesRef.current;
    if (!series) return;
    _amSetData(series, dateAxisRef.current, bars, timeframe, sentRef.current);
    // La fenêtre envoyée au chart a bougé → on re-filtre les marqueurs visibles.
    _amUpdateTradeMarkers(markerSeriesRef.current, bars, closedTrades, decimals, timeframe);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [bars]);

  // ── Lignes de niveaux (entrée / SL / TP / ordres en attente) ──────────────
  // Recréées au diff via axisRanges, SANS recréer le chart.
  React.useEffect(() => {
    if (!valueAxisRef.current || !rootRef.current) return;
    _amDrawLevels(valueAxisRef.current, rootRef.current, orderRangesRef, positions, pendingOrders, decimals);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [positions, pendingOrders, decimals]);

  // ── Marqueurs de trades : mis à jour quand un trade se clôture ────────────
  // (une clôture manuelle ne fait pas forcément avancer le playhead → l'effet
  // « bars » ci-dessus ne suffit pas).
  React.useEffect(() => {
    _amUpdateTradeMarkers(markerSeriesRef.current, lastBarsRef.current, closedTrades, decimals, timeframe);
  // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [closedTrades, decimals]);

  // Bibliothèque de graphiques absente. Ses scripts sont chargés de façon
  // synchrone avant l'app (index.html) : si elle manque ici, son chargement a
  // échoué (réseau). Ce n'est donc pas « en cours » : on le dit et on propose
  // de recharger la page.
  React.useEffect(() => {
    if (amMissing) console.warn('[Tempo] Backtest : amCharts 5 non chargé, graphique indisponible');
  }, [amMissing]);

  if (amMissing) {
    return (
      <div role="alert" style={{
        width: '100%', height: '100%', minHeight: 280, boxSizing: 'border-box', padding: 16,
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 12,
        background: 'var(--bg-soft)', borderRadius: 12, border: '1px solid var(--line)',
        color: 'var(--fg-3)', fontSize: 13, textAlign: 'center',
      }}>
        <span>Le graphique n’a pas pu se charger. Vérifie ta connexion, puis réessaie.</span>
        <button type="button" className="btn tap" style={{ fontSize: 12.5, color: 'var(--fg)' }}
                onClick={() => { try { window.location.reload(); } catch (e) {} }}>
          Réessayer
        </button>
      </div>
    );
  }

  return (
    <div style={{ width: '100%', height: '100%', minHeight: 280, display: 'flex', flexDirection: 'column' }}>
      {/* Barre d'outils amCharts (indicateurs + dessin) : police, taille et
          couleurs de l'app plutôt que celles d'amCharts (clair et sombre). */}
      <style>{'.bt-am-toolbar .am5stock-control-button{font-family:inherit;font-size:12.5px;font-weight:500;color:var(--fg);background:var(--bg-card);border:1px solid var(--line);border-radius:8px}'}</style>
      <div ref={toolbarDivRef} className="bt-am-toolbar" style={{ width: '100%' }} />
      {/* Chart */}
      <div ref={chartDivRef} style={{ width: '100%', flex: 1, minHeight: 240 }} />
    </div>
  );
}

// ── Formats communs (labels de niveaux + tooltips de marqueurs) ───────────
// Délèguent à format-prefs.jsx : prix aux décimales de l'actif (« 1,08800 »),
// quantité (« 0,5 »), montant du compte de backtest en USD (« −266,00 $ »).
function _amFmtPrice(v, decimals) {
  return fmtPrice(v, Number.isFinite(decimals) ? decimals : 5);
}
function _amFmtQty(v) {
  return fmtLots(v);
}
function _amFmtPnl(v) {
  return fmtMoneyCur(v, 'USD', true);
}

// ── Lignes de niveaux (axisRanges) ────────────────────────────────────────
// Supprime les ranges précédentes (dispose → pas de fuite amCharts), puis
// recrée une ligne horizontale par niveau :
//   • entrée de position : bleu CONTINU, « Achat 1 · 1,08800 »
//   • SL : rouge tireté / TP : vert tireté, « SL · 1,08500 »
//   • ordre en attente  : bleu POINTILLÉ, « Achat limite 1 · 1,08800 »
// NE PAS confondre avec les dessins utilisateur (gérés par DrawingControl).
function _amDrawLevels(valueAxis, root, orderRangesRef, positions, pendingOrders, decimals) {
  if (!valueAxis || !root) return;
  const am5 = window.am5;
  const pal = _amPalette();

  // Supprime les anciennes ranges de niveaux (refs gardées dans orderRangesRef).
  const prev = orderRangesRef.current || [];
  for (const r of prev) {
    try { valueAxis.axisRanges.removeValue(r); } catch (e) {}
    try { r.get('grid') && r.get('grid').dispose(); } catch (e) {}
    try { r.get('label') && r.get('label').dispose(); } catch (e) {}
    try { r.dispose(); } catch (e) {}
  }
  orderRangesRef.current = [];

  const fmt = (v) => _amFmtPrice(v, decimals);

  // dash : null → ligne continue, sinon tableau strokeDasharray.
  const addLine = (price, hex, label, dash) => {
    if (price == null || !isFinite(Number(price))) return;
    let range;
    try {
      range = valueAxis.createAxisRange(valueAxis.makeDataItem({ value: Number(price) }));
    } catch (e) { return; }
    if (!range) return;
    try {
      const color = am5.color(hex);
      const grid = range.get('grid');
      if (grid) {
        const gset = { stroke: color, strokeWidth: 1.5, strokeOpacity: 1, location: 1 };
        if (dash) gset.strokeDasharray = dash;
        grid.setAll(gset);
      }
      const lbl = range.get('label');
      if (lbl) {
        // Ancré par son bord droit sur l'axe des prix (à droite) : l'étiquette
        // s'étend vers l'intérieur du graphique au lieu d'être coupée au bord.
        lbl.setAll({
          text: label, inside: true, centerX: am5.p100, centerY: am5.p50,
          fontSize: 11, fill: am5.color('#ffffff'),
          background: am5.RoundedRectangle.new(root, {
            fill: color, fillOpacity: 1,
          }),
          paddingLeft: 5, paddingRight: 5, paddingTop: 2, paddingBottom: 2,
        });
      }
    } catch (e) {}
    orderRangesRef.current.push(range);
  };

  // Positions ouvertes : entrée (continue) + SL/TP (tiretés).
  // Libellé « Achat 1 · 1,08800 » : le point médian sépare quantité et prix.
  const list = Array.isArray(positions) ? positions : [];
  list.forEach((pos, i) => {
    const tag = list.length > 1 ? ' #' + (i + 1) : '';
    const sideTxt = pos.side === 'long' ? 'Achat' : 'Vente';
    addLine(pos.entry, pal.blue, sideTxt + ' ' + _amFmtQty(pos.qty) + tag + ' · ' + fmt(pos.entry), null);
    if (pos.sl != null) addLine(pos.sl, pal.down, 'SL' + tag + ' · ' + fmt(pos.sl), [3, 3]);
    if (pos.tp != null) addLine(pos.tp, pal.up, 'TP' + tag + ' · ' + fmt(pos.tp), [3, 3]);
  });

  // Ordres en attente : bleu pointillé, « Achat limite 1 · 1,08800 » (même
  // ordre que la pastille du panneau « Ordres en attente »).
  const pend = Array.isArray(pendingOrders) ? pendingOrders : [];
  pend.forEach((o) => {
    if (!o) return;
    const sideTxt = o.side === 'long' ? 'Achat' : 'Vente';
    const typeTxt = o.type === 'stop' ? 'stop' : 'limite';
    addLine(o.price, pal.blue, sideTxt + ' ' + typeTxt + ' ' + _amFmtQty(o.qty) + ' · ' + fmt(o.price), [2, 4]);
  });
}

// ── Marqueurs de trades (bullets sur la série fantôme) ────────────────────
// Alimente la série des marqueurs avec les entrées/sorties des trades CLÔTURÉS
// dont l'heure tombe dans la fenêtre de données envoyée au chart (même slice
// que _amSetData, cf. BT_AM_KEEP). Chaque heure est rattachée à la BOUGIE QUI
// LA CONTIENT dans l'unité affichée (dernière bougie commencée à cette heure
// ou avant) : un trade pris en 1 h reste sur la bonne bougie en 4 h ou en 1 j,
// et les Date correspondent toujours à des bougies existantes — aucun point
// parasite sur le GaplessDateAxis. Un trade postérieur à la fin de la dernière
// bougie affichée (juste après un passage à une unité supérieure) n'est pas
// dessiné : il n'a pas encore de bougie.
// Performance : seuls les BT_AM_MAX_TRADES derniers trades sont dessinés
// (2 marqueurs par trade → ~200 max), le surplus est ignoré.
const BT_AM_MAX_TRADES = 100;

function _amUpdateTradeMarkers(markerSeries, bars, closedTrades, decimals, timeframe) {
  if (!markerSeries) return;
  const trades = Array.isArray(closedTrades) ? closedTrades : [];
  const src = Array.isArray(bars) ? bars : [];
  const windowBars = src.length > BT_AM_KEEP ? src.slice(src.length - BT_AM_KEEP) : src;
  if (!windowBars.length || !trades.length) {
    try { markerSeries.data.setAll([]); } catch (e) {}
    return;
  }
  // Recherche sur les horodatages bruts (secondes) ; l'instant affiché passe
  // par _amBarMs, comme les bougies, pour tomber exactement sur la bougie.
  const firstSec = windowBars[0].time;
  const lastSec = windowBars[windowBars.length - 1].time;
  const tfInfo = typeof btTimeframe === 'function' ? btTimeframe(timeframe) : null;
  const endSec = lastSec + (tfInfo ? tfInfo.minutes * 60 : 0); // fin de la dernière bougie
  const tz = _amTz();
  // Instant affiché (ms) de la bougie qui contient l'heure t, ou null si t est
  // hors de la fenêtre.
  const barDateOf = (t) => {
    if (!isFinite(t) || t < firstSec || (endSec > lastSec ? t >= endSec : t > lastSec)) return null;
    let lo = 0, hi = windowBars.length - 1, res = 0;
    while (lo <= hi) {
      const mid = (lo + hi) >> 1;
      if (windowBars[mid].time <= t) { res = mid; lo = mid + 1; } else { hi = mid - 1; }
    }
    return _amBarMs(windowBars[res].time, timeframe, tz);
  };
  const pal = _amPalette();
  const recent = trades.length > BT_AM_MAX_TRADES ? trades.slice(trades.length - BT_AM_MAX_TRADES) : trades;

  const data = [];
  for (const t of recent) {
    if (!t) continue;
    const sideTxt = t.side === 'long' ? 'Achat' : 'Vente';
    const head = sideTxt + ' ' + _amFmtQty(t.qty);
    const pnl = Number(t.pnl);
    // Couleur du P&L arrondi au centime : vert, rouge, ou neutre (--fg) pour
    // un trade nul, comme les montants de la charte.
    const pnlCents = isFinite(pnl) ? Math.round(pnl * 100) : 0;
    const entryDate = barDateOf(Number(t.entryTime));
    const exitDate = barDateOf(Number(t.exitTime));
    // Entrée : triangle (haut = long / bas = short), couleur du side.
    if (entryDate != null && isFinite(Number(t.entry))) {
      data.push({
        Date: entryDate, Value: Number(t.entry), kind: 'entry',
        dir: t.side === 'long' ? 'up' : 'down',
        color: t.side === 'long' ? pal.up : pal.down,
        tip: 'Entrée · ' + head + '\n' + _amFmtPrice(t.entry, decimals),
      });
    }
    // Sortie : point cerclé, couleur du P&L.
    if (exitDate != null && isFinite(Number(t.exit))) {
      data.push({
        Date: exitDate, Value: Number(t.exit), kind: 'exit',
        color: pnlCents < 0 ? pal.down : pnlCents > 0 ? pal.up : pal.fg,
        tip: 'Sortie · ' + head + '\n' + _amFmtPrice(t.exit, decimals) + '\nP&L ' + _amFmtPnl(pnl),
      });
    }
  }
  // amCharts exige des données triées par Date sur un DateAxis.
  data.sort((a, b) => a.Date - b.Date);
  try { markerSeries.data.setAll(data); } catch (e) {}
}

Object.assign(window, { BacktestAMChart });
