// Tempo — Moteur d'analyse fondamentale v2 (100% gratuit, logique pure, aucun rendu React).
// ─────────────────────────────────────────────────────────────────────────────
// Ce fichier ne contient QUE de la logique : il agrège des sources publiques
// gratuites (calendrier économique, actualités et cotes Cboe relayés par le
// worker Tempo, Frankfurter/BCE, Binance, Kraken) et produit un « contexte »
// macro par catégorie — biais / scénarios / catalyseurs — sans jamais de
// prédiction ni de signal.
//
// v2 : le SOCLE v1 ci-dessus reste inchangé et fonctionne TOUJOURS seul. Quatre
// routes worker OPTIONNELLES (/fond/markets FRED, /fond/cot CFTC, /fond/sentiment
// F&G crypto, /fond/news RSS agrégés) viennent ENRICHIR l'analyse en best-effort :
// séries pour sparklines (categories[i].series), positionnement des fonds
// (categories[i].cot + drivers), drivers taux/sentiment chiffrés, et en scope
// weekly ctx.weekAhead (semaine à venir groupée par jour) + ctx.weekReview (bilan).
// Si les 4 routes échouent (worker v2 non déployé), l'analyse v1 complète sort quand même.
//
// Sortie unique : window.buildFundamentalContext({ scope, categories, onStep, force }) → Promise<ctx>.
// Une source en panne est nommée dans ctx.degradedSources ; si AUCUNE source ne
// répond, la promesse est rejetée ('fond-aucune-source') et rien n'est écrit.
// Le contrat exact du ctx (v2, rétrocompatible v1) est décrit dans le prompt et respecté ci-dessous.
//
// Vocabulaire STRICT : « biais », « scénarios », « contexte ». Jamais « prédiction »/« signal ».
// Toutes les phrases (drivers, summary) sont des chaînes FR déjà formatées : ZÉRO NaN/undefined
// ne peut atteindre le rendu.
// ─────────────────────────────────────────────────────────────────────────────

// ═══════════════════════════════════════════════════════════════════════════
//  CONSTANTES
// ═══════════════════════════════════════════════════════════════════════════

// Notre worker calendrier économique (fiable, CORS ouvert).
const FE_ECON_BASE = 'https://tempo-econ-proxy.guillaume-briere92.workers.dev';

// Frankfurter (BCE) — momentum FX, direct navigateur, sans clé.
const FE_FX_BASE = 'https://api.frankfurter.dev/v1';

// Binance PAXG/USDT ≈ or spot, direct, sans clé.
// v2 : limit=40 pour disposer d'une série ~30 points (sparkline) ; le momentum
// reste calculé sur les ~5 dernières séances comme en v1.
const FE_GOLD_URL = 'https://api.binance.com/api/v3/klines?symbol=PAXGUSDT&interval=1d&limit=40';

// Indices US (Nasdaq 100, Dow Jones, VIX) : lus sur la route /fond/quotes du
// worker Tempo (cotes Cboe relayées côté serveur, en-têtes CORS). Les relais CORS
// publics utilisés auparavant (allorigins, corsproxy, codetabs) sont hors service
// ou payants : ils sont retirés, comme dans news.jsx. Worker injoignable =
// indices absents (source dégradée), jamais d'échec global.

const FE_ECON_TIMEOUT   = 9000;   // calendrier : notre worker, un peu plus de marge
const FE_DIRECT_TIMEOUT = 8000;   // Frankfurter / Binance (directs)
const FE_FOND_TIMEOUT   = 8000;   // routes v2 /fond/* du worker (best-effort)

const FE_CACHE_PREFIX = 'tempo:fond:ctx:';   // + scope
const FE_CACHE_TTL_MS = 30 * 60 * 1000;      // 30 min

// Libellés FR des catégories (contrat).
const FE_LABELS = {
  USD:     'Dollar',
  INDICES: 'Indices US',
  GOLD:    'Or',
  EUR:     'Euro',
  AUD:     'Dollar australien',
};

const FE_DISCLAIMER = 'Contenu informatif — ne constitue pas un conseil en investissement.';

// ═══════════════════════════════════════════════════════════════════════════
//  HELPERS GÉNÉRIQUES (défensifs)
// ═══════════════════════════════════════════════════════════════════════════

// parseFloat sûr : renvoie null si non fini (Binance renvoie des STRINGS).
function feNum(v) {
  const n = parseFloat(v);
  return Number.isFinite(n) ? n : null;
}

// Formateurs d'affichage : délèguent aux formateurs communs (format-prefs.jsx,
// charte §1 et §3) — espace fine avant « % », signe moins U+2212, zéro arrondi
// sans signe, séparateur de milliers. Valeur absente → « — ».
// Pourcentage signé : 0.834 → « +0,8 % », −5.4 → « −5,4 % », −0.004 → « 0,0 % ».
function fePct(x, digits = 1) { return fmtPct(x, digits, true); }

// Nombre à la française : 15.8 → « 15,8 », 2650.3 (0 déc.) → « 2 650 », −0.35 → « −0,35 ».
function feFixed(x, digits = 1) { return fmtNum(x, digits); }

// Points de base signés : 24 → « +24 pb », −12 → « −12 pb ».
function feBp(bp) { return `${fmtNum(bp, 0, true)}\u00A0pb`; }

// Date locale ISO (YYYY-MM-DD) décalée de `days` jours, pour les URLs Frankfurter.
function feDateISO(days = 0) {
  const d = new Date();
  d.setDate(d.getDate() + days);
  const y = d.getFullYear();
  const m = String(d.getMonth() + 1).padStart(2, '0');
  const day = String(d.getDate()).padStart(2, '0');
  return `${y}-${m}-${day}`;
}

// Date d'événement dans le fuseau du profil : « mar. 14:30 ».
// Parse une ISO avec offset (ex '2026-06-28T08:15:00-04:00') — Date() gère l'offset,
// les formateurs communs reprojettent sur le fuseau du profil.
function feWhenFR(iso) {
  try {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return '';
    return `${fmtWeekdayShort(d)} ${tzTimeIn(d)}`;
  } catch (e) {
    return '';
  }
}

// Clé de jour 'YYYY-MM-DD' PROJETÉE sur le fuseau du profil — pour grouper
// les événements de la semaine à venir par jour local, pas par jour UTC.
function feDateKeyTZ(ts) {
  const d = new Date(ts);
  if (isNaN(d.getTime())) return '';
  try { return localDayInTz(d); } catch (e) { return ''; }
}

// Heure « 14:30 » dans le fuseau du profil pour un timestamp.
function feTimeFR(ts) {
  const d = new Date(ts);
  if (isNaN(d.getTime())) return '';
  try { return tzTimeIn(d); } catch (e) { return ''; }
}

// Libellé de jour « Lundi 6 juillet » à partir d'une clé 'YYYY-MM-DD' : date
// civile formatée SANS projection (charte §4), même rendu que le titre de jour
// de la page (fondDayHeading).
function feDayLabelFR(dateKey) {
  try {
    const s = fmtDayLong(dateKey);
    if (!s || s === '—') return dateKey;
    return s.charAt(0).toUpperCase() + s.slice(1);
  } catch (e) { return dateKey; }
}

// Jour de semaine seul en français (« mercredi ») pour le résumé hebdo — date
// civile, sans projection.
function feWeekdayFR(dateKey) {
  const d = new Date(dateKey + 'T12:00:00Z');
  if (isNaN(d.getTime())) return '';
  return FR_WEEKDAYS_LONG[(d.getUTCDay() + 6) % 7] || '';
}

// ── Helpers de séries [{d:'YYYY-MM-DD', v:number}] (v2) ──────────────────────

// Variation sur les ~5 dernières séances d'une série : { first, last, diff, pct, seances } | null.
// `win` = nombre de points de la fenêtre. `seances` = nombre d'INTERVALLES entre
// clôtures (6 points = 5 séances) — convention marché « variation sur N séances ».
function feSeriesDelta(points, win = 6) {
  if (!Array.isArray(points) || points.length < 2) return null;
  const w = points.slice(-Math.min(win, points.length));
  const first = w[0] && w[0].v;
  const last = w[w.length - 1] && w[w.length - 1].v;
  if (!Number.isFinite(first) || !Number.isFinite(last) || first === 0) return null;
  return { first, last, diff: last - first, pct: ((last - first) / Math.abs(first)) * 100, seances: w.length - 1 };
}

// Dernière valeur finie d'une série, sinon null.
function feSeriesLast(points) {
  if (!Array.isArray(points) || !points.length) return null;
  const p = points[points.length - 1];
  return (p && Number.isFinite(p.v)) ? p.v : null;
}

// Échantillonne proprement une série vers ~n points (garde premier ET dernier).
function feSampleSeries(points, n = 30) {
  if (!Array.isArray(points)) return [];
  const clean = points.filter(p => p && typeof p.d === 'string' && Number.isFinite(p.v));
  if (clean.length <= n) return clean;
  const out = [];
  const step = (clean.length - 1) / (n - 1);
  for (let i = 0; i < n; i++) {
    const p = clean[Math.round(i * step)];
    if (out.length === 0 || out[out.length - 1] !== p) out.push(p);
  }
  return out;
}

// fetch avec timeout via AbortController. Renvoie la Response ou lève.
async function feFetch(url, timeout, opts) {
  const controller = new AbortController();
  const t = setTimeout(() => controller.abort(), timeout);
  try {
    return await fetch(url, Object.assign({ mode: 'cors', signal: controller.signal }, opts || {}));
  } finally {
    clearTimeout(t);
  }
}

// fetch JSON direct (Frankfurter, Binance, notre worker). Renvoie l'objet parsé ou null.
async function feFetchJSON(url, timeout) {
  try {
    const r = await feFetch(url, timeout);
    if (!r || !r.ok) return null;
    return await r.json();
  } catch (e) {
    return null;
  }
}

// Convertit haussier/baissier/neutre depuis un score signé + seuil.
function feBiasFromScore(score, deadband = 0.5) {
  if (score > deadband)  return 'haussier';
  if (score < -deadband) return 'baissier';
  return 'neutre';
}

// Convertit l'ampleur d'un score en conviction 1..5 (3 par défaut si signaux mixtes/faibles).
// `strength` est |score| ; on mappe des paliers vers 1..5.
function feConviction(strength, cap) {
  let c;
  if (strength >= 4)      c = 5;
  else if (strength >= 3) c = 4;
  else if (strength >= 2) c = 3;
  else if (strength >= 1) c = 2;
  else                    c = 1;
  // Si signaux quasi nuls → on reste prudent mais lisible : minimum 1.
  if (cap && c > cap) c = cap;
  return c;
}

// Traduit un impact calendrier ('High'|'Medium'|'Low') en niveau interne.
function feImpactLevel(impact) {
  const s = String(impact || '').toLowerCase();
  if (s === 'high')   return 'eleve';
  if (s === 'medium') return 'moyen';
  return 'faible';
}
function feMaxRisk(a, b) {
  const order = { faible: 0, moyen: 1, eleve: 2 };
  return (order[a] || 0) >= (order[b] || 0) ? (a || 'faible') : (b || 'faible');
}

// ═══════════════════════════════════════════════════════════════════════════
//  ÉTAPE 1 — CALENDRIER ÉCONOMIQUE
// ═══════════════════════════════════════════════════════════════════════════

// Deux routes du worker, lancées en parallèle :
//   • /cal2 — la source du Calendrier éco. (TradingView) : deux semaines, donc la
//     semaine prochaine dès qu'elle est connue. Enveloppe { source, events:[{ ts,
//     country (ISO2), currency, title, impact 'High'|'Medium'|'Low', forecast,
//     previous (nombres), unit, scale }] }. C'est elle qui fait foi.
//   • /     — le flux historique (ForexFactory), semaine en cours seulement :
//     source de SECOURS, lue seulement si /cal2 échoue ou rend une liste vide
//     (même stratégie que fetchEconAny, econ.jsx). Elle est demandée en même
//     temps pour ne pas allonger l'étape quand /cal2 ne répond pas.
// Renvoie : tableau trié (éventuellement VIDE = calendrier calme, source OK),
// portant deux propriétés : `source` ('tradingview' | 'faireconomy') et
// `partial` (true = la fenêtre demandée dépasse ce que couvre la source de
// secours : la suite de la fenêtre n'est pas connue, ce n'est pas un calme) ;
// null = AUCUNE route n'a répondu (vraie panne).
async function feFetchCalendar(scope) {
  const now = Date.now();
  const windowMs = scope === 'weekly' ? 7 * 24 * 3600 * 1000 : 48 * 3600 * 1000;
  const horizon = now + windowMs;

  const secoursP = feFetchJSON(FE_ECON_BASE + '/', FE_ECON_TIMEOUT);
  let parsed = feCalParse(await feFetchJSON(FE_ECON_BASE + '/cal2', FE_ECON_TIMEOUT));
  if (!parsed || !parsed.events.length) {
    // /cal2 muet, ou vide (suspect : deux semaines ne sont jamais vides).
    parsed = feCalParse(await secoursP);
    if (parsed) parsed.source = 'faireconomy';
  }
  // Aucune route n'a répondu → panne réelle (≠ calendrier vide).
  if (!parsed) return null;
  const all = parsed.events;
  // Déduplication : clé titre+devise+pays+instant, première occurrence gagne.
  // Le pays (iso2, source /cal2 seulement) distingue les publications
  // nationales de la zone euro : PMI allemand et PMI français partagent le
  // titre, la devise et l'heure, ce sont pourtant deux publications. Le flux
  // de secours n'a pas de pays : sa clé reste titre+devise+instant.
  const seen = new Set();
  const uniq = [];
  for (const e of all) {
    const key = `${e.title.toLowerCase()}|${e.country}|${e.iso2 || ''}|${e.ts}`;
    if (seen.has(key)) continue;
    seen.add(key);
    uniq.push(e);
  }
  // Fenêtre : maintenant → horizon (événements À VENIR), avec la tolérance
  // -30 min « en cours » historique.
  // v3 (scope daily UNIQUEMENT) : on garde AUSSI les événements High/Medium
  // PASSÉS depuis 0-6 h, marqués passed:true — ils alimentent la mesure de
  // RÉACTION post-événement du biais Jour. Les consommateurs « futurs »
  // (feCatalystsFor, fePivotEvent, feEventRiskFor, feHighCount,
  // feForecastDriver, feBuildWeekAhead) filtrent e.passed : weekAhead et
  // nextCatalysts restent STRICTEMENT futurs, comme avant.
  const pastFloor = (scope === 'daily') ? now - 6 * 3600 * 1000 : now - 30 * 60 * 1000;
  const upcoming = uniq
    .filter(e => e.ts >= pastFloor && e.ts <= horizon)
    .filter(e => e.ts >= now - 30 * 60 * 1000 || /high|medium/i.test(e.impact))
    // Flag daily-scoped : TOUT événement réellement passé (e.ts < now) est marqué,
    // y compris la fenêtre 0-30 min post-publication (la plus chaude pour la
    // réaction). En weekly, pastFloor = now − 30 min et aucun événement n'est
    // flaggé : feHighCount/feGlobalRisk hebdo inchangés. Effet assumé : côté
    // Jour, l'événement quitte pivotEvent/nextCatalysts dès sa publication.
    .map(e => (scope === 'daily' && e.ts < now ? Object.assign(e, { passed: true }) : e))
    .sort((a, b) => a.ts - b.ts);
  upcoming.source = parsed.source;
  upcoming.partial = parsed.source !== 'tradingview' && !feSameFeedWeek(now, horizon);
  return upcoming;
}

// Lit la réponse d'une route calendrier, quelle que soit sa forme : enveloppe
// { source, events } de /cal2, ou tableau du flux historique (route /, et /cal2
// d'un ancien worker qui sert ce même tableau). Renvoie { events, source } avec
// les événements au format interne, ou null si la réponse est inexploitable.
function feCalParse(raw) {
  const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.events) ? raw.events : null);
  if (!arr) return null;
  const envSrc = (!Array.isArray(raw) && typeof raw.source === 'string') ? raw.source : '';
  // Sans enveloppe : la forme des événements décide (ts numérique = /cal2).
  const cal2Shape = (ev) => !!ev && typeof ev.ts === 'number' && ev.date === undefined;
  const source = envSrc === 'tradingview' || envSrc === 'faireconomy'
    ? envSrc
    : (arr.some(cal2Shape) ? 'tradingview' : 'faireconomy');
  const events = [];
  for (const ev of arr) {
    if (!ev) continue;
    const e = cal2Shape(ev) ? feCalFromCal2(ev, source === 'tradingview') : feCalFromFeed(ev);
    if (e) events.push(e);
  }
  return { events, source };
}

// Événement du flux historique { title, country (= DEVISE), date ISO, impact,
// forecast, previous } → format interne. null si la date est illisible.
function feCalFromFeed(ev) {
  if (!ev.date) return null;
  const ts = new Date(ev.date).getTime();
  if (!Number.isFinite(ts)) return null;
  const title = String(ev.title || '').trim();
  return {
    // `title` reste le titre d'origine (anglais) : il sert au rapprochement
    // avec la presse par mots-clés. `titleFr` est le nom affiché, identique
    // à celui du Calendrier éco. (econTitleFr, econ.jsx).
    title,
    titleFr: feTitleFr(title),
    country: String(ev.country || '').trim(),   // 'USD' | 'EUR' | 'AUD' | ...
    date: ev.date,
    ts,
    impact: String(ev.impact || '').trim(),      // 'High' | 'Medium' | 'Low'
    forecast: (ev.forecast == null ? '' : String(ev.forecast)),
    previous: (ev.previous == null ? '' : String(ev.previous)),
  };
}

// Événement /cal2 → même format interne. `country` y est la DEVISE (les scores
// filtrent par devise) ; le pays ISO2 sert au nom affiché et, gardé dans
// `iso2`, au dédoublonnage (feFetchCalendar). `iso2` reste interne au moteur :
// ni « Semaine à venir » ni les catalyseurs ne le recopient. `tv` : titres
// TradingView (une même publication existe pour plusieurs pays de la zone
// euro → pays ajouté au nom, comme dans le Calendrier éco.) ; false pour le
// repli serveur, dont les titres nomment déjà le pays (« German … »).
function feCalFromCal2(ev, tv) {
  const ts = ev.ts;
  if (!Number.isFinite(ts)) return null;
  const title = String(ev.title || '').trim();
  const cur = String(ev.currency || '').trim().toUpperCase();
  const iso2 = String(ev.country || '').trim().toUpperCase();
  return {
    title,
    titleFr: tv ? feTitleFrCountry(title, cur, iso2) : feTitleFr(title),
    country: cur,
    iso2,
    date: new Date(ts).toISOString(),
    ts,
    impact: String(ev.impact || '').trim(),
    forecast: feCal2Val(ev.forecast, ev.unit, ev.scale),
    previous: feCal2Val(ev.previous, ev.unit, ev.scale),
  };
}

// Valeur /cal2 (nombre déjà mis à l'échelle + unit/scale, ou chaîne du repli
// serveur) → chaîne lisible par feParseCalNum : 3.1 + '%' → « 3.1% »,
// 55 + 'K' → « 55K », 5.6 + 'B' → « 5.6B ». '' si absente.
function feCal2Val(v, unit, scale) {
  if (v == null || v === '') return '';
  if (typeof v !== 'number') return String(v);
  if (!Number.isFinite(v)) return '';
  return String(v) + (unit === '%' ? '%' : (scale ? String(scale) : ''));
}

// Les deux instants tombent-ils dans la même semaine du flux historique ? Ce
// flux ne publie que la semaine en cours (dimanche → samedi, heure de New
// York) : au-delà, il ne sait rien. La frontière retenue est la réouverture
// des marchés, dimanche 17:00 à New York — aucune publication n'a lieu entre
// le samedi et cette heure-là, une fenêtre qui s'y arrête reste donc couverte.
// La frontière se lit sur l'HORLOGE de New York (jour civil + heure locale),
// jamais en retranchant une durée fixe : les deux dimanches de changement
// d'heure, 17:00 n'est pas à 17 heures de minuit (16 h en mars, 18 h en
// novembre) et la frontière se décalait d'une heure.
// false par prudence en cas de doute.
function feSameFeedWeek(aMs, bMs) {
  try {
    const NY = 'America/New_York';
    // Dimanche (date civile) qui ouvre la semaine du flux contenant l'instant.
    const sunday = (ms) => {
      if (!Number.isFinite(ms)) return '';
      const d = new Date(ms);
      const hm = tzTimeIn(d, NY);                       // 'HH:MM' à New York
      let t = Date.parse(localDayInTz(d, NY) + 'T12:00:00Z');
      if (!Number.isFinite(t) || !/^\d\d:\d\d$/.test(hm)) return '';
      // Avant 17:00 locale, l'instant appartient encore à la « journée de
      // marché » de la veille (calcul sur la date civile, à midi UTC).
      if (hm < '17:00') t -= 86400e3;
      return new Date(t - new Date(t).getUTCDay() * 86400e3).toISOString().slice(0, 10);
    };
    const a = sunday(aMs), b = sunday(bMs);
    return !!a && a === b;
  } catch (e) { return false; }
}

// Extrait un chiffre ET son unité depuis un forecast/previous string :
// '2,9%' → {n:2.9, unit:'%'}, '240K' → {n:240, unit:'K'}, '3.1' → {n:3.1, unit:''}.
// Renvoie null si non numérique (souvent le cas). L'unité permet (a) de ne
// comparer que des grandeurs comparables (950K vs 1.0M serait inversable) et
// (b) de réinjecter le suffixe dans le texte du driver.
function feParseCalNum(s) {
  if (s == null) return null;
  const m = String(s).trim().match(/(-?\d+(?:[.,]\d+)?)\s*(%|K|M|B|T)?/i);
  if (!m) return null;
  const n = parseFloat(m[1].replace(',', '.'));
  if (!Number.isFinite(n)) return null;
  return { n, unit: (m[2] || '').toUpperCase() };
}

// Formate une valeur calendrier AVEC son unité (« 2,9 % », « 240K », « 3,1 »,
// « 0,25 % ») : jusqu'à 2 décimales comme la source, espace fine avant « % ».
function feCalNumTxt(v) {
  return fmtNum(v.n) + (v.unit === '%' ? '\u202F%' : (v.unit || ''));
}

// Nom français d'un événement du calendrier, le même que dans le Calendrier éco.
// (econTitleFr, econ.jsx, chargé avant ce fichier). Titre d'origine si la
// traduction est indisponible ou ne reconnaît pas l'intitulé.
function feTitleFr(title) {
  const raw = String(title || '').trim();
  if (!raw) return '';
  try { return (typeof econTitleFr === 'function' ? econTitleFr(raw) : raw) || raw; }
  catch (e) { return raw; }
}
// Nom français + pays, pour les publications nationales en euro : la même
// publication (PMI, inflation, production industrielle…) existe pour la France,
// l'Allemagne, l'Italie et la zone euro, avec la même devise. Même règle et même
// forme que le Calendrier éco. (econCountrySuffix, econ.jsx) : « Sentiment
// économique (ZEW) · Allemagne ». Rien pour les autres devises.
function feTitleFrCountry(title, cur, iso2) {
  const fr = feTitleFr(title);
  let pays = '';
  try {
    if (typeof econCountrySuffix === 'function') {
      pays = econCountrySuffix({ cur, flag: iso2, isSpeech: /speech|speaks|testimony/i.test(title) }, fr) || '';
    }
  } catch (e) { pays = ''; }
  return pays ? `${fr} · ${pays}` : fr;
}
// Titre affiché d'un événement normalisé (repli : traduction à la volée).
function feEvTitle(e) {
  if (!e) return 'Événement';
  return e.titleFr || feTitleFr(e.title) || 'Événement';
}

// Prochains catalyseurs d'une devise : événements à venir à impact élevé ou
// moyen, `max` au plus. Les impacts ÉLEVÉS passent d'abord (dans l'ordre
// chronologique), les impacts moyens complètent les places restantes ; la
// sélection est ensuite rendue dans l'ordre chronologique. Sans cette priorité,
// une devise dont la semaine s'ouvre sur quatre discours (impact moyen) ne
// montrait jamais la publication majeure annoncée par la synthèse.
// Conséquence utile : le premier événement élevé de la devise figure toujours
// dans la liste (feCtxOutdatedByEvent s'appuie dessus).
function feCatalystsFor(events, currency, max = 4) {
  const pool = events
    .filter(e => !e.passed && e.country === currency && /high|medium/i.test(e.impact))
    .sort((a, b) => a.ts - b.ts);
  const isHigh = (e) => /high/i.test(e.impact);
  const keep = new Set(pool.filter(isHigh).concat(pool.filter(e => !isHigh(e))).slice(0, Math.max(0, max)));
  return pool
    .filter(e => keep.has(e))
    .map(e => ({
      title: feEvTitle(e),
      whenFR: feWhenFR(e.date) || '—',
      impact: /high/i.test(e.impact) ? 'High' : 'Medium',
      currency: e.country || currency,
      // v2.2 — AJOUT OPTIONNEL (rétrocompatible) : timestamp ms de l'événement.
      // Permet à feCtxOutdatedByEvent de détecter qu'un catalyseur FORT est passé
      // depuis la génération du ctx (cache/snapshot périmé par l'événement).
      // Absent (undefined → retiré du JSON) si le ts n'est pas exploitable.
      ts: Number.isFinite(e.ts) ? e.ts : undefined,
    }));
}

// Risque événementiel d'une devise = max des impacts à venir la concernant.
function feEventRiskFor(events, currency) {
  let risk = 'faible';
  for (const e of events) {
    if (e.passed) continue;   // v3 : le risque à venir ignore les événements passés
    if (e.country === currency) risk = feMaxRisk(risk, feImpactLevel(e.impact));
  }
  return risk;
}

// ═══════════════════════════════════════════════════════════════════════════
//  SOURCES v2 — ROUTES /fond/* DU WORKER (best-effort, JAMAIS bloquantes)
// ═══════════════════════════════════════════════════════════════════════════
// ⚠️ AUCUN appel navigateur direct vers FRED/CFTC/RSS (CORS) : tout passe par
// le worker. Tant que le worker v2 n'est pas déployé, ces routes renvoient
// 404 → chaque fetcher renvoie null et la source est listée « dégradée ».
// L'analyse v1 (calendrier + Frankfurter + Binance + CBOE + news) reste le socle.

// GET /fond/markets → { updatedAt, series:{ spx,ndq,dji,us10y,us02y,usdIdx,wti,vix : [{d,v}] } }.
// Renvoie l'objet séries nettoyé (chaque série = tableau, éventuellement vide) ou null.
async function feFetchMarkets() {
  const raw = await feFetchJSON(FE_ECON_BASE + '/fond/markets', FE_FOND_TIMEOUT);
  if (!raw || !raw.series || typeof raw.series !== 'object') return null;
  const keys = ['spx', 'ndq', 'dji', 'us10y', 'us02y', 'usdIdx', 'wti', 'vix'];
  const out = {};
  let any = false;
  for (const k of keys) {
    const arr = Array.isArray(raw.series[k])
      ? raw.series[k].filter(p => p && typeof p.d === 'string' && Number.isFinite(p.v))
      : [];
    out[k] = arr;
    if (arr.length >= 2) any = true;
  }
  return any ? out : null;
}

// Nettoie un marché COT { date, long, short, net, prevNet, prevDate } — null si inexploitable.
function feCotClean(m) {
  if (!m || typeof m !== 'object') return null;
  const net = feNum(m.net);
  if (net == null) return null;
  return {
    date: (typeof m.date === 'string' && m.date) ? m.date : null,
    long: feNum(m.long),
    short: feNum(m.short),
    net,
    prevNet: feNum(m.prevNet),
    prevDate: (typeof m.prevDate === 'string' && m.prevDate) ? m.prevDate : null,
  };
}

// GET /fond/cot → { updatedAt, markets:{ eur,aud,gbp,nasdaq,dow,gold } }.
// Renvoie { eur, aud, gbp, nasdaq, dow, gold } (chaque champ nettoyé ou null), ou null si rien.
async function feFetchCot() {
  const raw = await feFetchJSON(FE_ECON_BASE + '/fond/cot', FE_FOND_TIMEOUT);
  if (!raw || !raw.markets || typeof raw.markets !== 'object') return null;
  const out = {
    eur: feCotClean(raw.markets.eur),
    aud: feCotClean(raw.markets.aud),
    gbp: feCotClean(raw.markets.gbp),
    nasdaq: feCotClean(raw.markets.nasdaq),
    dow: feCotClean(raw.markets.dow),
    gold: feCotClean(raw.markets.gold),
  };
  const any = out.eur || out.aud || out.gbp || out.nasdaq || out.dow || out.gold;
  return any ? out : null;
}

// Traduction FR des libellés Fear & Greed (source EN).
const FE_FNG_FR = {
  'extreme fear': 'peur extrême',
  'fear': 'peur',
  'neutral': 'neutre',
  'greed': 'avidité',
  'extreme greed': 'avidité extrême',
};

// GET /fond/sentiment → { updatedAt, fng:{value,label,prev} }.
// C'est le F&G CRYPTO : on l'utilise comme PROXY d'appétit au risque, étiqueté honnêtement.
async function feFetchSentiment() {
  const raw = await feFetchJSON(FE_ECON_BASE + '/fond/sentiment', FE_FOND_TIMEOUT);
  const f = raw && raw.fng;
  const value = f ? feNum(f.value) : null;
  if (value == null) return null;
  return {
    value,
    label: String((f && f.label) || '').trim(),
    prev: feNum(f && f.prev),
  };
}

// Actualités : UNE lecture et UNE mise en forme, celles de la carte de l'Accueil
// (news.jsx, chargé avant ce fichier) — window.fetchNewsItems lit la route
// /fond/news du worker (liste complète : éditeur au nom affiché, titre décodé,
// lien assaini, `ts` en ms ou null) et window.newsWithTime pose l'heure dans le
// fuseau du profil (« 14:38 » pour un titre du jour, « 6 oct. · 21:40 » sinon).
// Un même titre porte donc le même éditeur et la même heure sur toutes les
// cartes, et il n'y a qu'une requête par analyse.
// Renvoie { items, live } :
//   live = true  → titres lus à l'instant ;
//   live = false → la lecture a échoué. `items` contient alors les derniers
//     titres gardés par l'Accueil (window.readNewsCache) s'ils ont moins de
//     24 h — des titres datés, que l'appelant signale dans « Sources
//     partielles » — ou rien.
// Les titres sont rendus du plus récent au plus ancien (sans date : à la fin).
const FE_NEWS_CACHE_MAX_AGE = 24 * 3600 * 1000;
async function feFetchNews() {
  let items = null;
  try {
    if (typeof window !== 'undefined' && typeof window.fetchNewsItems === 'function') {
      items = await window.fetchNewsItems();
    }
  } catch (e) { items = null; }
  const live = Array.isArray(items) && items.length > 0;
  if (!live) {
    items = [];
    try {
      const c = (typeof window !== 'undefined' && typeof window.readNewsCache === 'function') ? window.readNewsCache() : null;
      const now = Date.now();
      if (c && Array.isArray(c.items)) {
        items = c.items.filter(n => n && Number.isFinite(n.ts) && now - n.ts < FE_NEWS_CACHE_MAX_AGE);
      }
    } catch (e) { items = []; }
  }
  const sorted = items.filter(Boolean).slice().sort((x, y) => {
    const tx = Number.isFinite(x.ts) ? x.ts : null, ty = Number.isFinite(y.ts) ? y.ts : null;
    if (tx == null && ty == null) return 0;
    if (tx == null) return 1;
    if (ty == null) return -1;
    return ty - tx;
  });
  let out = sorted;
  try {
    if (typeof window !== 'undefined' && typeof window.newsWithTime === 'function') out = window.newsWithTime(sorted);
  } catch (e) { out = sorted; }
  return { items: Array.isArray(out) ? out : [], live };
}

// ═══════════════════════════════════════════════════════════════════════════
//  ÉTAPE 2 — MOMENTUM DES DEVISES (Frankfurter / BCE)
// ═══════════════════════════════════════════════════════════════════════════

// Récupère USD→EUR et USD→AUD sur une fenêtre ~45 jours calendaires (v2 : élargie
// pour construire une série ~30 points pour les sparklines ; le MOMENTUM reste
// calculé sur les ~5 dernières séances, comme en v1).
// Renvoie { EUR: {first,last,pct,seances,series}, AUD: {...} } ou null si échec.
// USD/EUR qui MONTE = dollar FORT. `series` = paire INVERSÉE ({d, v} en EUR/USD, AUD/USD).
async function feFetchFxMomentum() {
  const from = feDateISO(-45);
  const to = feDateISO(0);
  const url = `${FE_FX_BASE}/${from}..${to}?base=USD&symbols=EUR,AUD`;
  const raw = await feFetchJSON(url, FE_DIRECT_TIMEOUT);
  if (!raw || !raw.rates || typeof raw.rates !== 'object') return null;

  // Jours ouvrés seulement → on trie les dates disponibles.
  const dates = Object.keys(raw.rates).sort();
  if (dates.length < 2) return null;

  const out = {};
  for (const sym of ['EUR', 'AUD']) {
    // Série des valeurs disponibles pour ce symbole (certains jours peuvent manquer).
    const series = [];       // valeurs brutes USD→sym (momentum v1)
    const pairSeries = [];   // {d, v} paire inversée sym/USD (sparkline v2)
    for (const d of dates) {
      const v = feNum(raw.rates[d] && raw.rates[d][sym]);
      if (v != null && v > 0) {
        series.push(v);
        pairSeries.push({ d, v: parseFloat((1 / v).toFixed(5)) });
      }
    }
    if (series.length < 2) { out[sym] = null; continue; }
    // On prend les ~5-8 dernières séances disponibles (5 par défaut si assez de points).
    const win = series.slice(-Math.min(6, series.length));
    const first = win[0];
    const last = win[win.length - 1];
    const pct = ((last - first) / first) * 100;   // variation USD/sym = force du dollar vs sym
    // seances = nombre d'intervalles entre clôtures (6 points = 5 séances).
    out[sym] = { first, last, pct, seances: win.length - 1, series: pairSeries };
  }
  if (out.EUR == null && out.AUD == null) return null;
  return out;
}

// ═══════════════════════════════════════════════════════════════════════════
//  ÉTAPE 3 — POULS DU MARCHÉ (Or, Indices, VIX)
// ═══════════════════════════════════════════════════════════════════════════

// Binance PAXG : variation sur ~5 séances (klines = tableaux de STRINGS).
// Renvoie { first, last, pct, seances, price, series } ou null.
// v2 : `series` = [{d:'YYYY-MM-DD', v:close}] (~40 points) pour la sparkline Or.
async function feFetchGold() {
  const raw = await feFetchJSON(FE_GOLD_URL, FE_DIRECT_TIMEOUT);
  if (!Array.isArray(raw) || raw.length < 2) return null;
  // Chaque kline : [ openTimeMs, "open", "high", "low", "close", ... ]
  const closes = [];
  const series = [];
  for (const k of raw) {
    const v = feNum(k && k[4]);
    const t = feNum(k && k[0]);
    if (v == null) continue;
    closes.push(v);
    if (t != null) {
      try { series.push({ d: new Date(t).toISOString().slice(0, 10), v }); } catch (e) {}
    }
  }
  if (closes.length < 2) return null;
  const win = closes.slice(-Math.min(6, closes.length));
  const first = win[0];
  const last = win[win.length - 1];
  const pct = ((last - first) / first) * 100;
  // seances = nombre d'intervalles entre clôtures (6 points = 5 séances).
  return { first, last, pct, seances: win.length - 1, price: last, series };
}

// Réponse brute de /fond/quotes, partagée entre les indices (feFetchIndices) et
// les cotations du jour (feFetchQuotesWorker). L'orchestrateur la demande UNE
// fois par analyse et passe la même promesse aux deux lecteurs : une requête par
// analyse, aucun état gardé d'une analyse à l'autre (une panne n'est jamais
// resservie à la régénération suivante). null si le worker ne répond pas.
function feFetchQuotesRaw() {
  return feFetchJSON(FE_ECON_BASE + '/fond/quotes', FE_QUOTES_TIMEOUT).catch(() => null);
}

// Récupère NDX, DJI, VIX depuis le worker. Renvoie { NDX, DJI, VIX } au format
// historique Cboe ({ current_price, price_change_percent }, null si absent)
// + degraded=true si aucun indice n'est disponible.
// `quotesRawP` : promesse de la réponse /fond/quotes de l'analyse en cours
// (feFetchQuotesRaw) ; relue ici si elle n'est pas fournie.
// Même garde-fou de fraîcheur que feFetchQuotesWorker : le worker peut servir
// une réponse ancienne sans borne d'âge. Réponse entière trop vieille
// (updatedAt) ou cote datée trop vieille (ts) → écartée ; une cote sans date
// reste tolérée (fraîcheur inconnue). Sans cela, des indices de la veille
// seraient présentés « en séance ».
async function feFetchIndices(quotesRawP) {
  const raw = await (quotesRawP || feFetchQuotesRaw());
  let q = (raw && raw.quotes && typeof raw.quotes === 'object') ? raw.quotes : {};
  const upAt = feTsMs(raw && raw.updatedAt);
  if (upAt != null && Date.now() - upAt > FE_QUOTE_MAX_AGE) q = {};
  const one = (k) => {
    const o = q[k];
    const price = feNum(o && o.price);
    const chg = feNum(o && o.chgPct);
    if (price == null || chg == null) return null;
    if (!feQuoteFresh(feTsMs(o.ts))) return null;
    return { current_price: price, price_change_percent: chg };
  };
  const ndx = one('ndx'), dji = one('dji'), vix = one('vix');
  const degraded = !ndx && !dji && !vix;
  if (degraded) console.warn('[Tempo] analyse fondamentale : indices US indisponibles (route /fond/quotes du worker)');
  return { NDX: ndx, DJI: dji, VIX: vix, degraded };
}

// ═══════════════════════════════════════════════════════════════════════════
//  SOURCES INTRADAY v3 — /fond/quotes (worker) + replis client (scope daily)
// ═══════════════════════════════════════════════════════════════════════════
// Le biais JOUR est « intraday-first » : il lit d'abord la route worker
// /fond/quotes (KV 3 min, stale-if-error). Tant que l'ANCIEN worker est
// déployé (route absente → 404), repli client : indices NDX/DJI/VIX de
// feFetchIndices (même réponse worker, promesse réutilisée, zéro requête en
// plus), Binance direct (EURUSDT, PAXGUSDT) et
// Kraken direct (GBP/USD, AUD/USD — CORS public VÉRIFIÉ ouvert le 2026-08-10 :
// l'API renvoie Access-Control-Allow-Origin en écho de l'Origin sur GET).
// ⚠️ Binance GBPUSDT/AUDUSDT sont DÉLISTÉS (bougies figées 2023) : interdits.
// Un actif KO = quote absente, JAMAIS d'échec global.

const FE_QUOTES_TIMEOUT = 6000;   // route worker /fond/quotes
const FE_BINANCE_15M = (sym) => `https://api.binance.com/api/v3/klines?symbol=${sym}&interval=15m&limit=96`;
const FE_KRAKEN_OHLC = (pair) => `https://api.kraken.com/0/public/OHLC?pair=${pair}&interval=15`;

// Normalise un timestamp en MILLISECONDES : Kraken renvoie des SECONDES,
// Binance des ms — garde par ordre de grandeur, null si inexploitable.
function feTsMs(v) {
  const n = feNum(v);
  if (n == null || n <= 0) return null;
  if (n >= 1e12) return n;          // déjà en millisecondes
  if (n >= 1e9)  return n * 1000;   // secondes → millisecondes
  return null;
}

// Début du jour UTC courant (ms) — référence du chgPct fourni par les sources
// (kline 1d UTC Binance côté worker, bougies 15 min côté client). Le moteur ne
// l'utilise plus que pour le libellé du REPLI (« depuis 02:00 ») : « aujourd'hui »
// se mesure depuis minuit dans le fuseau du profil (feDayMoves).
function feUtcMidnight(nowMs) {
  const d = new Date(Number.isFinite(nowMs) ? nowMs : Date.now());
  return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}

// Séance au comptant de New York ouverte ? (09:30–16:00, lun.–ven., heure de
// New York, hors jours fériés de la Bourse). Source unique des marchés :
// sessionStatusInTz (format-prefs.jsx). false par prudence en cas de doute.
function feUsCashOpen(nowMs) {
  try {
    const st = sessionStatusInTz(null, new Date(Number.isFinite(nowMs) ? nowMs : Date.now()));
    return !!(st && st.newyork);
  } catch (e) { return false; }
}

// Jour civil 'YYYY-MM-DD' précédent (date civile, sans projection).
function fePrevDayISO(day) {
  const t = Date.parse(day + 'T12:00:00Z');
  return Number.isFinite(t) ? new Date(t - 86400e3).toISOString().slice(0, 10) : '';
}

// Jour civil (New York) de la dernière séance de Wall Street COMMENCÉE : le
// jour même à partir de 09:30 s'il est ouvré (lun.–ven., hors jours fériés de
// la Bourse), sinon le jour ouvré précédent (sam., dim. ou lun. avant 09:30 →
// vendredi ; lundi férié → vendredi ; lendemain de Thanksgiving avant 09:30 →
// mercredi). Les variations CBOE (indices, VIX) sont celles de cette séance.
// Jours fériés : fpMarketHoliday('newyork', jour), le calendrier de
// sessionStatusInTz (format-prefs.jsx). '' si le fuseau est illisible.
function feLastUsSessionDay(nowMs) {
  try {
    const now = new Date(Number.isFinite(nowMs) ? nowMs : Date.now());
    const hm = tzTimeIn(now, 'America/New_York').split(':').map(Number);
    let day = localDayInTz(now, 'America/New_York');
    const wdOf = (d) => new Date(d + 'T12:00:00Z').getUTCDay();   // 0 = dimanche
    const closed = (d) => {
      const wd = wdOf(d);
      return wd === 0 || wd === 6 || (typeof fpMarketHoliday === 'function' && fpMarketHoliday('newyork', d));
    };
    if (!closed(day) && hm[0] * 60 + hm[1] >= 9 * 60 + 30) return day;
    // Borne de sécurité : jamais plus de dix jours fermés d'affilée.
    let guard = 0;
    do { day = fePrevDayISO(day); guard++; } while (day && closed(day) && guard < 10);
    return day;
  } catch (e) { return ''; }
}

// Libellé de fraîcheur des variations de Wall Street (indices, VIX), relatif
// au jour du profil : « en séance », « à la clôture » (séance du jour
// terminée), « à la clôture d’hier », « à la clôture de vendredi ».
function feSessionWhen(open, day) {
  if (open) return 'en séance';
  if (!day) return 'à la dernière séance';
  const today = todayISO();
  if (day === today) return 'à la clôture';
  if (day === fePrevDayISO(today)) return 'à la clôture d’hier';
  const wd = new Date(day + 'T12:00:00Z').getUTCDay();
  return Number.isFinite(wd) ? `à la clôture de ${FR_WEEKDAYS_LONG[(wd + 6) % 7]}` : 'à la dernière séance';
}

// Instant de clôture (16:00, heure de New York) de la séance d'un jour civil NY.
function feUsCloseMs(day) {
  try { return Date.parse(zonedToUtcISO(day, '16:00', 'America/New_York')); } catch (e) { return NaN; }
}

// Marché de l'or (contrats du CME) fermé ? Week-end, du vendredi 16:00 au
// dimanche 17:00, heure de Chicago, et séances que le CME Globex ne tient pas
// (1er janvier, Vendredi saint, Noël) : isCMEOpen, source unique des marchés.
// La pause quotidienne 16:00–17:00 du CME n'est pas une fermeture : on ne
// conclut « fermé » que si le CME l'était déjà une heure plus tôt (la première
// heure après la clôture du vendredi reste donc « ouverte » : la variation du
// jour y est encore celle de la séance qui vient de finir).
function feGoldMarketClosed(nowMs) {
  try {
    const now = Number.isFinite(nowMs) ? nowMs : Date.now();
    return !isCMEOpen(new Date(now)) && !isCMEOpen(new Date(now - 3600e3));
  } catch (e) { return false; }
}

// Marché des changes fermé ? Mêmes bornes que l'or (week-end, 1er janvier,
// Noël), à une exception près : le Vendredi saint, le CME ne cote pas mais le
// change au comptant s'échange encore (volumes réduits) ; il n'est donc pas
// annoncé fermé ce jour-là. Le Vendredi saint tombe toujours en mars ou en
// avril, jamais les deux autres fermetures.
function feFxMarketClosed(nowMs) {
  try {
    const now = Number.isFinite(nowMs) ? nowMs : Date.now();
    const closedAt = (ms) => {
      const d = new Date(ms);
      if (isCMEOpen(d)) return false;
      const t = fpWeekdayMinInTz('America/Chicago', d);
      // Week-end ou pause quotidienne : fermé comme le CME.
      if (t.wd === 6 || (t.wd === 0 && t.min < 1020) || (t.wd === 5 && t.min >= 960) || (t.min >= 960 && t.min < 1020)) return true;
      // Séance fériée du CME : seule celle du Vendredi saint laisse le change ouvert.
      const day = localDayInTz(d, 'America/Chicago');
      const sessionDay = t.min >= 1020 ? feNextDayISO(day) : day;
      const month = Number(String(sessionDay).slice(5, 7));
      return !(month === 3 || month === 4);
    };
    return closedAt(now) && closedAt(now - 3600e3);
  } catch (e) { return false; }
}

// Jour civil 'YYYY-MM-DD' suivant (date civile, sans projection).
function feNextDayISO(day) {
  const t = Date.parse(day + 'T12:00:00Z');
  return Number.isFinite(t) ? new Date(t + 86400e3).toISOString().slice(0, 10) : '';
}

// Nettoie une entrée quote { price, chgPct, ts, session? } — null si inexploitable.
function feQuoteClean(q) {
  if (!q || typeof q !== 'object') return null;
  const price = feNum(q.price);
  const chgPct = feNum(q.chgPct);
  if (price == null || chgPct == null) return null;
  const out = { price, chgPct, ts: feTsMs(q.ts) };
  if (q.session === 'cash') out.session = 'cash';
  return out;
}

// Nettoie un tableau de klines [{t,c}] → trié, t normalisé en ms, ≥2 points, sinon null.
function feKlinesClean(arr) {
  if (!Array.isArray(arr)) return null;
  const pts = [];
  for (const p of arr) {
    const t = feTsMs(p && p.t);
    const c = feNum(p && p.c);
    if (t != null && c != null) pts.push({ t, c });
  }
  if (pts.length < 2) return null;
  pts.sort((a, b) => a.t - b.t);
  return pts;
}

// Garde-fou de FRAÎCHEUR côté moteur (compatible ancien worker) : le worker peut
// servir un payload KV stale-if-error SANS borne d'âge (incident upstream total)
// — sans ce plafond, une cotation de vendredi soir serait scorée « aujourd'hui »
// avec drivers ±3 pleins. Une quote/kline dont le ts dépasse ce plafond est
// écartée ; tout écarté → null → repli client → degradedSources 'cotations
// du jour' + conviction ≤2 (la mécanique d'honnêteté existante fait le reste).
const FE_QUOTE_MAX_AGE = 60 * 60 * 1000;   // 60 min
function feQuoteFresh(ts) {
  // ts absent = fraîcheur inconnue → on garde (repli CBOE client sans ts) ;
  // ts connu et trop vieux → écarté.
  return ts == null || (Number.isFinite(ts) && Date.now() - ts <= FE_QUOTE_MAX_AGE);
}

// 1) Route worker — contrat { updatedAt, quotes:{spx,ndx,dji,rut,vix,eurusd,
//    gbpusd,audusd,gold}, klines:{eurusd,gbpusd,audusd,gold:[{t,c}]} }.
//    `quotesRawP` : promesse de la réponse de l'analyse en cours (feFetchQuotesRaw).
async function feFetchQuotesWorker(quotesRawP) {
  const raw = await (quotesRawP || feFetchQuotesRaw());
  if (!raw || typeof raw !== 'object' || !raw.quotes || typeof raw.quotes !== 'object') return null;
  // Borne globale : un payload dont updatedAt dépasse le plafond est un stale
  // intégral (stale-if-error sans refresh partiel) → on le refuse en bloc.
  const upAt = feTsMs(raw.updatedAt);
  if (upAt != null && Date.now() - upAt > FE_QUOTE_MAX_AGE) return null;
  const quotes = {};
  let any = false;
  for (const k of ['spx', 'ndx', 'dji', 'rut', 'vix', 'eurusd', 'gbpusd', 'audusd', 'gold']) {
    const q = feQuoteClean(raw.quotes[k]);
    if (q && feQuoteFresh(q.ts)) { quotes[k] = q; any = true; }
  }
  const klines = {};
  const kl = (raw.klines && typeof raw.klines === 'object') ? raw.klines : {};
  for (const k of ['eurusd', 'gbpusd', 'audusd', 'gold']) {
    const pts = feKlinesClean(kl[k]);
    if (pts && feQuoteFresh(pts[pts.length - 1].t)) klines[k] = pts;
  }
  return any ? { quotes, klines } : null;
}

// Depuis des bougies 15 min [{t ms, o, c}] triées : quote + klines.
// chgPct = depuis l'OPEN du jour UTC courant (repli : première bougie de la
// fenêtre ~24 h — carnet creux sans bougie depuis minuit UTC), même convention
// que le worker. Ce chgPct ne sert plus qu'au REPLI de feDayMoves : la
// variation « aujourd'hui » affichée et scorée se mesure sur les klines depuis
// minuit dans le fuseau du profil.
function feIntradayFromCandles(candles) {
  if (!Array.isArray(candles) || candles.length < 2) return null;
  const last = candles[candles.length - 1];
  if (!last || !Number.isFinite(last.c)) return null;
  const midnight = feUtcMidnight();
  let base = null;
  for (const p of candles) {
    if (p.t >= midnight) { base = Number.isFinite(p.o) ? p.o : p.c; break; }
  }
  if (!Number.isFinite(base)) {
    const p0 = candles[0];
    base = Number.isFinite(p0.o) ? p0.o : p0.c;
  }
  if (!Number.isFinite(base) || base === 0) return null;
  return {
    // ts aligné sur la convention worker : fin de bougie bornée par maintenant —
    // last.c est le cours LIVE de la bougie en cours (last.t = son OUVERTURE,
    // jusqu'à 15 min pessimiste), et reste honnête pour un carnet creux dont la
    // dernière bougie est ancienne.
    quote: { price: last.c, chgPct: ((last.c - base) / base) * 100, ts: Math.min(Date.now(), last.t + 15 * 60 * 1000) },
    klines: candles.map(p => ({ t: p.t, c: p.c })),
  };
}

// Binance 15 min (EURUSDT, PAXGUSDT) — klines = [[openTimeMs,"open",…,"close",…]].
async function feFetchBinance15m(sym) {
  const raw = await feFetchJSON(FE_BINANCE_15M(sym), FE_DIRECT_TIMEOUT);
  if (!Array.isArray(raw) || raw.length < 2) return null;
  const candles = [];
  for (const k of raw) {
    const t = feTsMs(k && k[0]);
    const o = feNum(k && k[1]);
    const c = feNum(k && k[4]);
    if (t != null && c != null) candles.push({ t, o, c });
  }
  candles.sort((a, b) => a.t - b.t);
  return feIntradayFromCandles(candles);
}

// Kraken OHLC 15 min — result.<CLÉ> = [[ts SECONDES, open, high, low, close, vwap, vol, count]].
// Clés vérifiées : ZEURZUSD / ZGBPZUSD / AUDUSD — on prend la première clé ≠ 'last'.
async function feFetchKraken15m(pair) {
  const raw = await feFetchJSON(FE_KRAKEN_OHLC(pair), FE_DIRECT_TIMEOUT);
  const res = raw && raw.result;
  if (!res || typeof res !== 'object') return null;
  let arr = null;
  for (const k of Object.keys(res)) {
    if (k !== 'last' && Array.isArray(res[k])) { arr = res[k]; break; }
  }
  if (!arr || arr.length < 2) return null;
  const candles = [];
  for (const k of arr.slice(-96)) {           // fenêtre ~24 h, ≤96 points
    const t = feTsMs(k && k[0]);              // SECONDES → ms via feTsMs
    const o = feNum(k && k[1]);
    const c = feNum(k && k[4]);
    if (t != null && c != null) candles.push({ t, o, c });
  }
  candles.sort((a, b) => a.t - b.t);
  return feIntradayFromCandles(candles);
}

// Lectures directes du repli client, UNE PROMESSE PAR COTE (clé du contrat
// /fond/quotes → source) : euro et or chez Binance, livre et dollar australien
// chez Kraken. Chaque promesse est protégée à la création (jamais de rejet :
// null en cas d'échec) : feFetchQuotes peut ainsi n'attendre que les cotes qui
// lui manquent et laisser les autres finir seules, sans rejet non géré.
const FE_CLIENT_QUOTE_KEYS = ['eurusd', 'gold', 'gbpusd', 'audusd'];
function feClientPairPromises() {
  const safe = (p) => Promise.resolve(p).catch(() => null);
  return {
    eurusd: safe(feFetchBinance15m('EURUSDT')),
    gold:   safe(feFetchBinance15m('PAXGUSDT')),
    gbpusd: safe(feFetchKraken15m('GBPUSD')),
    audusd: safe(feFetchKraken15m('AUDUSD')),
  };
}

// Range une lecture directe { quote, klines } dans quotes/klines sous `key`.
// Règles communes au repli complet et au complément d'un worker partiel :
// cote absente ou trop vieille (carnet creux) → rien n'est écrit, false ;
// les bougies sont celles de la même source que la cote, jamais un mélange
// (bougies d'une autre source retirées si la lecture n'en apporte pas).
function feApplyClientPair(quotes, klines, key, r) {
  if (!r || !r.quote) return false;
  if (!feQuoteFresh(r.quote.ts)) return false;
  quotes[key] = r.quote;
  if (Array.isArray(r.klines) && r.klines.length >= 2) klines[key] = r.klines;
  else delete klines[key];
  return true;
}

// 2) Repli client complet (ancien worker → /fond/quotes 404). `indicesP` est la
//    promesse des indices déjà lancée par l'orchestrateur (feFetchIndices).
//    `pairP` (optionnel) : lectures directes déjà en vol (feClientPairPromises) ;
//    lancées ici si elles ne sont pas fournies.
async function feFetchQuotesClient(indicesP, pairP) {
  const pairs = pairP || feClientPairPromises();
  const settled = await Promise.allSettled(
    [Promise.resolve(indicesP)].concat(FE_CLIENT_QUOTE_KEYS.map(k => pairs[k]))
  );
  const val = (i) => (settled[i] && settled[i].status === 'fulfilled' ? settled[i].value : null);
  const idx = val(0);

  const quotes = {};
  const klines = {};
  if (idx && !idx.degraded) {
    for (const [key, obj] of [['ndx', idx.NDX], ['dji', idx.DJI], ['vix', idx.VIX]]) {
      const price = feNum(obj && obj.current_price);
      const chgPct = feNum(obj && obj.price_change_percent);
      // PAS de ts fabriqué : la cote CBOE delayed peut porter la variation de la
      // VEILLE avant l'ouverture — un ts inventé (now) la ferait passer pour une
      // réaction fraîche (branche VIX-réaction). ts absent = fraîcheur inconnue.
      if (price != null && chgPct != null) quotes[key] = { price, chgPct, session: 'cash' };
    }
  }
  FE_CLIENT_QUOTE_KEYS.forEach((key, i) => { feApplyClientPair(quotes, klines, key, val(i + 1)); });
  return Object.keys(quotes).length ? { quotes, klines } : null;
}

// Chaîne complète : worker ET lectures directes lancés EN PARALLÈLE (worker
// injoignable = 6 s d'abort ; en séquence l'étape 1 pouvait durer ~14 s).
//   • worker muet → repli client complet (lectures déjà en vol, il ne repart
//     pas de zéro) ;
//   • worker complet → son résultat, sans attendre Binance ni Kraken ;
//   • worker PARTIEL (cas réel : il ne fournit ni l'EUR/USD ni l'or) → son
//     résultat COMPLÉTÉ par les lectures directes des SEULES cotes qui lui
//     manquent (cote + bougies de la même source). On n'attend que celles-là :
//     l'euro et l'or viennent de Binance, inutile d'attendre Kraken (jusqu'à
//     8 s sur un réseau où il est lent ou filtré) pour une livre et un dollar
//     australien que le worker a déjà fournis.
// Les indices (ndx, dji, vix) ne sont JAMAIS repris du repli client : ce sont
// les cotes de la même réponse worker, relayées sans date.
// Les cotes directes passent le même filtre de fraîcheur (feApplyClientPair).
// null si tout échoue (→ degradedSources 'cotations du jour', conviction Jour
// plafonnée à 2).
async function feFetchQuotes(indicesP, quotesRawP) {
  const pairP = feClientPairPromises();
  let w = null;
  try { w = await feFetchQuotesWorker(quotesRawP); } catch (e) { w = null; }
  if (!w) {
    try { return await feFetchQuotesClient(indicesP, pairP); } catch (e) { return null; }
  }
  const manquants = FE_CLIENT_QUOTE_KEYS.filter(k => !w.quotes[k]);
  if (!manquants.length) return w;
  const lus = await Promise.all(manquants.map(k => pairP[k]));
  manquants.forEach((k, i) => { feApplyClientPair(w.quotes, w.klines, k, lus[i]); });
  return w;
}

// ═══════════════════════════════════════════════════════════════════════════
//  ÉTAPE 4 — NEWS (mots-clés par catégorie)
// ═══════════════════════════════════════════════════════════════════════════

// Listes de mots-clés (EN + FR, flux mixtes). Matching insensible à la casse.
// Chaque catégorie a des termes « hausse » et « baisse » du point de vue de SON biais.
const FE_KEYWORDS = {
  USD: {
    up: [   // dollar plus fort
      'hawkish', 'rate hike', 'higher rates', 'sticky inflation', 'hot inflation', 'strong jobs',
      'fed holds', 'restrictive', 'dollar rises', 'dollar strengthens', 'dxy up',
      'hausse des taux', 'inflation forte', 'inflation persistante', 'ton restrictif',
      'dollar fort', 'dollar se renforce', 'fed prudente',
    ],
    down: [ // dollar plus faible
      'dovish', 'rate cut', 'rate cuts', 'lower rates', 'cooling inflation', 'disinflation',
      'weak jobs', 'soft data', 'dollar falls', 'dollar weakens', 'dxy down', 'pivot',
      'baisse des taux', 'désinflation', 'inflation ralentit', 'ton accommodant',
      'dollar faible', 'dollar recule', 'assouplissement',
    ],
  },
  EUR: {
    up: [
      'ecb hike', 'ecb hawkish', 'euro rises', 'euro strengthens', 'eurozone resilient',
      'strong eurozone', 'lagarde hawkish',
      'bce relève', 'hausse taux bce', 'euro se renforce', 'zone euro résiliente', 'euro fort',
    ],
    down: [
      'ecb cut', 'ecb dovish', 'euro falls', 'euro weakens', 'eurozone recession', 'weak eurozone',
      'lagarde dovish', 'german slowdown',
      'bce baisse', 'baisse taux bce', 'euro recule', 'récession zone euro', 'euro faible',
      'ralentissement allemand',
    ],
  },
  AUD: {
    up: [
      'rba hike', 'rba hawkish', 'aussie rises', 'aud strengthens', 'china stimulus',
      'strong china', 'iron ore rises', 'commodities rally', 'risk-on',
      'rba relève', 'dollar australien se renforce', 'relance chinoise', 'matières premières en hausse',
    ],
    down: [
      'rba cut', 'rba dovish', 'aussie falls', 'aud weakens', 'china slowdown', 'weak china',
      'iron ore falls', 'commodities drop', 'risk-off',
      'rba baisse', 'dollar australien recule', 'ralentissement chinois', 'matières premières en baisse',
    ],
  },
  GOLD: {
    up: [   // refuge / géopolitique → or en hausse
      'safe haven', 'geopolitical', 'war', 'conflict', 'tensions', 'escalation', 'gold rises',
      'gold rally', 'record high gold', 'central bank buying', 'inflation hedge', 'flight to safety',
      'valeur refuge', 'géopolitique', 'guerre', 'conflit', 'tensions', 'escalade', 'or en hausse',
      'or record', 'achats banques centrales', 'ruée vers l’or',
    ],
    down: [ // détente / dollar fort → or en baisse
      'risk-on', 'gold falls', 'gold slips', 'stronger dollar', 'higher yields', 'rally in stocks',
      'de-escalation', 'ceasefire', 'easing tensions',
      'or recule', 'détente', 'cessez-le-feu', 'rendements en hausse', 'apaisement',
    ],
  },
  INDICES: {
    up: [
      'stocks rally', 'record high', 'earnings beat', 'strong earnings', 'soft landing', 'risk-on',
      'rally', 'rebound', 'tech surge', 'melt-up', 'trade deal', 'de-escalation',
      'bourse en hausse', 'records', 'résultats solides', 'atterrissage en douceur', 'rebond',
      'accord commercial', 'détente',
    ],
    down: [
      'stocks fall', 'selloff', 'sell-off', 'tariffs', 'trade war', 'recession', 'earnings miss',
      'profit warning', 'crash', 'plunge', 'risk-off', 'correction', 'sanctions', 'escalation',
      'bourse en baisse', 'chute', 'tarifs', 'droits de douane', 'guerre commerciale', 'récession',
      'avertissement', 'krach', 'plongeon', 'escalade',
    ],
  },
};

// Termes de sélection : quelles headlines concernent quelle catégorie (pour l'affichage headlines[]).
const FE_TOPIC_TERMS = {
  USD:     ['fed', 'dollar', 'dxy', 'powell', 'treasury', 'us inflation', 'cpi', 'jobs', 'fomc', 'réserve fédérale', 'taux', 'inflation'],
  EUR:     ['ecb', 'euro', 'eurozone', 'lagarde', 'bce', 'zone euro', 'allemagne', 'germany'],
  AUD:     ['rba', 'aussie', 'australia', 'australian', 'china', 'chine', 'iron ore', 'commodities', 'matières premières', 'australie'],
  GOLD:    ['gold', 'or', 'bullion', 'xau', 'précieux', 'refuge', 'safe haven'],
  INDICES: ['stocks', 'nasdaq', 'dow', 's&p', 'wall street', 'equities', 'earnings', 'vix', 'bourse', 'actions', 'indices', 'résultats', 'tarifs', 'tariffs'],
};

// Matching de terme avec FRONTIÈRES DE MOT — pour les termes d'un seul mot
// (évite « or » dans « revalorisation »/« Ormuz », « war » dans « warning »)
// ET pour les expressions multi-mots (évite « rate cut » à cheval dans
// « corpoRATE CUTbacks ») : l'expression est échappée puis ses espaces
// deviennent \s+, avec les mêmes gardes unicode de début/fin de mot.
const _feTermReCache = new Map();
function feTermMatch(haystack, term) {
  let re = _feTermReCache.get(term);
  if (re === undefined) {
    const esc = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(/\s+/g, '\\s+');
    // Frontières unicode-safe : début/fin ou caractère non lettre/chiffre.
    try { re = new RegExp('(^|[^\\p{L}\\p{N}])' + esc + '(?=[^\\p{L}\\p{N}]|$)', 'iu'); }
    catch (e) { re = null; }
    _feTermReCache.set(term, re);
  }
  return re ? re.test(haystack) : haystack.includes(term);
}

// Analyse les news pour une catégorie : renvoie { score, hits:[{title,source,time,link}], recent }.
// score = somme des directions, PONDÉRÉES PAR LA FRAÎCHEUR (v2.2) : un item de
// moins de 6 h compte DOUBLE, 6-24 h normal, plus de 24 h MOITIÉ ; un item sans
// timestamp exploitable garde un poids neutre (1). Les titres (feFetchNews)
// portent `ts` en ms (ou null) — on reste défensif.
// `recent` = true si la tonalité directionnelle vient majoritairement d'items <6 h
// (les drivers presse mentionnent alors « dernières heures »).
// Chaque headline ne compte qu'une fois (première correspondance up/down).
function feNewsFor(news, catId, maxHeadlines = 3) {
  const kw = FE_KEYWORDS[catId];
  const topic = FE_TOPIC_TERMS[catId] || [];
  if (!Array.isArray(news) || !news.length) return { score: 0, hits: [], recent: false };

  const now = Date.now();
  let score = 0;
  let nbDir = 0;        // headlines directionnelles (up ou down)
  let nbDirFresh = 0;   // dont datées de moins de 6 h
  const hits = [];
  for (const n of news) {
    const h = String(n && n.headline || '').toLowerCase();
    if (!h) continue;

    // Direction : la headline penche-t-elle up ou down pour cette catégorie ?
    let dir = 0;
    if (kw) {
      const isUp = kw.up.some(t => feTermMatch(h, t));
      const isDown = kw.down.some(t => feTermMatch(h, t));
      // Si les deux (rare) → on annule ; sinon on prend la direction trouvée.
      if (isUp && !isDown) dir = 1;
      else if (isDown && !isUp) dir = -1;
    }

    // Pondération par l'âge (v2.2) — uniquement si le ts est fini et pas futur
    // (horloge/flux aberrant → poids neutre, jamais de bonus indu).
    if (dir !== 0) {
      let poids = 1;
      const ts = feNum(n && n.ts);
      const fresh = ts != null && ts <= now && (now - ts) < 6 * 3600e3;
      if (ts != null && ts <= now) {
        const age = now - ts;
        if (age < 6 * 3600e3) poids = 2;          // très récent : compte double
        else if (age > 24 * 3600e3) poids = 0.5;  // vieux de plus d'un jour : moitié
      }
      score += dir * poids;
      nbDir++;
      if (fresh) nbDirFresh++;
    }

    // Sélection pour affichage : la headline touche-t-elle le sujet (topic ou direction non nulle) ?
    const onTopic = dir !== 0 || topic.some(t => feTermMatch(h, t));
    if (onTopic && hits.length < maxHeadlines) {
      hits.push({
        title: String(n.headline || '').trim(),
        source: String(n.source || '').trim(),
        time: String(n.time || '').trim(),
        link: String(n.link || '').trim(),
      });
    }
  }
  // « récent » : au moins un item directionnel <6 h ET au moins la moitié du flux directionnel.
  return { score, hits, recent: nbDirFresh > 0 && nbDirFresh * 2 >= nbDir };
}

// ═══════════════════════════════════════════════════════════════════════════
//  SCORING PAR CATÉGORIE
// ═══════════════════════════════════════════════════════════════════════════
//
// Convention de score : positif = biais HAUSSIER de la catégorie, négatif = baissier.
// Chaque driver est une phrase FR complète, chiffrée, avec un `tone` et un `kind`.
// Aucune valeur brute non formatée ne remonte.

// ── Pondération v2 des kinds de drivers : marchés > positionnement > taux >
//    sentiment > calendrier > actualité. Sert au TRI d'affichage (les drivers les
//    plus « lourds » d'abord) ; les contributions au score suivent la même logique
//    (marché ±1..2, positionnement/taux/sentiment ±1, actualité plafonnée ±1).
const FE_KIND_WEIGHT = {
  'marché': 0, 'marche': 0,          // tolérance ancien libellé
  'positionnement': 1,
  'taux': 2,
  'sentiment': 3,
  'calendrier': 4,
  'actualité': 5, 'news': 5,         // tolérance ancien libellé
};
function feKindWeight(kind) {
  const w = FE_KIND_WEIGHT[kind];
  return (typeof w === 'number') ? w : 6;
}

// Décrit l'évolution du positionnement net des fonds (COT) en français.
// net ≥ 0 = position nette longue ; delta > 0 = les fonds achètent.
function feCotPhrase(net, delta) {
  if (delta > 0) return net >= 0 ? 'renforcent leurs positions longues' : 'réduisent leurs positions courtes';
  return net >= 0 ? 'allègent leurs positions longues' : 'renforcent leurs positions courtes';
}

// Construit un driver COT { text, tone, kind:'positionnement', contrib } | null.
// `labelFR` : nom du sous-jacent (« EUR », « or », « Nasdaq »…).
// `invert` : lecture MIROIR pour le dollar (les fonds achètent l'EUR → pression baissière USD).
// Seuil : |delta| < 1500 contrats → driver informatif neutre, sans contribution.
function feCotDriverFor(m, labelFR, invert) {
  if (!m || !Number.isFinite(m.net)) return null;
  const net = m.net;
  const delta = Number.isFinite(m.prevNet) ? (net - m.prevNet) : null;
  const netTxt = `net ${fmtNum(net, 0, true)} contrats`;
  if (delta == null || Math.abs(delta) < 1500) {
    return {
      text: `COT ${labelFR}\u00A0: positionnement des fonds stable (${netTxt})`,
      tone: 'neutre', kind: 'positionnement', contrib: 0,
    };
  }
  const dTxt = `${fmtNum(delta, 0, true)} sur la semaine`;
  let tone = delta > 0 ? 'hausse' : 'baisse';
  let text = `COT ${labelFR}\u00A0: les fonds ${feCotPhrase(net, delta)} (${netTxt}, ${dTxt})`;
  if (invert) {
    tone = (tone === 'hausse') ? 'baisse' : 'hausse';
    text += (tone === 'baisse') ? ' — pression baissière sur le dollar' : ' — soutien indirect au dollar';
  }
  return { text, tone, kind: 'positionnement', contrib: tone === 'hausse' ? 1 : -1 };
}

// Densité d'événements High à venir pour une devise (composante « risque », neutre sur le biais).
function feHighCount(events, currency) {
  return events.filter(e => !e.passed && e.country === currency && /high/i.test(e.impact)).length;
}

// Construit un driver calendrier « direction attendue » quand forecast et previous sont numériques.
// Ex : CPI attendu 2,9% après 3,1% = désinflation attendue. Renvoie un driver ou null.
function feForecastDriver(events, currency, upIfLower) {
  // On cherche un événement High de la devise avec forecast & previous numériques.
  for (const e of events) {
    if (e.passed || e.country !== currency || !/high/i.test(e.impact)) continue;
    const f = feParseCalNum(e.forecast);
    const p = feParseCalNum(e.previous);
    // Unités différentes ('950K' vs '1.0M') → comparaison inversable : on s'abstient.
    if (!f || !p || f.unit !== p.unit || f.n === p.n) continue;
    const lower = f.n < p.n;
    // upIfLower : true pour l'inflation (prévision plus basse = désinflation = biais devise… dépend).
    // On reste factuel : on décrit l'écart, le signe du score est géré par l'appelant.
    const sens = lower ? 'sous le' : 'au-dessus du';
    const txt = `${feEvTitle(e)} attendu à ${feCalNumTxt(f)}, ${sens} précédent (${feCalNumTxt(p)})`;
    const delta = lower ? -1 : 1;               // brut : plus haut = plus « chaud »
    return { text: txt, delta, tone: 'neutre', kind: 'calendrier' };
  }
  return null;
}

// ── USD ──────────────────────────────────────────────────────────────────────
function feScoreUSD(data) {
  const { fx, indices, cal, newsRes, mkts, cot } = data;
  const drivers = [];
  let score = 0;

  // v2-1) Indice dollar large (FRED DTWEXBGS, ~5 séances) : la mesure la plus directe.
  //       Seuils resserrés (l'indice bouge peu) : ±0,25% = signal, ±0,8% = fort.
  const usdIdxD = mkts ? feSeriesDelta(mkts.usdIdx) : null;
  if (usdIdxD) {
    const p = usdIdxD.pct;
    let contrib = 0;
    if (Math.abs(p) >= 0.8) contrib = p > 0 ? 2 : -2;
    else if (Math.abs(p) >= 0.25) contrib = p > 0 ? 1 : -1;
    score += contrib;
    drivers.push({
      text: `Indice dollar large ${fePct(p)} sur ${usdIdxD.seances} séances (${feFixed(usdIdxD.last, 1)})`,
      tone: feToneOf(contrib),
      kind: 'marché',
      own: contrib,
    });
  }

  // v2-2) Taux : mouvement du 10 ans US en points de base (~5 séances).
  //       Rendements en hausse = soutien au dollar (différentiel de taux).
  const us10D = mkts ? feSeriesDelta(mkts.us10y) : null;
  if (us10D) {
    const bp = Math.round(us10D.diff * 100);
    if (Math.abs(bp) >= 5) {
      score += bp > 0 ? 1 : -1;
      drivers.push({
        text: `10 ans US à ${fmtPct(us10D.last, 2)} (${feBp(bp)} sur ${us10D.seances} séances)`,
        tone: bp > 0 ? 'hausse' : 'baisse',
        kind: 'taux',
      });
    }
  }

  // v2-3) Pente 2s10s : signalée seulement si inversion notable (informatif, sans score).
  if (mkts) {
    const l10 = feSeriesLast(mkts.us10y);
    const l02 = feSeriesLast(mkts.us02y);
    if (l10 != null && l02 != null) {
      const pente = l10 - l02;
      if (pente <= -0.1) {
        drivers.push({
          text: `Courbe 2–10 ans inversée (${feFixed(pente, 2)}\u00A0pt) — indicateur de cycle à surveiller`,
          tone: 'neutre',
          kind: 'taux',
        });
      }
    }
  }

  // v2-4) Positionnement COT EUR lu en MIROIR du dollar.
  const cotUsd = feCotDriverFor(cot && cot.eur, 'EUR', true);
  if (cotUsd) {
    score += cotUsd.contrib;
    drivers.push({ text: cotUsd.text, tone: cotUsd.tone, kind: cotUsd.kind });
  }

  // 1) Momentum FX : USD vs EUR et AUD. USD/EUR qui monte = dollar fort. (socle v1)
  if (fx) {
    for (const [sym, obj] of [['EUR', fx.EUR], ['AUD', fx.AUD]]) {
      if (!obj) continue;
      const p = obj.pct;
      // Seuils : >±0,35% = signal, >±0,9% = fort.
      let contrib = 0;
      if (Math.abs(p) >= 0.9) contrib = p > 0 ? 2 : -2;
      else if (Math.abs(p) >= 0.35) contrib = p > 0 ? 1 : -1;
      score += contrib;
      if (Math.abs(p) >= 0.35) {
        // Comme l'indice dollar large, ces paires mesurent le prix du dollar
        // lui-même (`own`, voir feFinalize).
        drivers.push({
          text: `Dollar ${fePct(p)} par rapport ${sym === 'EUR' ? 'à l’euro' : 'au dollar australien'} sur ${obj.seances} séances`,
          tone: feToneOf(contrib),
          kind: 'marché',
          own: contrib,
        });
      }
    }
  }

  // 2) Densité d'événements USD High à venir (risque, léger poids sur la conviction, neutre biais).
  const highUSD = feHighCount(cal, 'USD');
  if (highUSD > 0) {
    drivers.push({
      text: `${highUSD} publication${highUSD > 1 ? 's' : ''} USD à fort impact à venir`,
      tone: 'neutre',
      kind: 'calendrier',
    });
  }

  // 2bis) Direction ATTENDUE du prochain chiffre USD (forecast vs previous) — driver
  // purement informatif (tone neutre, pas d'impact sur le score : l'interprétation
  // dépend du type d'événement, on reste factuel).
  const fcast = feForecastDriver(cal, 'USD');
  if (fcast) drivers.push({ text: fcast.text, tone: 'neutre', kind: 'calendrier' });

  // 3) News hawkish/dovish. Pondération v2 : l'actualité pèse moins que les
  //    données de marché → contribution plafonnée à ±1 (±2 en v1).
  //    v2.2 : mention de la fraîcheur quand la tonalité vient d'items récents (<6 h).
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraich = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Tonalité presse${fraich} plutôt restrictive (soutien au dollar)`
        : `Tonalité presse${fraich} plutôt accommodante (pression sur le dollar)`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse',
      kind: 'actualité',
    });
  }

  // Aucune mesure du prix du dollar (ni indice large ni devises) : données
  // partielles, conviction plafonnée à 2.
  return feFinalize('USD', score, drivers, cal, newsRes, !usdIdxD && !fx, undefined, 3);
}

// ── EUR / AUD (miroir du momentum de leur paire vs USD) ───────────────────────
function feScoreFxPair(catId, data) {
  const sym = catId;                         // 'EUR' | 'AUD'
  const { fx, cal, newsRes, mkts, cot } = data;
  const drivers = [];
  let score = 0;

  // Momentum : USD/sym qui MONTE = sym FAIBLE → biais baissier pour sym. On inverse. (socle v1)
  const obj = fx && fx[sym];
  if (obj) {
    const symMove = -obj.pct;                 // variation implicite de sym vs USD
    let contrib = 0;
    if (Math.abs(symMove) >= 0.9) contrib = symMove > 0 ? 2 : -2;
    else if (Math.abs(symMove) >= 0.35) contrib = symMove > 0 ? 1 : -1;
    score += contrib;
    if (Math.abs(symMove) >= 0.35) {
      drivers.push({
        text: `${FE_LABELS[sym]} ${fePct(symMove)} par rapport au dollar américain sur ${obj.seances} séances`,
        tone: feToneOf(contrib),
        kind: 'marché',
        own: contrib,
      });
    }
  }

  // v2-1) Positionnement COT direct de la devise (EUR FX / AUSTRALIAN DOLLAR).
  const cotPair = feCotDriverFor(cot && (sym === 'EUR' ? cot.eur : cot.aud), sym, false);
  if (cotPair) {
    score += cotPair.contrib;
    drivers.push({ text: cotPair.text, tone: cotPair.tone, kind: cotPair.kind });
  }

  // v2-2) Différentiel de taux implicite : un mouvement NOTABLE des rendements US
  //       (≥8 pb sur ~5 séances) pèse sur la devise face au dollar (et inversement).
  const y10Pair = mkts ? feSeriesDelta(mkts.us10y) : null;
  if (y10Pair) {
    const bp = Math.round(y10Pair.diff * 100);
    if (Math.abs(bp) >= 8) {
      score += bp > 0 ? -1 : 1;
      drivers.push({
        text: bp > 0
          ? `Rendements US en hausse (${feBp(bp)} sur ${y10Pair.seances} séances) — différentiel de taux défavorable`
          : `Rendements US en baisse (${feBp(bp)} sur ${y10Pair.seances} séances) — différentiel de taux favorable`,
        tone: bp > 0 ? 'baisse' : 'hausse',
        kind: 'taux',
      });
    }
  }

  // Leurs événements (EUR/BCE, AUD/RBA).
  const highN = feHighCount(cal, sym);
  if (highN > 0) {
    drivers.push({
      text: `${highN} publication${highN > 1 ? 's' : ''} ${sym} à fort impact à venir`,
      tone: 'neutre',
      kind: 'calendrier',
    });
  }

  // Direction attendue du prochain chiffre (forecast vs previous) — informatif.
  const fcastP = feForecastDriver(cal, sym);
  if (fcastP) drivers.push({ text: fcastP.text, tone: 'neutre', kind: 'calendrier' });

  // News. Pondération v2 : contribution plafonnée à ±1.
  // v2.2 : mention de la fraîcheur quand la tonalité vient d'items récents (<6 h).
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraichP = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Tonalité presse${fraichP} plutôt favorable au ${FE_LABELS[sym].toLowerCase()}`
        : `Tonalité presse${fraichP} plutôt défavorable au ${FE_LABELS[sym].toLowerCase()}`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse',
      kind: 'actualité',
    });
  }

  // Paire absente : données partielles, conviction plafonnée à 2.
  return feFinalize(sym, score, drivers, cal, newsRes, !obj, undefined, 3);
}

// ── GOLD ──────────────────────────────────────────────────────────────────────
function feScoreGold(data) {
  const { gold, fx, cal, newsRes, mkts, cot } = data;
  const drivers = [];
  let score = 0;

  // 1) Momentum PAXG. (socle v1)
  if (gold) {
    const p = gold.pct;
    let contrib = 0;
    if (Math.abs(p) >= 1.5) contrib = p > 0 ? 2 : -2;
    else if (Math.abs(p) >= 0.5) contrib = p > 0 ? 1 : -1;
    score += contrib;
    // Couleur = sens de la contribution : sous ±0,5 % l'élément ne pèse pas,
    // il reste gris (une pastille colorée laisserait croire qu'il appuie le biais).
    drivers.push({
      text: `Or ${fePct(p)} sur ${gold.seances} séances`,
      tone: feToneOf(contrib),
      kind: 'marché',
      own: contrib,
    });
  }

  // 2) Composante inverse-dollar : dollar fort = vent contraire pour l'or.
  //    v2 : on privilégie l'indice dollar large FRED (mesure directe) ; à défaut,
  //    on garde l'approximation v1 (moyenne des variations USD/EUR & USD/AUD).
  const usdIdxGold = mkts ? feSeriesDelta(mkts.usdIdx) : null;
  if (usdIdxGold && Math.abs(usdIdxGold.pct) >= 0.25) {
    score += usdIdxGold.pct > 0 ? -1 : 1;
    drivers.push({
      text: usdIdxGold.pct > 0
        ? `Indice dollar large ${fePct(usdIdxGold.pct)} sur ${usdIdxGold.seances} séances — vent contraire pour l’or`
        : `Indice dollar large ${fePct(usdIdxGold.pct)} sur ${usdIdxGold.seances} séances — soutien pour l’or`,
      tone: usdIdxGold.pct > 0 ? 'baisse' : 'hausse',
      kind: 'marché',
    });
  } else if (!usdIdxGold && fx) {
    const parts = [fx.EUR, fx.AUD].filter(Boolean).map(o => o.pct);
    if (parts.length) {
      const usdMove = parts.reduce((a, b) => a + b, 0) / parts.length;
      if (Math.abs(usdMove) >= 0.35) {
        // Dollar fort (usdMove>0) → -1 sur l'or ; dollar faible → +1.
        score += usdMove > 0 ? -1 : 1;
        drivers.push({
          text: usdMove > 0
            ? `Dollar ferme (${fePct(usdMove)} en moyenne par rapport aux autres devises) — vent contraire`
            : `Dollar en repli (${fePct(usdMove)} en moyenne par rapport aux autres devises) — soutien`,
          tone: usdMove > 0 ? 'baisse' : 'hausse',
          kind: 'marché',
        });
      }
    }
  }

  // v2-1) Taux : le 10 ans US en repli = détente des taux (réels, en approximation
  //       nominale) = soutien pour l'or, et inversement.
  const y10Gold = mkts ? feSeriesDelta(mkts.us10y) : null;
  if (y10Gold) {
    const bp = Math.round(y10Gold.diff * 100);
    if (Math.abs(bp) >= 5) {
      score += bp < 0 ? 1 : -1;
      drivers.push({
        text: bp < 0
          ? `Détente des taux US (${feBp(bp)} sur ${y10Gold.seances} séances) — soutien pour l’or`
          : `Tension des taux US (${feBp(bp)} sur ${y10Gold.seances} séances) — vent contraire pour l’or`,
        tone: bp < 0 ? 'hausse' : 'baisse',
        kind: 'taux',
      });
    }
  }

  // v2-2) Positionnement COT or (Legacy noncomm).
  const cotGold = feCotDriverFor(cot && cot.gold, 'or', false);
  if (cotGold) {
    score += cotGold.contrib;
    drivers.push({ text: cotGold.text, tone: cotGold.tone, kind: cotGold.kind });
  }

  // 3) News (géopolitique / refuge). Pondération v2 : contribution plafonnée à ±1.
  //    v2.2 : mention de la fraîcheur quand la tonalité vient d'items récents (<6 h).
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraichG = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Contexte presse${fraichG} porteur pour la valeur refuge`
        : `Contexte presse${fraichG} de détente — moins de demande refuge`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse',
      kind: 'actualité',
    });
  }

  // L'or n'a pas de calendrier propre → catalystes = événements USD High (moteurs du dollar/taux).
  // Cours de l'or absent : données partielles, conviction plafonnée à 2.
  return feFinalize('GOLD', score, drivers, cal, newsRes, !gold, 'USD', 3);
}

// ── INDICES ────────────────────────────────────────────────────────────────────
function feScoreIndices(data) {
  const { indices, cal, newsRes, mkts, cot, fng } = data;
  const drivers = [];
  let score = 0;
  // Fraîcheur des cotes CBOE : « en séance », « à la clôture de vendredi »…
  const usWhen = data.usWhen || feSessionWhen(feUsCashOpen(), feLastUsSessionDay());

  const cboeOk = !!(indices && !indices.degraded);

  // v2-1) Variations RÉELLES sur ~5 séances via FRED (S&P, Nasdaq, Dow) : un seul
  //       driver compact et chiffré. Contributions : S&P et Nasdaq seulement
  //       (±1 dès ±0,6%, ±2 dès ±2%) — le Dow est affiché mais non compté (triple comptage).
  const spxD = mkts ? feSeriesDelta(mkts.spx) : null;
  const ndqD = mkts ? feSeriesDelta(mkts.ndq) : null;
  const djiD = mkts ? feSeriesDelta(mkts.dji) : null;
  const fredOk = !!(spxD || ndqD || djiD);
  if (fredOk) {
    const bits = [];
    let contrib = 0;   // somme des contributions S&P + Nasdaq réellement comptées
    for (const [name, dd, compte] of [['S&P 500', spxD, true], ['Nasdaq', ndqD, true], ['Dow', djiD, false]]) {
      if (!dd) continue;
      bits.push(`${name} ${fePct(dd.pct)}`);
      if (compte) {
        if (Math.abs(dd.pct) >= 2) contrib += dd.pct > 0 ? 2 : -2;
        else if (Math.abs(dd.pct) >= 0.6) contrib += dd.pct > 0 ? 1 : -1;
      }
    }
    score += contrib;
    const ref = spxD || ndqD || djiD;
    // Couleur = sens de ce qui est réellement compté dans le score.
    drivers.push({
      text: `${bits.join(', ')} sur ${ref.seances} séances`,
      tone: feToneOf(contrib),
      kind: 'marché',
      own: contrib,
    });
  }

  // 1) Quotes intraday CBOE (socle v1) : conservées quand FRED est ABSENT
  //    (sinon redondant — FRED donne la vraie variation multi-séances).
  if (cboeOk && !fredOk) {
    for (const [name, obj] of [['Nasdaq 100', indices.NDX], ['Dow Jones', indices.DJI]]) {
      const p = feNum(obj && obj.price_change_percent);
      if (p == null) continue;
      let contrib = 0;
      if (Math.abs(p) >= 1) contrib = p > 0 ? 2 : -2;
      else if (Math.abs(p) >= 0.3) contrib = p > 0 ? 1 : -1;
      score += contrib;
      drivers.push({
        text: `${name} ${fePct(p)} ${usWhen}`,
        tone: feToneOf(contrib),
        kind: 'marché',
        own: contrib,
      });
    }
  }

  // 2) VIX : priorité au temps réel CBOE (socle v1), sinon niveau FRED (v2).
  //    <14 = risk-on, >20 = risk-off.
  let vixDone = false;
  if (cboeOk) {
    const vixLvl = feNum(indices.VIX && indices.VIX.current_price);
    const vixChg = feNum(indices.VIX && indices.VIX.price_change_percent);
    if (vixLvl != null) {
      vixDone = true;
      let contrib = 0;
      if (vixLvl < 14) contrib += 1;            // volatilité basse = appétit pour le risque
      else if (vixLvl > 20) contrib -= 1;       // volatilité haute = aversion
      if (vixChg != null && Math.abs(vixChg) >= 3) {
        // VIX en hausse = risk-off → pression sur les indices.
        contrib += vixChg > 0 ? -1 : 1;
      }
      score += contrib;
      const chgTxt = (vixChg != null) ? ` (${fePct(vixChg)} ${usWhen})` : '';
      // Couleur = sens de la contribution (niveau ET variation), pas du seul niveau.
      drivers.push({
        text: `VIX ${feFixed(vixLvl)}${chgTxt} — volatilité ${vixLvl < 14 ? 'contenue' : (vixLvl > 20 ? 'élevée' : 'modérée')}`,
        tone: feToneOf(contrib),
        kind: 'marché',
      });
    }
  }
  if (!vixDone && mkts) {
    const vixD = feSeriesDelta(mkts.vix);
    if (vixD) {
      const lvl = vixD.last;
      let contrib = 0;
      if (lvl < 14) contrib += 1;
      else if (lvl > 20) contrib -= 1;
      score += contrib;
      drivers.push({
        text: `VIX ${feFixed(lvl)} (${fePct(vixD.pct)} sur ${vixD.seances} séances) — volatilité ${lvl < 14 ? 'contenue' : (lvl > 20 ? 'élevée' : 'modérée')}`,
        tone: feToneOf(contrib),
        kind: 'marché',
      });
    }
  }

  // v2-2) Sentiment : F&G crypto utilisé comme PROXY d'appétit au risque (étiqueté
  //       honnêtement). ≤30 = appétit dégradé (-1), ≥70 = appétit soutenu (+1).
  if (fng && Number.isFinite(fng.value)) {
    const v = Math.round(fng.value);
    const labKey = String(fng.label || '').toLowerCase().trim();
    const labFR = FE_FNG_FR[labKey] || (labKey || '—');
    let tone = 'neutre';
    if (v <= 30) { tone = 'baisse'; score -= 1; }
    else if (v >= 70) { tone = 'hausse'; score += 1; }
    const prevTxt = Number.isFinite(fng.prev) ? `, précédent ${Math.round(fng.prev)}` : '';
    const prefixe = v <= 30 ? 'Appétit au risque dégradé — ' : (v >= 70 ? 'Appétit au risque soutenu — ' : '');
    drivers.push({
      text: `${prefixe}sentiment crypto (indicateur d’appétit pour le risque) ${v} «\u00A0${labFR}\u00A0»${prevTxt}`,
      tone,
      kind: 'sentiment',
    });
  }

  // v2-3) Positionnement COT sur les futures indices (Nasdaq mini, sinon Dow).
  const cotIdxM = cot ? (cot.nasdaq || cot.dow) : null;
  const cotIdx = feCotDriverFor(cotIdxM, (cot && cot.nasdaq) ? 'Nasdaq' : 'Dow', false);
  if (cotIdx) {
    score += cotIdx.contrib;
    drivers.push({ text: cotIdx.text, tone: cotIdx.tone, kind: cotIdx.kind });
  }

  // 3) Événements USD High à venir = risque pour les indices (neutre sur le biais).
  const highUSD = feHighCount(cal, 'USD');
  if (highUSD > 0) {
    drivers.push({
      text: `${highUSD} publication${highUSD > 1 ? 's' : ''} US à fort impact — risque de volatilité`,
      tone: 'neutre',
      kind: 'calendrier',
    });
  }

  // 4) News (tarifs/guerre/récession → baisse ; earnings solides/détente → hausse).
  //    Pondération v2 : contribution plafonnée à ±1.
  //    v2.2 : mention de la fraîcheur quand la tonalité vient d'items récents (<6 h).
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraichI = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Flux presse${fraichI} plutôt porteur pour les actions`
        : `Flux presse${fraichI} plutôt défavorable aux actions (risque)`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse',
      kind: 'actualité',
    });
  }

  // Dégradé v2 : seulement si CBOE ET FRED manquent tous les deux (conviction
  // plafonnée à 2, scoring calendrier+sentiment+news seulement).
  const degraded = !cboeOk && !fredOk;
  return feFinalize('INDICES', score, drivers, cal, newsRes, degraded, 'USD', 3);
}

// ═══════════════════════════════════════════════════════════════════════════
//  SCORING JOUR v3 — INTRADAY-FIRST (scope 'daily' UNIQUEMENT)
// ═══════════════════════════════════════════════════════════════════════════
// Le biais SEMAINE garde feScoreUSD / feScoreIndices / feScoreGold /
// feScoreFxPair à l'identique. Le chemin JOUR ci-dessous construit d'abord des
// drivers INTRADAY (variations du jour, réaction post-événement), puis fusionne
// les composantes de FOND PLAFONNÉES à ±1 (FRED 5 séances, COT, presse) — c'est
// ce qui règle « NFP très positif, tous les actifs +2,5%, et le biais Jour dit
// encore baissier » : les données DU JOUR mènent, le fond ne fait que nuancer.
// Les règles de conviction v2 (faisceau / contradiction / plafonds) restent
// appliquées par feFinalize par-dessus le nouveau score.

// Contribution ±3 des variations du jour : |0,4|% → 1, |1|% → 2, |1,8|% → 3.
function feIntradayContrib3(p) {
  if (!Number.isFinite(p)) return 0;
  const a = Math.abs(p);
  const m = a >= 1.8 ? 3 : (a >= 1 ? 2 : (a >= 0.4 ? 1 : 0));
  return p >= 0 ? m : -m;
}

// Amplitude de réaction post-événement → 0..2.
// FX/or : |0,3|% → 1, |0,8|% → 2 ; indices : |0,6|% → 1, |1,4|% → 2.
function feReactionMag(p, isIndex) {
  if (!Number.isFinite(p)) return 0;
  const a = Math.abs(p);
  if (isIndex) return a >= 1.4 ? 2 : (a >= 0.6 ? 1 : 0);
  return a >= 0.8 ? 2 : (a >= 0.3 ? 1 : 0);
}

function feToneOf(contrib) { return contrib > 0 ? 'hausse' : (contrib < 0 ? 'baisse' : 'neutre'); }

// Variations « du jour » du change et de l'or (scope daily). « Aujourd'hui »
// = depuis minuit dans le fuseau du profil (charte §4) : mesuré sur les
// bougies 15 min (fenêtre 24 h, qui couvre ce minuit) avec la même règle que la
// réaction post-événement (close de la bougie terminée à minuit → dernier
// cours). Repli honnête si ce minuit n'est pas couvert : la même mesure depuis
// minuit UTC, sinon le chgPct de la source (même référence UTC), avec le
// libellé « depuis 02:00 » / « depuis hier 20:00 » — jamais « aujourd'hui ».
// Renvoie { eurusd, gbpusd, audusd, gold } → { pct, since, price } (clé
// absente si la paire manque). Le texte ET le score lisent ces valeurs.
function feDayMoves(quotes, klines, nowMs) {
  const now = Number.isFinite(nowMs) ? nowMs : Date.now();
  const q = (quotes && typeof quotes === 'object') ? quotes : {};
  const kl = (klines && typeof klines === 'object') ? klines : {};
  let dayStart = NaN;
  let sinceUtc = 'depuis minuit UTC';
  const utc0 = feUtcMidnight(now);
  try {
    const today = todayISO();
    dayStart = Date.parse(zonedToUtcISO(today, '00:00'));
    sinceUtc = utc0 === dayStart ? 'aujourd’hui'
      : `depuis ${localDayInTz(utc0) !== today ? 'hier ' : ''}${tzTimeIn(utc0)}`;
  } catch (e) { /* repli : libellé UTC ci-dessus */ }
  const out = {};
  for (const k of ['eurusd', 'gbpusd', 'audusd', 'gold']) {
    const quote = q[k];
    if (!quote || !Number.isFinite(quote.price)) continue;
    let pct = feReactionFromKlines(kl[k], dayStart);
    let since = 'aujourd’hui';
    if (!Number.isFinite(pct)) {
      pct = feReactionFromKlines(kl[k], utc0);
      since = sinceUtc;
    }
    if (!Number.isFinite(pct)) pct = Number.isFinite(quote.chgPct) ? quote.chgPct : null;
    if (!Number.isFinite(pct)) continue;
    out[k] = { pct, since, price: quote.price };
  }
  return out;
}

// Suffixes « marché fermé » (week-end et séances fériées : feFxMarketClosed
// pour le change, feGoldMarketClosed pour l'or) : les cotes Binance
// et Kraken tournent 24 h/24, mais le marché de référence est fermé.
const FE_FX_CLOSED_TXT = ' — hors séance, marché des changes fermé';
const FE_GOLD_CLOSED_TXT = ' — hors séance, marché de l’or fermé';

// Panier dollar INVERSÉ : moyenne des variations du jour des paires (EUR/USD,
// GBP/USD, AUD/USD) issues de feDayMoves, signe inversé = variation implicite
// du dollar. Toutes les paires moyennées ont la MÊME référence : celles
// mesurées depuis minuit (heure du profil) en priorité, sinon celles du repli.
// { usd, n, since } | null si aucune paire disponible.
function feUsdBasket(moves) {
  const all = ['eurusd', 'gbpusd', 'audusd']
    .map(k => moves && moves[k])
    .filter(m => m && Number.isFinite(m.pct));
  if (!all.length) return null;
  const today = all.filter(m => m.since === 'aujourd’hui');
  const same = today.length ? today : all.filter(m => m.since === all[0].since);
  return {
    usd: -(same.reduce((a, m) => a + m.pct, 0) / same.length),
    n: same.length,
    since: same[0].since,
  };
}

// Variation depuis un événement via klines 15 min [{t ms, c}] : base = close de
// la dernière bougie ENTIÈREMENT CLÔTURÉE avant l'événement (t + 15 min <= evTs)
// → dernier cours. Les publications macro tombent quasi toujours PILE sur une
// frontière de bougie (NFP 14:30, FOMC 20:00…) : la bougie qui OUVRE à evTs
// contient déjà le pic de réaction — sa close est le cours 15 min APRÈS la
// publication. La bougie qui clôt à evTs donne le cours pré-publication exact.
// null si non mesurable (klines vides, aucune bougie close avant evTs, base nulle).
function feReactionFromKlines(pts, evTs) {
  if (!Array.isArray(pts) || pts.length < 2 || !Number.isFinite(evTs)) return null;
  let base = null;
  for (const p of pts) {
    if (p && Number.isFinite(p.t) && p.t + 15 * 60 * 1000 <= evTs) {
      if (Number.isFinite(p.c)) base = p.c;
    } else break;
  }
  if (!Number.isFinite(base) || base === 0) return null;
  const cur = pts[pts.length - 1].c;
  if (!Number.isFinite(cur)) return null;
  return ((cur - base) / base) * 100;
}

// Événement High PASSÉ (0-6 h, flag passed posé par feFetchCalendar en daily)
// le plus RÉCENT pour une devise. null sinon.
function feLastPassedHigh(cal, currency) {
  let best = null;
  for (const e of (Array.isArray(cal) ? cal : [])) {
    if (!e || !e.passed || e.country !== currency || !/high/i.test(e.impact)) continue;
    if (!Number.isFinite(e.ts)) continue;
    if (!best || e.ts > best.ts) best = e;
  }
  return best;
}

// Alias presse par famille d'événements : « Non-Farm Payrolls » se retrouve
// dans des titres qui disent « US jobs »… (feTermMatch mot-frontière derrière).
const FE_EVENT_ALIASES = [
  { re: /non-?farm|payroll|nfp/i,                 terms: ['nfp', 'payroll', 'payrolls', 'jobs', 'emploi', 'employment'] },
  { re: /\bcpi\b|inflation/i,                     terms: ['cpi', 'inflation'] },
  { re: /fomc|fed funds|federal funds|rate decision|interest rate/i, terms: ['fed', 'fomc', 'rates', 'taux', 'rate'] },
  { re: /\bgdp\b/i,                               terms: ['gdp', 'pib', 'growth', 'croissance'] },
  { re: /retail sales/i,                          terms: ['retail', 'ventes'] },
  { re: /unemployment|jobless|claims/i,           terms: ['unemployment', 'jobless', 'claims', 'jobs', 'emploi'] },
  { re: /\bpmi\b|\bism\b/i,                       terms: ['pmi', 'ism'] },
  { re: /\bppi\b/i,                               terms: ['ppi'] },
];

// Au moins un titre de presse < 6 h mentionne-t-il l'événement ? (mots
// significatifs du titre + alias de sa famille, matchés via feTermMatch).
function fePressMentionsEvent(news, ev) {
  if (!Array.isArray(news) || !news.length || !ev) return false;
  const title = String(ev.title || '');
  let terms = title.toLowerCase().split(/[^\p{L}\p{N}-]+/u).filter(w => w.length >= 4);
  for (const a of FE_EVENT_ALIASES) {
    if (a.re.test(title)) terms = terms.concat(a.terms);
  }
  if (!terms.length) return false;
  const now = Date.now();
  for (const n of news) {
    const ts = feNum(n && n.ts);
    if (ts == null || ts > now || now - ts >= 6 * 3600e3) continue;
    const h = String(n.headline || '').toLowerCase();
    if (h && terms.some(t => feTermMatch(h, t))) return true;
  }
  return false;
}

// Driver de RÉACTION post-événement (kind 'calendrier', w:0.5 pour rester
// visible parmi les drivers marché). `reacPct` est DANS LE RÉFÉRENTIEL de la
// catégorie (positif = soutien au biais haussier — déjà inversé pour le dollar).
// Marché ambigu (amplitude sous seuil) → la presse fraîche mentionnant
// l'événement donne le SENS (±1). Renvoie { contrib, driver } | null.
function feBuildReactionDriver(ev, reacPct, detailTxt, isIndex, newsRes, news) {
  if (!ev) return null;
  const timeTxt = feTimeFR(ev.ts) || feWhenFR(ev.date) || '—';
  const mag = feReactionMag(reacPct, isIndex);
  if (mag > 0) {
    const contrib = reacPct > 0 ? mag : -mag;
    return {
      contrib,
      driver: {
        text: `${feEvTitle(ev)} publié à ${timeTxt} — réaction\u00A0: ${detailTxt}`,
        tone: feToneOf(contrib), kind: 'calendrier', w: 0.5,
      },
    };
  }
  // Amplitude sous seuil → sens par la presse fraîche si elle parle de l'événement.
  const pressDir = (newsRes && newsRes.recent && newsRes.score && fePressMentionsEvent(news, ev))
    ? (newsRes.score > 0 ? 1 : -1) : 0;
  if (pressDir) {
    const mesure = Number.isFinite(reacPct) ? ` (réaction de marché mesurée ${detailTxt})` : '';
    return {
      contrib: pressDir,
      driver: {
        text: `${feEvTitle(ev)} publié à ${timeTxt} — presse ${pressDir > 0 ? 'porteuse' : 'défavorable'} dans les dernières heures${mesure}`,
        tone: feToneOf(pressDir), kind: 'calendrier', w: 0.5,
      },
    };
  }
  if (Number.isFinite(reacPct)) {
    return {
      contrib: 0,
      driver: {
        text: `${feEvTitle(ev)} publié à ${timeTxt} — réaction de marché contenue (${detailTxt})`,
        tone: 'neutre', kind: 'calendrier', w: 0.5,
      },
    };
  }
  return null;   // rien de mesurable et pas de presse : pas de driver
}

// ── JOUR · Dollar ────────────────────────────────────────────────────────────
function feScoreDailyUSD(data) {
  const { klines, cal, newsRes, news, mkts, cot, meta } = data;
  const moves = data.moves || {};
  const drivers = [];
  let score = 0;
  let intra = 0;

  // 1) Panier de devises INVERSÉ (EUR/GBP/AUD face à l'USD) → ±3 ; marché
  //    des changes fermé (week-end) → dérive des cotes 24 h/24, plafond ±1.
  const basket = feUsdBasket(moves);
  if (basket) {
    let contrib = feIntradayContrib3(basket.usd);
    if (data.fxClosed) contrib = Math.max(-1, Math.min(1, contrib));
    score += contrib;
    drivers.push({
      text: `Dollar ${fePct(basket.usd)} ${basket.since} face au panier de devises (${basket.n} paire${basket.n > 1 ? 's' : ''})${data.fxClosed ? FE_FX_CLOSED_TXT : ''}`,
      tone: feToneOf(contrib), kind: 'marché', own: contrib,
    });
    intra++;
  }

  // 2) Réaction post-événement USD → ±2 (moyenne des paires, inversée).
  const evUSD = feLastPassedHigh(cal, 'USD');
  if (evUSD) {
    const reacs = [];
    for (const k of ['eurusd', 'gbpusd', 'audusd']) {
      const r = feReactionFromKlines(klines && klines[k], evUSD.ts);
      if (Number.isFinite(r)) reacs.push(r);
    }
    const usdReac = reacs.length ? -(reacs.reduce((a, b) => a + b, 0) / reacs.length) : null;
    const rd = feBuildReactionDriver(
      evUSD, usdReac,
      Number.isFinite(usdReac) ? `dollar ${fePct(usdReac)} depuis la publication` : '',
      false, newsRes, news
    );
    if (rd) { score += rd.contrib; drivers.push(rd.driver); if (rd.contrib) intra++; }
  }

  // 3) Contexte de fond : 10 ans US (FRED, ~5 séances) plafonné ±1.
  const us10D = mkts ? feSeriesDelta(mkts.us10y) : null;
  if (us10D) {
    const bp = Math.round(us10D.diff * 100);
    if (Math.abs(bp) >= 5) {
      score += bp > 0 ? 1 : -1;
      drivers.push({
        text: `10 ans US à ${fmtPct(us10D.last, 2)} (${feBp(bp)} sur ${us10D.seances} séances) — contexte de fond`,
        tone: bp > 0 ? 'hausse' : 'baisse', kind: 'taux',
      });
    }
  }

  // 4) COT EUR lu en miroir (±1) + 5) presse (±1, déjà fraîcheur-pondérée).
  const cotUsd = feCotDriverFor(cot && cot.eur, 'EUR', true);
  if (cotUsd) { score += cotUsd.contrib; drivers.push({ text: cotUsd.text, tone: cotUsd.tone, kind: cotUsd.kind }); }
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraich = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Tonalité presse${fraich} plutôt restrictive (soutien au dollar)`
        : `Tonalité presse${fraich} plutôt accommodante (pression sur le dollar)`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse', kind: 'actualité',
    });
  }

  if (meta) meta.intraday += intra;
  return feFinalize('USD', score, drivers, cal, newsRes, intra === 0, undefined, 2);
}

// ── JOUR · Indices US ────────────────────────────────────────────────────────
function feScoreDailyIndices(data) {
  const { quotes, cal, newsRes, news, mkts, cot, fng, sessionOpen, meta } = data;
  const drivers = [];
  let score = 0;
  let intra = 0;
  // Fraîcheur des cotes CBOE (variation de la dernière séance de New York) :
  // « en séance », « à la clôture », « à la clôture de vendredi »…
  const usDay = data.usDay || feLastUsSessionDay();
  const usWhen = data.usWhen || feSessionWhen(sessionOpen, usDay);

  // 1) Composite NDX/DJI du jour (SPX en appui) → ±3 ; séance de New York
  //    fermée → variation de la dernière séance, poids réduit à ±2 et libellé
  //    de clôture (« à la clôture de vendredi »).
  const ndx = quotes.ndx, dji = quotes.dji, spx = quotes.spx;
  const comps = [ndx, dji].filter(q => q && Number.isFinite(q.chgPct)).map(q => q.chgPct);
  let comp = null;
  if (comps.length) {
    comp = comps.reduce((a, b) => a + b, 0) / comps.length;
    let contrib = feIntradayContrib3(comp);
    const suffix = usWhen;
    if (!sessionOpen) contrib = Math.max(-2, Math.min(2, contrib));
    score += contrib;
    const bits = [];
    if (ndx && Number.isFinite(ndx.chgPct)) bits.push(`Nasdaq ${fePct(ndx.chgPct)}`);
    if (dji && Number.isFinite(dji.chgPct)) bits.push(`Dow ${fePct(dji.chgPct)}`);
    if (spx && Number.isFinite(spx.chgPct)) bits.push(`S&P ${fePct(spx.chgPct)}`);
    drivers.push({ text: `${bits.join(', ')} ${suffix}`, tone: feToneOf(contrib), kind: 'marché', own: contrib });
    intra++;
  }

  // 2) VIX de la dernière séance → ±1 (VIX en baisse = appétit au risque).
  const vixQ = quotes.vix;
  const vixChg = (vixQ && Number.isFinite(vixQ.chgPct)) ? vixQ.chgPct : null;
  if (vixQ && Number.isFinite(vixQ.price)) {
    let contrib = 0;
    if (vixChg != null && Math.abs(vixChg) >= 3) contrib = vixChg > 0 ? -1 : 1;
    score += contrib;
    const chgTxt = vixChg != null ? ` (${fePct(vixChg)} ${usWhen})` : '';
    drivers.push({
      text: `VIX ${feFixed(vixQ.price)}${chgTxt} — volatilité ${vixQ.price < 14 ? 'contenue' : (vixQ.price > 20 ? 'élevée' : 'modérée')}`,
      tone: feToneOf(contrib), kind: 'marché',
    });
    intra++;
  }

  // 3) Réaction post-événement → ±2 : séance ouverte → chgPct composite en
  //    approximation « depuis l'événement » ; séance fermée → VIX + presse.
  const evIdx = feLastPassedHigh(cal, 'USD');
  if (evIdx) {
    let rd = null;
    if (sessionOpen && comp != null) {
      const lead = (ndx && Number.isFinite(ndx.chgPct)) ? `Nasdaq ${fePct(ndx.chgPct)} en séance` : `indices ${fePct(comp)} en séance`;
      rd = feBuildReactionDriver(evIdx, comp, lead, true, newsRes, news);
    } else if (vixChg != null && Math.abs(vixChg) >= 5
               && vixQ && Number.isFinite(vixQ.ts) && vixQ.ts > evIdx.ts
               && usDay && localDayInTz(evIdx.ts, 'America/New_York') === usDay
               && evIdx.ts < feUsCloseMs(usDay)
               && Date.now() - evIdx.ts < 3 * 3600e3) {
      // Séance fermée : le VIX n'est qu'un indicateur INDIRECT — vixChg est la
      // variation de la dernière séance (par rapport à la clôture précédente),
      // pas depuis l'événement. Garde-fous : cote datée et postérieure à la
      // publication ; publication faite le jour de cette séance et AVANT sa
      // clôture de 16:00 (sinon vixChg ne contient pas la réaction : avant
      // l'ouverture, c'est la variation de la veille — le ts CBOE est l'instant
      // du relevé, il ne le prouve pas à lui seul) ; événement récent (<3 h)
      // pour que l'attribution reste plausible ; contribution plafonnée à ±1 ;
      // texte sans causalité.
      const contrib = vixChg > 0 ? -1 : 1;
      rd = {
        contrib,
        driver: {
          text: `${feEvTitle(evIdx)} publié à ${feTimeFR(evIdx.ts) || '—'} — séance de New York fermée, VIX ${fePct(vixChg)} ${usWhen} (indicateur indirect, réaction directe non mesurable)`,
          tone: feToneOf(contrib), kind: 'calendrier', w: 0.5,
        },
      };
    } else {
      rd = feBuildReactionDriver(evIdx, null, '', true, newsRes, news);
    }
    if (rd) { score += rd.contrib; drivers.push(rd.driver); if (rd.contrib) intra++; }
  }

  // 4) F&G (indicateur indirect d'appétit au risque) → ±1.
  if (fng && Number.isFinite(fng.value)) {
    const v = Math.round(fng.value);
    const labKey = String(fng.label || '').toLowerCase().trim();
    const labFR = FE_FNG_FR[labKey] || (labKey || '—');
    let tone = 'neutre';
    if (v <= 30) { tone = 'baisse'; score -= 1; }
    else if (v >= 70) { tone = 'hausse'; score += 1; }
    const prevTxt = Number.isFinite(fng.prev) ? `, précédent ${Math.round(fng.prev)}` : '';
    const prefixe = v <= 30 ? 'Appétit au risque dégradé — ' : (v >= 70 ? 'Appétit au risque soutenu — ' : '');
    drivers.push({ text: `${prefixe}sentiment crypto (indicateur d’appétit pour le risque) ${v} «\u00A0${labFR}\u00A0»${prevTxt}`, tone, kind: 'sentiment' });
  }

  // 5) Contexte de fond FRED (~5 séances) PLAFONNÉ ±1 (poids d'affichage réduit).
  const spxD = mkts ? feSeriesDelta(mkts.spx) : null;
  const ndqD = mkts ? feSeriesDelta(mkts.ndq) : null;
  const ctxParts = [spxD, ndqD].filter(Boolean);
  if (ctxParts.length) {
    const moy = ctxParts.map(d => d.pct).reduce((a, b) => a + b, 0) / ctxParts.length;
    let contrib = 0;
    if (Math.abs(moy) >= 0.6) contrib = moy > 0 ? 1 : -1;
    score += contrib;
    const bits = [];
    if (spxD) bits.push(`S&P 500 ${fePct(spxD.pct)}`);
    if (ndqD) bits.push(`Nasdaq ${fePct(ndqD.pct)}`);
    const ref = spxD || ndqD;
    drivers.push({
      text: `Contexte de fond\u00A0: ${bits.join(', ')} sur ${ref.seances} séances`,
      tone: feToneOf(contrib), kind: 'marché', w: 0.8,
    });
  }

  // 6) COT indices (±1) + 7) presse (±1).
  const cotIdxM = cot ? (cot.nasdaq || cot.dow) : null;
  const cotIdx = feCotDriverFor(cotIdxM, (cot && cot.nasdaq) ? 'Nasdaq' : 'Dow', false);
  if (cotIdx) { score += cotIdx.contrib; drivers.push({ text: cotIdx.text, tone: cotIdx.tone, kind: cotIdx.kind }); }
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraichI = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Flux presse${fraichI} plutôt porteur pour les actions`
        : `Flux presse${fraichI} plutôt défavorable aux actions (risque)`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse', kind: 'actualité',
    });
  }

  if (meta) meta.intraday += intra;
  return feFinalize('INDICES', score, drivers, cal, newsRes, intra === 0, 'USD', 2);
}

// ── JOUR · Or ────────────────────────────────────────────────────────────────
function feScoreDailyGold(data) {
  const { klines, cal, newsRes, news, mkts, cot, meta } = data;
  const moves = data.moves || {};
  const fxClosed = !!data.fxClosed;
  // L'or suit le CME (fermé aussi le Vendredi saint) ; sans cette donnée
  // (appelant ancien), même état que le change.
  const goldClosed = data.goldClosed != null ? !!data.goldClosed : fxClosed;
  const drivers = [];
  let score = 0;
  let intra = 0;

  // 1) PAXG du jour → ±3 ; marché de l'or fermé (week-end, séance fériée) → plafond ±1.
  //    Prix : cotation de marché en dollars (charte §2, devise nommée par
  //    son symbole), jamais convertie dans la devise du profil.
  const g = moves.gold;
  if (g && Number.isFinite(g.pct)) {
    let contrib = feIntradayContrib3(g.pct);
    if (goldClosed) contrib = Math.max(-1, Math.min(1, contrib));
    score += contrib;
    const prixTxt = Number.isFinite(g.price) ? ` (${fmtMoneyCur(g.price, 'USD')})` : '';
    drivers.push({
      text: `Or ${fePct(g.pct)} ${g.since}${prixTxt}${goldClosed ? FE_GOLD_CLOSED_TXT : ''}`,
      tone: feToneOf(contrib), kind: 'marché', own: contrib,
    });
    intra++;
  }

  // 2) Réaction post-événement (événements USD, moteurs de l'or) → ±2.
  const evG = feLastPassedHigh(cal, 'USD');
  if (evG) {
    const r = feReactionFromKlines(klines && klines.gold, evG.ts);
    const rd = feBuildReactionDriver(
      evG, r, Number.isFinite(r) ? `or ${fePct(r)} depuis la publication` : '',
      false, newsRes, news
    );
    if (rd) { score += rd.contrib; drivers.push(rd.driver); if (rd.contrib) intra++; }
  }

  // 3) Dollar du jour INVERSÉ → ±1.
  const basketG = feUsdBasket(moves);
  if (basketG && Math.abs(basketG.usd) >= 0.4) {
    const contrib = basketG.usd > 0 ? -1 : 1;
    score += contrib;
    const quand = `${basketG.since}${fxClosed ? ', hors séance' : ''}`;
    drivers.push({
      text: basketG.usd > 0
        ? `Dollar ${fePct(basketG.usd)} ${quand} — vent contraire pour l’or`
        : `Dollar ${fePct(basketG.usd)} ${quand} — soutien pour l’or`,
      tone: feToneOf(contrib), kind: 'marché', w: 0.8,
    });
    intra++;
  }

  // 4) Contexte de fond : 10 ans US plafonné ±1.
  const y10Gold = mkts ? feSeriesDelta(mkts.us10y) : null;
  if (y10Gold) {
    const bp = Math.round(y10Gold.diff * 100);
    if (Math.abs(bp) >= 5) {
      score += bp < 0 ? 1 : -1;
      drivers.push({
        text: bp < 0
          ? `Détente des taux US (${feBp(bp)} sur ${y10Gold.seances} séances) — soutien de fond pour l’or`
          : `Tension des taux US (${feBp(bp)} sur ${y10Gold.seances} séances) — vent contraire de fond`,
        tone: bp < 0 ? 'hausse' : 'baisse', kind: 'taux',
      });
    }
  }

  // 5) COT or (±1) + 6) presse (±1).
  const cotGold = feCotDriverFor(cot && cot.gold, 'or', false);
  if (cotGold) { score += cotGold.contrib; drivers.push({ text: cotGold.text, tone: cotGold.tone, kind: cotGold.kind }); }
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraichG = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Contexte presse${fraichG} porteur pour la valeur refuge`
        : `Contexte presse${fraichG} de détente — moins de demande refuge`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse', kind: 'actualité',
    });
  }

  if (meta) meta.intraday += intra;
  return feFinalize('GOLD', score, drivers, cal, newsRes, intra === 0, 'USD', 2);
}

// ── JOUR · Euro / Dollar australien ──────────────────────────────────────────
function feScoreDailyFxPair(catId, data) {
  const { klines, cal, newsRes, news, cot, meta } = data;
  const moves = data.moves || {};
  const drivers = [];
  let score = 0;
  let intra = 0;
  const key = catId === 'EUR' ? 'eurusd' : 'audusd';
  const pairLabel = catId === 'EUR' ? 'EUR/USD' : 'AUD/USD';

  // 1) Variation du jour de la paire → ±3 ; marché des changes fermé
  //    (week-end) → dérive des cotes 24 h/24, plafond ±1.
  const q = moves[key];
  if (q && Number.isFinite(q.pct)) {
    let contrib = feIntradayContrib3(q.pct);
    if (data.fxClosed) contrib = Math.max(-1, Math.min(1, contrib));
    score += contrib;
    const prixTxt = Number.isFinite(q.price) ? ` (${fmtPrice(q.price, 4)})` : '';
    drivers.push({
      text: `${pairLabel} ${fePct(q.pct)} ${q.since}${prixTxt}${data.fxClosed ? FE_FX_CLOSED_TXT : ''}`,
      tone: feToneOf(contrib), kind: 'marché', own: contrib,
    });
    intra++;
  }

  // 2) Réaction post-événement de la devise → ±2 (mesurée sur la paire).
  const evP = feLastPassedHigh(cal, catId);
  if (evP) {
    const r = feReactionFromKlines(klines && klines[key], evP.ts);
    const rd = feBuildReactionDriver(
      evP, r, Number.isFinite(r) ? `${pairLabel} ${fePct(r)} depuis la publication` : '',
      false, newsRes, news
    );
    if (rd) { score += rd.contrib; drivers.push(rd.driver); if (rd.contrib) intra++; }
  }

  // 3) COT direct (±1) + 4) presse (±1).
  const cotPair = feCotDriverFor(cot && (catId === 'EUR' ? cot.eur : cot.aud), catId, false);
  if (cotPair) { score += cotPair.contrib; drivers.push({ text: cotPair.text, tone: cotPair.tone, kind: cotPair.kind }); }
  if (newsRes && newsRes.score !== 0) {
    score += Math.max(-1, Math.min(1, newsRes.score));
    const fraichP = newsRes.recent ? ' (dernières heures)' : '';
    drivers.push({
      text: newsRes.score > 0
        ? `Tonalité presse${fraichP} plutôt favorable au ${FE_LABELS[catId].toLowerCase()}`
        : `Tonalité presse${fraichP} plutôt défavorable au ${FE_LABELS[catId].toLowerCase()}`,
      tone: newsRes.score > 0 ? 'hausse' : 'baisse', kind: 'actualité',
    });
  }

  if (meta) meta.intraday += intra;
  return feFinalize(catId, score, drivers, cal, newsRes, intra === 0, undefined, 2);
}

// Aiguillage du scoring JOUR v3.
function feScoreDailyCat(catId, data) {
  if (catId === 'USD') return feScoreDailyUSD(data);
  if (catId === 'INDICES') return feScoreDailyIndices(data);
  if (catId === 'GOLD') return feScoreDailyGold(data);
  return feScoreDailyFxPair(catId, data);
}

// Segment « calendrier » des deux synthèses (Jour et Semaine) : l'événement
// pivot avec le niveau de risque, sinon l'état du calendrier. Un fait non
// mesuré n'est jamais présenté comme une période calme : « indisponible » si
// aucune route n'a répondu, « partiel » si la source de secours ne couvre pas
// toute la période, « allégé » seulement quand la période est connue et calme.
// `eleveTxt` : tournure du risque élevé, propre à chaque synthèse.
function feCalendarPhrase(pivot, riskEvent, calOk, calPartial, eleveTxt) {
  if (riskEvent === 'eleve' && pivot) return `${eleveTxt} — ${pivot.title} ${pivot.whenFR}`;
  if (riskEvent === 'moyen' && pivot) return `risque événementiel modéré — ${pivot.title} ${pivot.whenFR}`;
  if (pivot) return `prochain temps fort\u00A0: ${pivot.title} ${pivot.whenFR}`;
  if (calOk === false) return 'calendrier économique indisponible sur la période (source en panne)';
  if (calPartial) return 'calendrier économique partiel sur la période (source de secours)';
  return 'calendrier économique allégé sur la période';
}

// Summary JOUR mené par les données du jour : « Nasdaq +2,3 % et Dow +1,8 %
// en séance, dollar −0,6 % face au panier aujourd’hui… », puis pivot/risque,
// puis volatilité. Chaque groupe porte SA fraîcheur : Wall Street (dernière
// séance de New York, `day.usWhen`) et change/or (depuis minuit, heure du
// profil, `day.moves` ; « hors séance » quand le marché des changes est fermé).
function feBuildDailySummary(quotes, sessionOpen, pivot, riskEvent, calOk, day) {
  const d = day || {};
  const usWhen = d.usWhen || feSessionWhen(sessionOpen, feLastUsSessionDay());
  const moves = d.moves || {};
  const bits = [];
  const ndx = quotes.ndx, dji = quotes.dji;
  const idxBits = [];
  if (ndx && Number.isFinite(ndx.chgPct)) idxBits.push(`Nasdaq ${fePct(ndx.chgPct)}`);
  if (dji && Number.isFinite(dji.chgPct)) idxBits.push(`Dow ${fePct(dji.chgPct)}`);
  if (idxBits.length) bits.push(`${idxBits.join(' et ')} ${usWhen}`);
  // Change et or : regroupés s'ils partagent la même référence de temps.
  const fxBits = [];
  const basket = feUsdBasket(moves);
  if (basket) fxBits.push({ txt: `dollar ${fePct(basket.usd)} face au panier`, since: basket.since });
  const g = moves.gold;
  if (g && Number.isFinite(g.pct) && Math.abs(g.pct) >= 0.3) fxBits.push({ txt: `or ${fePct(g.pct)}`, since: g.since });
  if (fxBits.length) {
    const tail = d.fxClosed ? ' (hors séance)' : '';
    if (fxBits.every(b => b.since === fxBits[0].since)) {
      bits.push(`${fxBits.map(b => b.txt).join(' et ')} ${fxBits[0].since}${tail}`);
    } else {
      for (const b of fxBits) bits.push(`${b.txt} ${b.since}${tail}`);
    }
  }

  const parts = [];
  if (bits.length) parts.push(bits.join(', '));
  parts.push(feCalendarPhrase(pivot, riskEvent, calOk, !!d.calPartial, 'risque événementiel élevé'));

  let s = parts.join(' — ') + '.';
  s = s.charAt(0).toUpperCase() + s.slice(1);
  const vixQ = quotes.vix;
  if (vixQ && Number.isFinite(vixQ.price)) {
    const qual = vixQ.price < 14 ? 'contenue' : (vixQ.price > 20 ? 'élevée' : 'modérée');
    const chg = Number.isFinite(vixQ.chgPct) ? `, ${fePct(vixQ.chgPct)} ${usWhen}` : '';
    s += ` Volatilité ${qual} (VIX ${feFixed(vixQ.price)}${chg}).`;
  }
  return s;
}

// ── Finalisation commune ───────────────────────────────────────────────────────
// Assemble la catégorie : biais, conviction, éléments (5 au plus), catalyseurs,
// titres, risque. `ownCap` : plafond de conviction quand le prix de l'actif
// lui-même n'appuie pas le biais (2 pour le Jour, 3 pour la Semaine).
// Les phrases de remplissage (carte sans élément, ou avec un seul) ne sont plus
// posées ici : elles dépendent de l'état du calendrier et de la presse, connu
// du seul orchestrateur (feFillDrivers).
function feFinalize(catId, score, drivers, cal, newsRes, degraded, catalystCurrency, ownCap) {
  // Devise dont on tire les catalystes calendrier (USD par défaut sauf EUR/AUD eux-mêmes).
  const curr = catalystCurrency || (catId === 'EUR' || catId === 'AUD' ? catId : 'USD');

  const bias = feBiasFromScore(score);

  // ── Règle de conviction (documentée) ──
  // 1) Base = paliers sur |score| (feConviction : 1→2→3→4→5 à 1/2/3/4). Le
  //    nombre d'éléments concordants ne l'augmente PAS : trois éléments de fond
  //    (±1 chacun) valent déjà 3 points de score, les compter une seconde fois
  //    donnait une conviction maximale à des mouvements minimes.
  // 2) Garde-fous, qui ne font que BAISSER la conviction (jamais le sens) :
  //    - au moins un élément dans CHAQUE sens → plafond 3 (signaux contradictoires) ;
  //    - le prix de l'actif lui-même (éléments marqués `own` par les fonctions
  //      de score, avec leur contribution : indice dollar et devises face au
  //      dollar, panier du jour, composite des indices, or, paire) absent,
  //      sans poids, ou de sens contraire au biais → plafond `ownCap`
  //      (2 en Jour, 3 en Semaine) : un biais que son propre marché ne confirme
  //      pas ne peut pas être convaincu ;
  //    - sources de marché dégradées → plafond 2 ;
  //    - score nul → plafond 2 (1 si dégradé).
  let conviction = feConviction(Math.abs(score), degraded ? 2 : null);
  const nbUp = drivers.filter(d => d && d.tone === 'hausse').length;
  const nbDown = drivers.filter(d => d && d.tone === 'baisse').length;
  if (nbUp > 0 && nbDown > 0) conviction = Math.min(conviction, 3);
  const ownSum = drivers.reduce((a, d) => a + ((d && Number.isFinite(d.own)) ? d.own : 0), 0);
  const ownAgrees = (bias === 'haussier' && ownSum > 0) || (bias === 'baissier' && ownSum < 0);
  if (bias !== 'neutre' && !ownAgrees) conviction = Math.min(conviction, Number.isFinite(ownCap) ? ownCap : 3);
  if (degraded) conviction = Math.min(conviction, 2);
  if (score === 0) conviction = degraded ? 1 : Math.min(conviction, 2);

  // Tri STABLE par pondération de kind (marchés > positionnement > taux > sentiment
  // > calendrier > actualité), puis cap à 5 drivers (v2 : un de plus qu'en v1).
  // v3 additif : un driver peut porter un poids EXPLICITE `w` (fini) qui prime
  // sur celui de son kind — utilisé par le scoring Jour pour garder la RÉACTION
  // post-événement (kind 'calendrier', w:0.5) visible parmi les drivers marché.
  // Les drivers weekly ne posent jamais `w` : tri hebdo strictement inchangé.
  // Le marqueur interne `own` n'est pas rendu.
  const wOf = (d) => (d && Number.isFinite(d.w)) ? d.w : feKindWeight(d && d.kind);
  const sorted = drivers.slice().sort((a, b) => wOf(a) - wOf(b));
  const finalDrivers = sorted.slice(0, 5).map(d => {
    if (!d || d.own === undefined) return d;
    // Copie explicite, sans déstructuration avec reste : Babel la compile en
    // une variable globale « _excluded » partagée par tous les fichiers.
    const rest = {};
    for (const k of Object.keys(d)) { if (k !== 'own') rest[k] = d[k]; }
    return rest;
  });

  const nextCatalysts = feCatalystsFor(cal, curr, 4);
  const headlines = (newsRes && newsRes.hits) ? newsRes.hits.slice(0, 3) : [];
  const eventRisk = feEventRiskFor(cal, curr);

  return {
    id: catId,
    label: FE_LABELS[catId],
    bias,
    conviction,
    drivers: finalDrivers,
    nextCatalysts,
    headlines,
    eventRisk,
    degraded: !!degraded,
  };
}

// Phrases de remplissage d'une carte (appelé par l'orchestrateur, juste après
// le calcul de la catégorie). Elles ne citent que des sources qui ont répondu :
//   • carte SANS élément — données de marché absentes (carte « données
//     partielles ») : le contexte repose sur le calendrier et/ou la presse
//     s'ils ont répondu, sinon aucun biais n'est calculable ; données
//     présentes mais sans mouvement : « pas de biais directionnel marqué » ;
//   • carte avec UN seul élément — second élément : renvoi aux prochains
//     catalyseurs seulement si la carte en affiche ; sinon l'état réel du
//     calendrier (en panne, partiel, ou aucune publication majeure à venir).
// `etat` : { calOk, calPartial, newsOk }. Modifie et renvoie `cat`.
// (Les cartes Indices US et Or suivent les publications américaines.)
const FE_FILL_NO_EVENT = {
  USD: 'Aucune publication américaine majeure à venir sur la période',
  INDICES: 'Aucune publication américaine majeure à venir sur la période',
  GOLD: 'Aucune publication américaine majeure à venir sur la période',
  EUR: 'Aucune publication majeure de la zone euro à venir sur la période',
  AUD: 'Aucune publication australienne majeure à venir sur la période',
};
function feFillDrivers(cat, etat) {
  if (!cat || !Array.isArray(cat.drivers)) return cat;
  const e = etat || {};
  if (cat.drivers.length === 0) {
    let text = 'Pas de biais directionnel marqué sur la période';
    if (cat.degraded) {
      const appuis = [e.calOk ? 'le calendrier' : '', e.newsOk ? 'la presse' : ''].filter(Boolean);
      text = appuis.length
        ? `Données de marché indisponibles — contexte fondé sur ${appuis.join(' et ')}`
        : 'Données de marché indisponibles — aucun biais calculable pour l’instant';
    }
    cat.drivers.push({ text, tone: 'neutre', kind: 'marché' });
  }
  if (cat.drivers.length === 1) {
    let text;
    if (Array.isArray(cat.nextCatalysts) && cat.nextCatalysts.length > 0) {
      text = 'Contexte à confirmer par les prochains catalyseurs';
    } else if (!e.calOk) {
      text = 'Calendrier économique indisponible';
    } else if (e.calPartial) {
      text = 'Calendrier économique partiel sur la période (source de secours)';
    } else {
      text = FE_FILL_NO_EVENT[cat.id] || 'Aucune publication majeure à venir sur la période';
    }
    cat.drivers.push({ text, tone: 'neutre', kind: 'calendrier' });
  }
  return cat;
}

// ═══════════════════════════════════════════════════════════════════════════
//  SYNTHÈSE (summary + pivotEvent + riskEvent)  — par TEMPLATE, jamais d'IA
// ═══════════════════════════════════════════════════════════════════════════

// Événement pivot = l'événement High le plus proche (le plus lourd/imminent) toutes devises.
function fePivotEvent(cal) {
  const highs = cal.filter(e => !e.passed && /high/i.test(e.impact)).sort((a, b) => a.ts - b.ts);
  if (!highs.length) return null;
  const e = highs[0];
  return {
    title: feEvTitle(e),
    whenFR: feWhenFR(e.date) || '—',
    impact: 'High',
    // v2.2 — ajout optionnel (rétrocompatible) : voir feCatalystsFor / feCtxOutdatedByEvent.
    ts: Number.isFinite(e.ts) ? e.ts : undefined,
  };
}

// Risque global = max des impacts à venir sur la fenêtre.
function feGlobalRisk(cal) {
  let risk = 'faible';
  for (const e of cal) {
    if (e && e.passed) continue;   // v3 : le risque À VENIR ignore les événements passés
    risk = feMaxRisk(risk, feImpactLevel(e.impact));
  }
  return risk;
}

// summary : 1-2 phrases FR par template. Direction dollar + risque dominant + événement pivot.
// v2 : `mkts` (optionnel) sert de secours pour le VIX quand CBOE est indisponible.
// `calOk` (optionnel, défaut true) : false = AUCUN flux calendrier n'a répondu —
// on écrit « indisponible » plutôt que « allégé » (un fait non mesuré n'est pas
// une semaine calme).
// `calPartial` (optionnel) : seule la source de secours a répondu et elle ne
// couvre pas toute la période.
function feBuildSummary(catsById, cal, indices, fx, pivot, riskEvent, mkts, calOk = true, calPartial = false) {
  const parts = [];

  // Direction du dollar (basée sur la catégorie USD si présente, sinon momentum FX).
  const usd = catsById.USD;
  if (usd) {
    const biaisTxt = usd.bias === 'haussier' ? 'en appréciation'
      : usd.bias === 'baissier' ? 'sous pression' : 'sans direction nette';
    // Chiffre le plus parlant : USD vs EUR si dispo ET cohérent avec le biais
    // affiché (l'indice large FRED, en retard ~1 sem, diverge régulièrement de la
    // paire spot : on ne met JAMAIS un chiffre qui contredit la phrase qu'il illustre).
    let chiffre = '';
    const eurPct = (fx && fx.EUR) ? fx.EUR.pct : null;
    const eurCoherent = eurPct != null && Math.abs(eurPct) >= 0.35 &&
      (usd.bias === 'neutre' || (usd.bias === 'haussier' ? eurPct > 0 : eurPct < 0));
    if (eurCoherent) {
      chiffre = ` (${fePct(eurPct)} par rapport à l’euro sur ${fx.EUR.seances} séances)`;
    } else if (usd.bias !== 'neutre') {
      // Repli : variation de l'indice dollar large, si elle concorde avec le biais.
      const idxD = mkts ? feSeriesDelta(mkts.usdIdx) : null;
      if (idxD && Math.abs(idxD.pct) >= 0.25 && (usd.bias === 'haussier' ? idxD.pct > 0 : idxD.pct < 0)) {
        chiffre = ` (indice dollar large ${fePct(idxD.pct)} sur ${idxD.seances} séances)`;
      }
    }
    parts.push(`Dollar ${biaisTxt}${chiffre}`);
  } else if (fx && fx.EUR && Math.abs(fx.EUR.pct) >= 0.35) {
    parts.push(`Dollar ${fx.EUR.pct > 0 ? 'ferme' : 'en repli'} (${fePct(fx.EUR.pct)} par rapport à l’euro)`);
  }

  // Risque événementiel dominant + pivot.
  parts.push(feCalendarPhrase(pivot, riskEvent, calOk, calPartial, 'dans un contexte de risque événementiel élevé'));

  // Jointure avec « — » : chaque part est un segment autonome, un espace nu
  // produirait une phrase agrammaticale (ex « ...séances) calendrier allégé... »).
  let phrase1 = parts.join(' — ') + '.';
  phrase1 = phrase1.charAt(0).toUpperCase() + phrase1.slice(1);

  // Phrase 2 optionnelle : volatilité si indices dispo (CBOE, sinon FRED v2).
  let phrase2 = '';
  let vix = null;
  if (indices && !indices.degraded && indices.VIX) {
    vix = feNum(indices.VIX.current_price);
  }
  if (vix == null && mkts) vix = feSeriesLast(mkts.vix);
  if (vix != null) {
    const q = vix < 14 ? 'contenue' : (vix > 20 ? 'élevée' : 'modérée');
    phrase2 = ` Volatilité ${q} (VIX ${feFixed(vix)}).`;
  }

  return (phrase1 + phrase2).trim();
}

// ═══════════════════════════════════════════════════════════════════════════
//  SCOPE WEEKLY (v2) — SEMAINE À VENIR + BILAN DE LA SEMAINE ÉCOULÉE
// ═══════════════════════════════════════════════════════════════════════════

// ctx.weekAhead = { days:[{date:'YYYY-MM-DD', label:'Lundi 6 juil.', total, events:[{title,country,impact,timeFR}]}] }.
// Événements FUTURS uniquement (High/Medium), groupés par JOUR LOCAL (fuseau du
// profil), semaine glissante de 7 jours max.
// Sélection par jour (le calendrier /cal2 couvre 8 devises : 10 à 28 événements
// élevé+moyen par jour, un simple « les 8 premiers » coupait les publications
// de l'après-midi, dont l'inflation américaine) :
//   1. TOUS les événements à impact élevé (borne de sécurité FE_WEEK_HIGH_MAX) ;
//   2. puis les impacts moyens jusqu'à FE_WEEK_DAY_ROWS lignes, ceux des devises
//      suivies par l'analyse d'abord, dans l'ordre chronologique ;
//   3. la sélection est rendue dans l'ordre chronologique.
// `total` = nombre réel d'événements élevé+moyen du jour : l'écran écrit
// « 8 sur 28 événements » quand la liste est abrégée.
// `currencies` (optionnel) : devises suivies par l'analyse ; défaut USD, EUR, AUD.
const FE_WEEK_DAY_ROWS = 8;
const FE_WEEK_HIGH_MAX = 12;
const FE_WEEK_CURRENCIES = ['USD', 'EUR', 'AUD'];
// Devise du calendrier suivie par chaque catégorie (la même que celle de ses
// catalyseurs : indices américains et or lisent les publications en dollars).
const FE_CAT_CURRENCY = { USD: 'USD', INDICES: 'USD', GOLD: 'USD', EUR: 'EUR', AUD: 'AUD' };
function feWeekDaySelect(list, followed) {
  const sorted = list.slice().sort((a, b) => a.ts - b.ts);
  const isHigh = (e) => /high/i.test(e.impact);
  const highs = sorted.filter(isHigh).slice(0, FE_WEEK_HIGH_MAX);
  const mediums = sorted.filter(e => !isHigh(e));
  const isFollowed = (e) => followed.indexOf(e.country) >= 0;
  const room = Math.max(0, FE_WEEK_DAY_ROWS - highs.length);
  const keep = new Set(highs.concat(
    mediums.filter(isFollowed).concat(mediums.filter(e => !isFollowed(e))).slice(0, room)
  ));
  return sorted.filter(e => keep.has(e));
}
function feBuildWeekAhead(cal, currencies) {
  const followed = (Array.isArray(currencies) && currencies.length) ? currencies : FE_WEEK_CURRENCIES;
  const byDay = new Map();
  const now = Date.now();
  for (const e of (Array.isArray(cal) ? cal : [])) {
    if (!e || !Number.isFinite(e.ts)) continue;
    if (e.ts < now - 5 * 60 * 1000) continue;              // futurs uniquement (tolérance 5 min)
    if (!/high|medium/i.test(e.impact)) continue;          // on ne prépare que le signifiant
    const key = feDateKeyTZ(e.ts);
    if (!key) continue;
    if (!byDay.has(key)) byDay.set(key, []);
    byDay.get(key).push(e);
  }
  const evKeys = Array.from(byDay.keys()).sort().slice(0, 7);
  // On émet AUSSI les jours OUVRÉS sans événement de la fenêtre couverte
  // (events: []) : la timeline distingue ainsi « jour calme » de « donnée
  // manquante ». Les week-ends n'apparaissent que s'ils portent un événement.
  let allKeys = evKeys;
  if (evKeys.length) {
    const filled = [];
    const startMs = Date.parse(evKeys[0] + 'T12:00:00Z');
    const endMs = Date.parse(evKeys[evKeys.length - 1] + 'T12:00:00Z');
    for (let t = startMs; Number.isFinite(t) && t <= endMs; t += 86400e3) {
      const d = new Date(t);
      const key = d.toISOString().slice(0, 10);
      const dow = d.getUTCDay();
      if (dow === 0 || dow === 6) {           // week-end : seulement si événement
        if (byDay.has(key)) filled.push(key);
      } else {
        filled.push(key);
      }
    }
    if (filled.length) allKeys = filled;
  }
  const days = allKeys.map(key => {
    const list = byDay.get(key) || [];
    return {
      date: key,
      label: feDayLabelFR(key),
      total: list.length,
      events: feWeekDaySelect(list, followed).map(e => ({
        title: feEvTitle(e),
        country: e.country || '',
        impact: /high/i.test(e.impact) ? 'High' : 'Medium',
        timeFR: feTimeFR(e.ts) || '—',
      })),
    };
  });
  return { days };
}

// ctx.weekReview = { text } : bilan CHIFFRÉ de la semaine écoulée (2-3 phrases)
// à partir des séries disponibles (FRED + or Binance + FX Frankfurter). Best-effort :
// on n'énonce que ce qui est mesuré, phrase de repli si tout manque.
function feBuildWeekReview(mkts, gold, fx) {
  const sIndices = [];
  const sTaux = [];
  const sAutres = [];

  if (mkts) {
    const spx = feSeriesDelta(mkts.spx);
    if (spx) sIndices.push(`S&P 500 ${fePct(spx.pct)}`);
    const ndq = feSeriesDelta(mkts.ndq);
    if (ndq) sIndices.push(`Nasdaq ${fePct(ndq.pct)}`);
    const vix = feSeriesLast(mkts.vix);
    if (vix != null) sIndices.push(`VIX à ${feFixed(vix)}`);

    const usd = feSeriesDelta(mkts.usdIdx);
    if (usd) sTaux.push(`indice dollar large ${fePct(usd.pct)}`);
    const y10 = feSeriesDelta(mkts.us10y);
    if (y10) {
      const bp = Math.round(y10.diff * 100);
      sTaux.push(`10 ans US ${feBp(bp)} à ${fmtPct(y10.last, 2)}`);
    }
  }
  if (gold && Number.isFinite(gold.pct)) sAutres.push(`or ${fePct(gold.pct)}`);
  if (fx && fx.EUR && Number.isFinite(fx.EUR.pct)) sAutres.push(`EUR/USD ${fePct(-fx.EUR.pct)}`);

  const phrases = [];
  if (sIndices.length) phrases.push(`Semaine écoulée\u00A0: ${sIndices.join(', ')}.`);
  if (sTaux.length) phrases.push(`Côté dollar et taux\u00A0: ${sTaux.join(', ')}.`);
  if (sAutres.length) phrases.push(`Or et changes\u00A0: ${sAutres.join(', ')}.`);
  if (!phrases.length) {
    return { text: 'Bilan hebdomadaire indisponible — sources de marché dégradées sur la période.' };
  }
  return { text: phrases.join(' ') };
}

// Complément du summary hebdo : « Semaine à venir : CPI mercredi, NFP vendredi. »
// (jusqu'à 3 événements High distincts, jour en français). Chaîne vide si rien.
function feWeekAheadSummary(weekAhead) {
  if (!weekAhead || !Array.isArray(weekAhead.days)) return '';
  const bits = [];
  const seen = new Set();
  for (const day of weekAhead.days) {
    if (!day || !Array.isArray(day.events)) continue;
    const jour = feWeekdayFR(day.date);
    for (const ev of day.events) {
      if (!ev || ev.impact !== 'High' || bits.length >= 3) continue;
      const key = String(ev.title || '').toLowerCase();
      if (!key || seen.has(key)) continue;
      seen.add(key);
      bits.push(jour ? `${ev.title} ${jour}` : ev.title);
    }
    if (bits.length >= 3) break;
  }
  if (!bits.length) return '';
  return ` Semaine à venir\u00A0: ${bits.join(', ')}.`;
}

// ═══════════════════════════════════════════════════════════════════════════
//  CACHE — sensible au temps ET aux événements (v2.2)
// ═══════════════════════════════════════════════════════════════════════════

// Péremption ÉVÉNEMENTIELLE d'un ctx : true si un événement d'impact FORT (High)
// est PASSÉ entre ctx.generatedAt et maintenant — l'analyse a été générée AVANT
// le chiffre, le contexte a pu basculer (ex : une analyse pré-NFP du matin ne
// vaut plus rien après la publication). S'appuie sur les timestamps `ts`
// OPTIONNELS portés par pivotEvent et categories[].nextCatalysts (ajoutés v2.2) :
// un ctx plus ancien (sans ts) renvoie false — dégradation propre, seul l'âge décide.
function feCtxOutdatedByEvent(ctx, nowMs) {
  try {
    if (!ctx || typeof ctx !== 'object') return false;
    const gen = Number(ctx.generatedAt);
    const now = Number.isFinite(nowMs) ? nowMs : Date.now();
    if (!Number.isFinite(gen) || gen >= now) return false;
    // Un événement « périme » le ctx s'il est High ET tombé STRICTEMENT entre
    // la génération et maintenant (ts futur ou antérieur à l'analyse : inoffensif).
    const passed = (impact, ts) =>
      /high/i.test(String(impact || '')) && Number.isFinite(ts) && ts > gen && ts <= now;
    if (ctx.pivotEvent && passed(ctx.pivotEvent.impact, Number(ctx.pivotEvent.ts))) return true;
    const cats = Array.isArray(ctx.categories) ? ctx.categories : [];
    for (const c of cats) {
      const list = (c && Array.isArray(c.nextCatalysts)) ? c.nextCatalysts : [];
      for (const k of list) {
        if (k && passed(k.impact, Number(k.ts))) return true;
      }
    }
    return false;
  } catch (e) {
    return false;
  }
}

// Analyse en cache d'un onglet, ou null (l'appelant régénère). Le cache est une
// donnée locale que rien ne garantit : entrée abîmée, analyse d'un autre onglet
// rangée sous cette clé, date d'enregistrement illisible ou dans le futur
// (horloge de l'ordinateur déréglée puis corrigée) → ignorée.
function feReadCache(scope) {
  try {
    const raw = localStorage.getItem(FE_CACHE_PREFIX + scope);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    if (!parsed || !parsed.ctx || typeof parsed.ctx !== 'object') return null;
    // v2 : les ctx v1 en cache (sans champ .v) sont IGNORÉS — l'appelant régénère,
    // et feWriteCache écrase proprement l'ancienne entrée.
    if (parsed.ctx.v !== 2) return null;
    if (parsed.ctx.scope !== scope) return null;
    // Les ctx générés avant la traduction des événements (titres anglais dans
    // la synthèse) sont ignorés : l'appelant régénère.
    if (!parsed.ctx.titlesFr) return null;
    // Âge écrit en positif et borné des deux côtés : avec « maintenant − date >
    // durée », une date illisible (NaN) ou future n'expirait jamais. Une minute
    // d'avance est tolérée (réglage d'horloge).
    if (!Number.isFinite(parsed.savedAt)) return null;
    const age = Date.now() - parsed.savedAt;
    if (!(age >= -60000 && age <= FE_CACHE_TTL_MS)) return null;
    // v2.2 : cache invalidé si un événement FORT est passé depuis la génération —
    // le TTL 30 min ne suffit pas quand un NFP/CPI tombe dans l'intervalle.
    if (feCtxOutdatedByEvent(parsed.ctx, Date.now())) return null;
    return parsed.ctx;
  } catch (e) {
    return null;
  }
}
function feWriteCache(scope, ctx) {
  try {
    localStorage.setItem(FE_CACHE_PREFIX + scope, JSON.stringify({ ctx, savedAt: Date.now() }));
  } catch (e) {}
}

// ═══════════════════════════════════════════════════════════════════════════
//  ENRICHISSEMENT v2 PAR CATÉGORIE — series (sparklines) + cot
// ═══════════════════════════════════════════════════════════════════════════

// categories[i].series : ≤2 séries par catégorie, ~30 points échantillonnés.
// Dollar → indice dollar + 10 ans ; Indices → S&P + VIX ; Or → PAXG + 10 ans ;
// Euro → EUR/USD ; AUD → AUD/USD. Une série n'apparaît que si ≥2 points valides.
function feSeriesFor(catId, mkts, gold, fx) {
  const out = [];
  const push = (label, unit, pts) => {
    if (out.length >= 2) return;
    const points = feSampleSeries(pts || [], 30);
    if (points.length >= 2) out.push({ label, unit, points });
  };
  if (catId === 'USD') {
    push('Indice dollar large', 'indice', mkts && mkts.usdIdx);
    push('10 ans US', '%', mkts && mkts.us10y);
  } else if (catId === 'INDICES') {
    push('S&P 500', 'pts', mkts && mkts.spx);
    push('VIX', 'pts', mkts && mkts.vix);
  } else if (catId === 'GOLD') {
    push('Or (PAXG)', 'USD', gold && gold.series);
    push('10 ans US', '%', mkts && mkts.us10y);
  } else if (catId === 'EUR') {
    push('EUR/USD', 'USD', fx && fx.EUR && fx.EUR.series);
  } else if (catId === 'AUD') {
    push('AUD/USD', 'USD', fx && fx.AUD && fx.AUD.series);
  }
  return out;
}

// categories[i].cot : positionnement pertinent pour la catégorie
// ({net, prevNet, date, market, mirror} | null).
// Dollar → EUR (lecture MIROIR : mirror=true, l'UI neutralise le code couleur
// pour que le vert/rouge ne prétende pas donner le sens du biais dollar) ;
// Indices → Nasdaq (sinon Dow) ; Or → gold ; Euro/AUD → direct.
// `market` = libellé FR du marché source, affiché dans l'en-tête de la jauge.
function feCotFor(catId, cot) {
  if (!cot) return null;
  let m = null, market = '', mirror = false;
  if (catId === 'USD') { m = cot.eur; market = 'Euro FX'; mirror = true; }
  else if (catId === 'INDICES') {
    m = cot.nasdaq || cot.dow;
    market = cot.nasdaq ? 'Nasdaq mini' : 'Dow Jones mini';
  }
  else if (catId === 'GOLD') { m = cot.gold; market = 'Or (COMEX)'; }
  else if (catId === 'EUR') { m = cot.eur; market = 'Euro FX'; }
  else if (catId === 'AUD') { m = cot.aud; market = 'Dollar australien (CME)'; }
  if (!m || !Number.isFinite(m.net)) return null;
  return {
    net: m.net,
    prevNet: Number.isFinite(m.prevNet) ? m.prevNet : null,
    date: m.date || null,
    market,
    mirror,
  };
}

// ═══════════════════════════════════════════════════════════════════════════
//  SNAPSHOT SUPABASE (lancé sans attente ; un échec est RENDU à l'appelant)
// ═══════════════════════════════════════════════════════════════════════════

// Sauvegarde le ctx dans fondamental_snapshots (RLS owner-only, user_id via
// default auth.uid()) après chaque génération réussie, si connecté. Puis purge
// best-effort au-delà de la rétention par scope. Ne bloque JAMAIS l'analyse et
// ne rejette jamais : le résultat dit ce qui s'est passé, l'appelant décide
// quoi afficher (une analyse non enregistrée n'apparaît ni dans l'Historique ni
// dans la fiabilité du biais : l'écran le signale et propose de réessayer).
//   { ok: true }      — analyse enregistrée ;
//   { skipped: true } — rien à faire : non connecté ou client absent ;
//   { error }         — écriture refusée ou réseau coupé (le message brut part
//                       dans console.warn('[Tempo] …'), jamais à l'écran).
// Un rappel pour la même analyse insère une NOUVELLE ligne (la table n'a pas de
// contrainte d'unicité) : ne rappeler qu'après un { error }, jamais deux fois
// en parallèle.
// Rétention v3.1 (track record) : 120 snapshots en daily (≈ 4-6 mois de
// fiabilité du biais mesurable), 40 en weekly (inchangé).
const FE_RETENTION = { daily: 120, weekly: 40 };
async function feSaveSnapshot(ctx) {
  const echec = (err) => {
    try { console.warn('[Tempo] Fondamental : analyse non enregistrée dans l’historique', err); } catch (e) {}
    return { error: err || new Error('enregistrement impossible') };
  };
  let sb = null;
  try {
    if (!ctx || typeof ctx !== 'object') return { skipped: true };
    sb = (typeof window !== 'undefined') ? window.sb : null;
    if (!sb || !sb.auth || typeof sb.from !== 'function') return { skipped: true };
    const res = await sb.auth.getUser();
    const user = res && res.data && res.data.user;
    if (!user) {
      // Pas d'utilisateur : non connecté, il n'y a pas d'historique… sauf si
      // une session existe sur cet appareil. La vérification a alors ÉCHOUÉ
      // (réseau coupé, session refusée) : c'est un enregistrement manqué, à
      // signaler, pas une absence de compte.
      let session = null;
      try {
        const s = await sb.auth.getSession();
        session = s && s.data && s.data.session;
      } catch (e) { session = null; }
      return session ? echec((res && res.error) || new Error('session non vérifiée')) : { skipped: true };
    }

    const ins = await sb.from('fondamental_snapshots').insert({
      scope: ctx.scope,
      generated_at: new Date(ctx.generatedAt).toISOString(),
      ctx,
    });
    if (ins && ins.error) return echec(ins.error);
  } catch (e) {
    return echec(e);
  }

  // L'analyse est enregistrée. La purge qui suit est un entretien : son échec
  // ne remet pas l'enregistrement en cause (et ne doit surtout pas provoquer
  // un second enregistrement).
  try {
    // Rétention : garder ≤120 snapshots en daily, ≤40 en weekly
    // (delete des plus vieux, best-effort).
    const keep = FE_RETENTION[ctx.scope] || 40;
    const extra = await sb.from('fondamental_snapshots')
      .select('id')
      .eq('scope', ctx.scope)
      .order('generated_at', { ascending: false })
      .range(keep, keep + 400);
    const rows = extra && extra.data;
    if (Array.isArray(rows) && rows.length) {
      await sb.from('fondamental_snapshots').delete().in('id', rows.map(r => r.id));
    }
  } catch (e) {
    try { console.warn('[Tempo] Fondamental : purge des anciennes analyses impossible', e); } catch (e2) {}
  }
  return { ok: true };
}

// ═══════════════════════════════════════════════════════════════════════════
//  TRACK RECORD v3.1 — evalRefs + évaluation honnête des snapshots passés
// ═══════════════════════════════════════════════════════════════════════════
//
// Principe (validé) : le verdict d'un biais passé = DIRECTION + AMPLITUDE
// mesurées sur les MÊMES sources temps réel que le scoring Jour (CBOE /
// Binance / Kraken via feFetchQuotes). JAMAIS de simulation de prise de
// position (« TP atteint » = fiction interdite), JAMAIS de prix FRED ou
// stale (un prix retardé fabriquerait un verdict malhonnête).

// Catégories évaluables (ids RÉELS du moteur — cf. FE_LABELS / ctx.biases).
const FE_EVAL_CATS = ['USD', 'INDICES', 'GOLD', 'EUR', 'AUD'];

// Seuils de neutralité (en % de variation) : en-dessous, le marché n'a pas
// tranché — verdict 'neutre' (ni juste ni faux). ±0,15% FX / ±0,30% Indices-Or.
const FE_EVAL_THRESHOLDS = { USD: 0.15, EUR: 0.15, AUD: 0.15, INDICES: 0.30, GOLD: 0.30 };

// Construit ctx.evalRefs depuis les quotes intraday FRAÎCHES (feQuoteFresh) :
//   USD     → panier {kind:'basket', pairs:{eurusd,gbpusd,audusd}, ts} (les 3
//             paires requises — un panier partiel serait un faux panier) ;
//   INDICES → spot SPX (route worker) ou NDX (repli client CBOE) ;
//   GOLD    → spot PAXG ; EUR → EURUSD ; AUD → AUDUSD.
// Catégorie sans quote fraîche → null (jamais de prix de repli FRED/stale).
// Un evalRef archive un prix DATÉ pour un verdict futur : l'horodatage doit
// être RÉEL. Une quote sans ts (repli client CBOE : la cote delayed peut
// porter la clôture de la VEILLE) est tolérée par le scoring (feQuoteFresh,
// « fraîcheur inconnue ») mais PAS ici — un ts inventé (now) estampillerait
// un prix potentiellement stale comme frais et fabriquerait des verdicts
// (pct=0 « neutre », ou un pct incluant un gap ANTÉRIEUR au biais noté).
function feBuildEvalRefs(quotes) {
  const q = (quotes && typeof quotes === 'object') ? quotes : {};
  const spot = (key, asset) => {
    const it = q[key];
    if (!it || !Number.isFinite(it.price) || it.price <= 0) return null;
    if (it.ts == null || !Number.isFinite(it.ts) || !feQuoteFresh(it.ts)) return null;
    return { kind: 'spot', asset, price: it.price, ts: it.ts };
  };
  // Panier USD : les 3 paires majeures cotées CONTRE le dollar, toutes fraîches
  // ET toutes horodatées (même exigence que spot()).
  let basket = null;
  const pairs = [['eurusd', q.eurusd], ['gbpusd', q.gbpusd], ['audusd', q.audusd]];
  if (pairs.every(([, p]) => p && Number.isFinite(p.price) && p.price > 0
    && p.ts != null && Number.isFinite(p.ts) && feQuoteFresh(p.ts))) {
    const prices = {};
    let ts = Infinity;
    for (const [k, p] of pairs) {
      prices[k] = p.price;
      // ts du panier = la paire la plus ANCIENNE (fraîcheur pessimiste, honnête).
      if (p.ts < ts) ts = p.ts;
    }
    basket = { kind: 'basket', pairs: prices, ts };
  }
  return {
    USD: basket,
    INDICES: spot('spx', 'SPX') || spot('ndx', 'NDX'),
    GOLD: spot('gold', 'PAXG'),
    EUR: spot('eurusd', 'EURUSD'),
    AUD: spot('audusd', 'AUDUSD'),
  };
}

// Variation % arrondie à 1e-4 AVANT comparaison au seuil : évite qu'un bruit
// flottant (100 → 100,15 donne 0,14999999999999858) fasse basculer un cas
// exactement à la frontière du mauvais côté.
function feEvalPct(refPrice, nowPrice) {
  if (!Number.isFinite(refPrice) || refPrice === 0 || !Number.isFinite(nowPrice)) return null;
  return Math.round(((nowPrice - refPrice) / refPrice) * 100 * 10000) / 10000;
}

// ── Calcul PUR du bilan d'un snapshot (testable isolément, aucun réseau) ────
// snapshotRow : { id, generated_at, biases, evalRefs, convictions } — la
//   projection légère sélectionnée en base (ctx->'biases', ctx->'evalRefs',
//   ctx->'convictions' ; les vieux snapshots peuvent n'avoir AUCUN des trois).
// freshRefs   : evalRefs construits depuis les quotes fraîches de MAINTENANT
//   (mêmes sources que celles stockées — comparaison honnête).
// nowMs       : horodatage de l'évaluation (injectable pour les tests).
// altRefs     : refs alternatifs OPTIONNELS { [catId]: spot } (v35, additif) —
//   utilisés uniquement quand l'actif du ref frais principal ne correspond pas
//   à l'actif stocké (ex. snapshot NDX du repli CBOE, worker sain à l'éval →
//   fresh.INDICES = SPX prioritaire mais un NDX frais existe : comparer
//   NDX/NDX est honnête et disponible). Les shapes stockées ne changent pas.
// Retour : shape `evaluation` v1 du contrat, ou null si la ligne est inexploitable.
// Règles :
//   • biais 'neutre' au snapshot → non_evaluable (un biais neutre ne se trade pas) ;
//   • pas d'evalRefs stockés OU pas de quote fraîche à l'évaluation → non_evaluable ;
//   • |pct| < seuil → 'neutre' ; sinon 'juste' si le signe concorde avec le
//     biais, 'faux' sinon ;
//   • USD panier : pct = −moyenne(pct des 3 paires vs refPairs) — les paires
//     montent ⇒ le dollar baisse (lecture INVERSÉE).
function feComputeEvaluation(snapshotRow, freshRefs, nowMs, altRefs) {
  const row = snapshotRow || {};
  const alts = (altRefs && typeof altRefs === 'object') ? altRefs : {};

  // Horodatage de génération : ISO string (Supabase) ou ms — défensif.
  let genMs = null;
  if (typeof row.generated_at === 'string') {
    const p = Date.parse(row.generated_at);
    genMs = Number.isFinite(p) ? p : null;
  } else {
    genMs = feTsMs(row.generated_at);
  }
  if (genMs == null) return null;

  const now = Number.isFinite(nowMs) ? nowMs : Date.now();
  const horizonH = Math.max(0, Math.round((now - genMs) / 3600000));

  const biases = (row.biases && typeof row.biases === 'object') ? row.biases : {};
  const stored = (row.evalRefs && typeof row.evalRefs === 'object') ? row.evalRefs : {};
  const convs = (row.convictions && typeof row.convictions === 'object') ? row.convictions : {};
  const fresh = (freshRefs && typeof freshRefs === 'object') ? freshRefs : {};

  // Une quote « fraîche à l'évaluation » l'est par rapport à `now` injecté
  // (PAS Date.now() : la pureté rend la staleness testable). ts RÉEL exigé :
  // « ts absent = fraîcheur inconnue » est tolérable pour le scoring, pas pour
  // un verdict archivé (miroir de l'exigence de feBuildEvalRefs).
  const freshAt = (ref) => !!(ref && ref.ts != null && Number.isFinite(ref.ts) && now - ref.ts <= FE_QUOTE_MAX_AGE);

  const cats = {};
  const agg = { ok: 0, ko: 0, neutre: 0, na: 0 };

  for (const catId of FE_EVAL_CATS) {
    const bias = (biases[catId] === 'haussier' || biases[catId] === 'baissier' || biases[catId] === 'neutre')
      ? biases[catId] : null;
    const conviction = Number.isFinite(convs[catId]) ? convs[catId] : null;
    const entry = { bias, conviction, pct: null, verdict: 'non_evaluable' };
    const sRef = stored[catId];
    let fRef = fresh[catId];
    // Actif frais ≠ actif stocké (spot) : tenter le ref alternatif dont l'actif
    // correspond (v35 — voir altRefs ci-dessus). Sinon, mismatch → non_evaluable
    // comme avant (comparer SPX à NDX serait malhonnête).
    if (sRef && sRef.kind === 'spot' && fRef && fRef.kind === 'spot' && fRef.asset !== sRef.asset) {
      const alt = alts[catId];
      if (alt && alt.kind === 'spot' && alt.asset === sRef.asset && freshAt(alt)) fRef = alt;
    }

    // Ordre des garde-fous : biais neutre → refs stockés → quote fraîche.
    if (bias == null || bias === 'neutre') {
      // biais absent ou neutre : rien à trader, rien à juger.
    } else if (!sRef || typeof sRef !== 'object') {
      // vieux snapshot sans evalRefs (ou catégorie sans quote au moment T).
    } else if (!fRef || typeof fRef !== 'object' || !freshAt(fRef)) {
      // pas de quote fraîche MAINTENANT → on ne fabrique pas de verdict.
    } else if (sRef.kind === 'basket' && fRef.kind === 'basket') {
      // ── USD panier (inversé) ──
      const sp = (sRef.pairs && typeof sRef.pairs === 'object') ? sRef.pairs : null;
      const fp = (fRef.pairs && typeof fRef.pairs === 'object') ? fRef.pairs : null;
      const keys = ['eurusd', 'gbpusd', 'audusd'];
      if (sp && fp && keys.every(k => Number.isFinite(sp[k]) && sp[k] !== 0 && Number.isFinite(fp[k]))) {
        let sum = 0;
        for (const k of keys) sum += ((fp[k] - sp[k]) / sp[k]) * 100;
        // Les paires montent ⇒ dollar plus faible : signe inversé.
        const pct = Math.round((-sum / keys.length) * 10000) / 10000;
        entry.refPairs = { eurusd: sp.eurusd, gbpusd: sp.gbpusd, audusd: sp.audusd };
        entry.nowPairs = { eurusd: fp.eurusd, gbpusd: fp.gbpusd, audusd: fp.audusd };
        entry.pct = pct;
        entry.verdict = feEvalVerdict(catId, bias, pct);
      }
    } else if (sRef.kind === 'spot' && fRef.kind === 'spot') {
      // ── Spot : même actif exigé (comparer SPX à NDX serait malhonnête) ──
      if (sRef.asset === fRef.asset) {
        const pct = feEvalPct(sRef.price, fRef.price);
        if (pct != null) {
          entry.refPrice = sRef.price;
          entry.nowPrice = fRef.price;
          entry.pct = pct;
          entry.verdict = feEvalVerdict(catId, bias, pct);
        }
      }
    }

    cats[catId] = entry;
    if (entry.verdict === 'juste') agg.ok++;
    else if (entry.verdict === 'faux') agg.ko++;
    else if (entry.verdict === 'neutre') agg.neutre++;
    else agg.na++;
  }

  return { v: 1, horizonH, source: 'intraday', cats, agg };
}

// Verdict d'une catégorie depuis son pct signé : seuil de neutralité par
// catégorie, puis concordance du signe avec le biais noté.
function feEvalVerdict(catId, bias, pct) {
  const thr = FE_EVAL_THRESHOLDS[catId] != null ? FE_EVAL_THRESHOLDS[catId] : 0.15;
  if (Math.abs(pct) < thr) return 'neutre';
  const up = pct > 0;
  return ((bias === 'haussier' && up) || (bias === 'baissier' && !up)) ? 'juste' : 'faux';
}

// ── Routine d'évaluation (fire-and-forget, jamais bloquante) ────────────────
// Appelée après chaque génération DAILY réussie (et sa sauvegarde snapshot) :
// évalue jusqu'à 20 snapshots daily en attente (evaluated_at null) âgés de
// 2 h à 7 jours, en comparant leurs evalRefs stockés aux quotes FRAÎCHES du
// ctx courant (ctx.evalRefs — mêmes sources, honnêteté). Échec silencieux
// (console.warn), l'UI n'attend jamais ce travail.
async function feEvaluatePending(ctx, quotes) {
  try {
    if (!ctx || ctx.scope !== 'daily') return;
    const refs = ctx.evalRefs;
    // AUCUNE quote fraîche dans le ctx courant → on n'évalue rien : marquer
    // « non évaluable » sur une simple panne réseau serait une perte — la
    // fenêtre de 7 jours laisse leur chance aux prochaines générations.
    if (!refs || !FE_EVAL_CATS.some(c => refs[c])) return;

    // Refs ALTERNATIFS (v35) : INDICES est mono-actif dans evalRefs (SPX
    // prioritaire) — porter aussi le NDX frais pour évaluer les snapshots
    // stockés NDX (générés pendant une panne worker) quand le worker est sain.
    const q = (quotes && typeof quotes === 'object') ? quotes : {};
    const altRefs = {};
    const ndx = q.ndx;
    if (ndx && Number.isFinite(ndx.price) && ndx.price > 0
      && ndx.ts != null && Number.isFinite(ndx.ts) && feQuoteFresh(ndx.ts)) {
      altRefs.INDICES = { kind: 'spot', asset: 'NDX', price: ndx.price, ts: ndx.ts };
    }

    const sb = (typeof window !== 'undefined') ? window.sb : null;
    if (!sb || !sb.auth || typeof sb.from !== 'function') return;
    const res = await sb.auth.getUser();
    if (!(res && res.data && res.data.user)) return;    // non connecté

    const now = Date.now();
    const sel = await sb.from('fondamental_snapshots')
      // Projection LÉGÈRE (alias PostgREST ctx->…) : jamais le ctx complet.
      .select('id, generated_at, biases:ctx->biases, evalRefs:ctx->evalRefs, convictions:ctx->convictions')
      .eq('scope', 'daily')
      .is('evaluated_at', null)
      .gte('generated_at', new Date(now - 7 * 24 * 3600 * 1000).toISOString())
      .lte('generated_at', new Date(now - 2 * 3600 * 1000).toISOString())
      .order('generated_at', { ascending: true })
      .limit(20);
    if (sel && sel.error) {
      console.warn('Tempo fondamental — évaluation différée :', sel.error.message || sel.error);
      return;
    }
    const rows = sel && sel.data;
    if (!Array.isArray(rows) || !rows.length) return;

    // Marge avant sortie de la fenêtre de 7 j : en-deçà de cet âge, une catégorie
    // évaluable en théorie (refs stockés + biais tranché) mais sans ref frais
    // MAINTENANT (panne partielle d'une source, actif mismatch) fait REPORTER la
    // ligne au lieu de figer des non_evaluable — les non_evaluable ne sont
    // gravés qu'en dernière chance (snapshot proche de now−7j).
    const FE_EVAL_LAST_CHANCE_MS = 6 * 24 * 3600 * 1000;

    for (const row of rows) {
      try {
        const evaluation = feComputeEvaluation(row, refs, now, altRefs);
        if (!evaluation || !row.id) continue;

        // Report (fix panne partielle) : catégorie au biais tranché, avec refs
        // stockés, mais verdict non_evaluable (pas de ref frais correspondant à
        // l'évaluation) → réessayer à la prochaine génération tant que la ligne
        // a moins de ~6 jours.
        let genMs = typeof row.generated_at === 'string' ? Date.parse(row.generated_at) : feTsMs(row.generated_at);
        const lastChance = !Number.isFinite(genMs) || (now - genMs) >= FE_EVAL_LAST_CHANCE_MS;
        if (!lastChance) {
          const biases = (row.biases && typeof row.biases === 'object') ? row.biases : {};
          const storedRefs = (row.evalRefs && typeof row.evalRefs === 'object') ? row.evalRefs : {};
          const recoverable = FE_EVAL_CATS.some(c => {
            const b = biases[c];
            if (b !== 'haussier' && b !== 'baissier') return false;
            if (!storedRefs[c] || typeof storedRefs[c] !== 'object') return false;
            const e = evaluation.cats && evaluation.cats[c];
            return !!(e && e.verdict === 'non_evaluable');
          });
          if (recoverable) continue;
        }

        const up = await sb.from('fondamental_snapshots')
          .update({ evaluation, evaluated_at: new Date(now).toISOString() })
          .eq('id', row.id);
        if (up && up.error) {
          console.warn('Tempo fondamental — évaluation non enregistrée :', up.error.message || up.error);
        }
      } catch (e) {
        try { console.warn('Tempo fondamental — évaluation non enregistrée :', (e && e.message) || e); } catch (e2) {}
      }
    }
  } catch (e) {
    try { console.warn('Tempo fondamental — évaluation différée :', (e && e.message) || e); } catch (e2) {}
  }
}

// ═══════════════════════════════════════════════════════════════════════════
//  ORCHESTRATEUR — buildFundamentalContext
// ═══════════════════════════════════════════════════════════════════════════

// Étapes onStep (réelles, dans l'ordre — chacune correspond à du vrai travail) :
const FE_STEPS = [
  'Lecture des marchés (indices, taux, dollar, or)…',
  'Positionnement des fonds (COT)…',
  'Sentiment et appétit au risque…',
  'Lecture du calendrier économique…',
  'Lecture des catalyseurs presse…',
  'Synthèse du contexte…',
];

// options : { scope: 'daily'|'weekly', categories: [...], onStep: (i, label) => void, force: bool,
//             onSnapshot: (resultat, ctx) => void }
// onSnapshot (optionnel) est appelé APRÈS le retour de l'analyse, quand son
// enregistrement dans l'historique est terminé : resultat = { ok } | { skipped }
// | { error } (feSaveSnapshot). Jamais appelé pour une analyse relue du cache
// (rien n'est réenregistré).
async function buildFundamentalContext(options) {
  const opts = options || {};
  const scope = (opts.scope === 'weekly') ? 'weekly' : 'daily';
  const categories = Array.isArray(opts.categories) && opts.categories.length
    ? opts.categories.filter(c => FE_LABELS[c])
    : ['USD', 'INDICES', 'GOLD', 'EUR', 'AUD'];
  const onStep = typeof opts.onStep === 'function' ? opts.onStep : function () {};
  const onSnapshot = typeof opts.onSnapshot === 'function' ? opts.onSnapshot : null;

  // Cache (sauf force).
  if (!opts.force) {
    const cached = feReadCache(scope);
    if (cached) return cached;
  }

  const degradedSources = [];

  // ── Étape 1 : marchés — socle v1 (Frankfurter, Binance, CBOE) + FRED v2 +
  //    cotations intraday v3 (JOUR uniquement), tout en parallèle. L'étape
  //    « marchés » COUVRE les quotes : pas d'étape supplémentaire.
  onStep(0, FE_STEPS[0]);
  // Une seule lecture de /fond/quotes par analyse, partagée entre les indices
  // et les cotations du jour.
  const quotesRawP = feFetchQuotesRaw();
  const indicesP = feFetchIndices(quotesRawP);
  // v3.1 : les quotes intraday sont désormais récupérées AUSSI en weekly —
  // uniquement pour poser ctx.evalRefs (références d'évaluation temps réel du
  // track record). Le scoring weekly, lui, ne les consomme jamais (useIntraday
  // reste strictement daily) et la source dégradée « cotations intraday »
  // reste un signal daily uniquement.
  const quotesP = feFetchQuotes(indicesP, quotesRawP);
  const [mkts, fx, gold, indices, intradayData] = await Promise.all([
    feFetchMarkets(),        // v2, best-effort (null si worker v2 absent)
    feFetchFxMomentum(),
    feFetchGold(),
    indicesP,
    quotesP,                 // v3, best-effort (worker → replis client → null)
  ]);
  if (!mkts) degradedSources.push('marchés (FRED)');
  if (!fx) degradedSources.push('devises');
  if (!gold) degradedSources.push('or');
  // Indices : la cascade CBOE client n'est pas la seule source. En Jour
  // (cotations du jour présentes), les cotes CBOE arrivent aussi par
  // /fond/quotes ; sinon (Semaine, ou Jour de repli) le scoring lit d'abord
  // les séries FRED sur 5 séances. « Source partielle » seulement si ce que
  // l'écran affiche pour les indices manque vraiment.
  const qIdx = intradayData && intradayData.quotes;
  const idxCovered = (scope === 'daily' && intradayData)
    ? !!(qIdx && (qIdx.ndx || qIdx.dji || qIdx.vix))
    : !!(mkts && ['spx', 'ndq', 'dji'].some(k => Array.isArray(mkts[k]) && mkts[k].length >= 2));
  if ((!indices || indices.degraded) && !idxCovered) degradedSources.push('indices');
  if (scope === 'daily' && !intradayData) degradedSources.push('cotations du jour');

  // ── Étape 2 : positionnement des fonds (COT, v2 best-effort) ──
  onStep(1, FE_STEPS[1]);
  const cot = await feFetchCot();
  if (!cot) degradedSources.push('positionnement (COT)');

  // ── Étape 3 : sentiment / appétit au risque (v2 best-effort) ──
  onStep(2, FE_STEPS[2]);
  const fng = await feFetchSentiment();
  if (!fng) degradedSources.push('sentiment');

  // ── Étape 4 : calendrier économique (/cal2, secours /) ──
  onStep(3, FE_STEPS[3]);
  const calRaw = await feFetchCalendar(scope);
  // Dégradé UNIQUEMENT si le réseau a échoué (null) — un calendrier vide est une
  // information valide (période calme), pas une source en panne.
  // « Partiel » aussi quand seule la source de secours a répondu et que la
  // fenêtre analysée dépasse sa couverture (la semaine en cours).
  const calPartial = calRaw !== null && calRaw.partial === true;
  if (calRaw === null || calPartial) degradedSources.push('calendrier');
  const cal = calRaw || [];

  // ── Étape 5 : actualités (une lecture, voir feFetchNews) ──
  onStep(4, FE_STEPS[4]);
  const newsRead = await feFetchNews();
  // Lecture en direct réussie ? Sinon les titres (s'il y en a) sont ceux gardés
  // par l'Accueil : datés, donc signalés dans « Sources partielles ».
  const newsLive = newsRead.live;
  if (!newsLive) degradedSources.push('actualités');

  // Déduplication par titre normalisé, cap 150 (le scoring reste borné).
  const news = [];
  const seenTitles = new Set();
  for (const n of newsRead.items) {
    if (!n) continue;
    const key = String(n.headline || '').toLowerCase().replace(/\s+/g, ' ').trim();
    if (!key || seenTitles.has(key)) continue;
    seenTitles.add(key);
    news.push(n);
    if (news.length >= 150) break;
  }

  // Panne de TOUTES les sources : aucune donnée de marché (FRED, devises, or,
  // indices, cotations du jour) ET aucun appui (positionnement, sentiment,
  // calendrier, presse lue en direct). Les fonctions de score produiraient
  // quand même cinq cartes « neutres » : on s'arrête ici, AVANT tout calcul et
  // toute écriture (cache local, historique, évaluation des biais passés).
  // L'appelant affiche « Analyse impossible » ou garde l'analyse déjà à l'écran.
  // Le critère presse est la LECTURE RÉSEAU : des titres relus du cache local
  // ne prouvent pas qu'une source a répondu.
  const aucunMarche = !mkts && !fx && !gold && !intradayData && (!indices || indices.degraded);
  const aucunAppui = !cot && !fng && calRaw === null && !newsLive;
  if (aucunMarche && aucunAppui) throw new Error('fond-aucune-source');

  // ── Étape 6 : synthèse (scoring par catégorie + summary + hebdo) ──
  onStep(5, FE_STEPS[5]);

  const cats = [];
  const catsById = {};
  // v3 : le JOUR est intraday-first quand les cotations sont là ; sinon repli
  // sur le scoring actuel avec conviction PLAFONNÉE à 2 (un biais Jour sans
  // données du jour ne peut pas être convaincu). Le WEEKLY ne change pas.
  // Fraîcheur, calculée UNE fois pour toute l'analyse : séance de New York
  // (indices, VIX : « en séance », « à la clôture de vendredi »), marché des
  // changes fermé (week-end) et variations du jour change/or depuis minuit,
  // heure du profil (texte, score et panier dollar lisent les mêmes valeurs).
  const nowMs = Date.now();
  const sessionOpen = feUsCashOpen(nowMs);
  const usDay = feLastUsSessionDay(nowMs);
  const usWhen = feSessionWhen(sessionOpen, usDay);
  const fxClosed = feFxMarketClosed(nowMs);
  const goldClosed = feGoldMarketClosed(nowMs);
  const dayMoves = feDayMoves(
    (intradayData && intradayData.quotes) || {},
    (intradayData && intradayData.klines) || {},
    nowMs
  );
  const dailyMeta = { intraday: 0 };
  const useIntraday = (scope === 'daily') && !!intradayData;
  const calOk = calRaw !== null; // false = panne réseau calendrier (≠ période calme)
  for (const catId of categories) {
    const newsRes = feNewsFor(news, catId);
    let cat;
    if (useIntraday) {
      const dataD = {
        quotes: (intradayData && intradayData.quotes) || {},
        klines: (intradayData && intradayData.klines) || {},
        moves: dayMoves, fxClosed, goldClosed, usDay, usWhen,
        cal: cal || [], newsRes, news, mkts, cot, fng, sessionOpen, meta: dailyMeta,
      };
      cat = feScoreDailyCat(catId, dataD);
    } else {
      const data = { fx, gold, indices, cal: cal || [], newsRes, mkts, cot, fng, usWhen };
      if (catId === 'USD')          cat = feScoreUSD(data);
      else if (catId === 'INDICES') cat = feScoreIndices(data);
      else if (catId === 'GOLD')    cat = feScoreGold(data);
      else                          cat = feScoreFxPair(catId, data);   // EUR | AUD
      if (scope === 'daily') cat.conviction = Math.min(cat.conviction, 2);
    }
    // Phrases de remplissage, selon ce qui a réellement répondu.
    feFillDrivers(cat, { calOk, calPartial, newsOk: newsLive });
    // Enrichissement v2 : sparklines + positionnement (tableaux/objets sûrs, jamais undefined).
    cat.series = feSeriesFor(catId, mkts, gold, fx);
    cat.cot = feCotFor(catId, cot);
    cats.push(cat);
    catsById[catId] = cat;
  }

  const safeCal = cal || [];
  const pivotEvent = fePivotEvent(safeCal);
  // Calendrier en PANNE : « faible » affirmerait un fait non mesuré → « moyen »
  // par prudence (le badge ne prétend plus que la semaine est calme).
  // Même prudence quand la source de secours ne couvre pas toute la fenêtre.
  let riskEvent = calOk ? feGlobalRisk(safeCal) : 'moyen';
  if (calPartial && riskEvent === 'faible') riskEvent = 'moyen';
  // v3 : le summary JOUR mène avec l'intraday quand ≥1 driver intraday réel ;
  // sinon summary actuel + mention honnête (cotations du jour indisponibles).
  let summary;
  if (useIntraday && dailyMeta.intraday > 0) {
    summary = feBuildDailySummary((intradayData && intradayData.quotes) || {}, sessionOpen, pivotEvent, riskEvent, calOk,
      { usWhen, moves: dayMoves, fxClosed, calPartial });
  } else {
    summary = feBuildSummary(catsById, safeCal, indices, fx, pivotEvent, riskEvent, mkts, calOk, calPartial);
    if (scope === 'daily') {
      summary += ' Cotations du jour indisponibles — biais fondé sur les données de fond, conviction limitée.';
    }
  }

  const ctx = {
    v: 2,
    // Titres d'événements en français (feEvTitle) : marqueur lu par feReadCache.
    titlesFr: true,
    generatedAt: Date.now(),
    scope,
    riskEvent,
    summary,
    pivotEvent,
    disclaimer: FE_DISCLAIMER,
    degradedSources,
    categories: cats,
    // Projection LÉGÈRE des biais ({ USD:'haussier', … }) : permet à la liste
    // Historique de tracer l'évolution du biais sans télécharger le ctx complet.
    biases: cats.reduce((acc, c) => { acc[c.id] = c.bias; return acc; }, {}),
    // Projection légère miroir de `biases` : conviction 1..5 par catégorie —
    // relue par l'évaluation différée (verdict avec sa conviction d'origine)
    // sans jamais retélécharger le ctx complet.
    convictions: cats.reduce((acc, c) => { acc[c.id] = c.conviction; return acc; }, {}),
  };

  // v3.1 additif (daily ET weekly) : références d'évaluation du track record,
  // depuis les quotes intraday fraîches. Catégorie sans quote fraîche → null
  // (jamais de prix FRED/stale — un verdict se mesure sur du temps réel).
  ctx.evalRefs = feBuildEvalRefs(intradayData && intradayData.quotes);

  // v3 additif (scope daily) : true si au moins UN driver intraday réel a été
  // construit — l'UI peut ainsi distinguer un biais Jour « du jour » d'un biais
  // Jour de repli (données de fond seulement).
  if (scope === 'daily') ctx.intraday = useIntraday && dailyMeta.intraday > 0;

  // Scope hebdo : préparation de la semaine à venir + bilan chiffré de la semaine écoulée.
  if (scope === 'weekly') {
    // Calendrier en panne : une liste vide ne dirait pas « semaine calme » mais
    // « donnée absente » → drapeau `unavailable`. Source de secours seule :
    // `partial` (la liste s'arrête à la fin de la semaine en cours).
    // Calendrier complet sur toute la fenêtre : marqueur POSITIF `full`. Lui
    // seul autorise l'écran à affirmer « aucun événement » devant une liste
    // vide : les analyses enregistrées avant ce marqueur (flux d'une seule
    // semaine, épuisé le week-end) portent une liste vide sans aucun drapeau.
    const weekCurrencies = [];
    for (const catId of categories) {
      const cur = FE_CAT_CURRENCY[catId];
      if (cur && weekCurrencies.indexOf(cur) < 0) weekCurrencies.push(cur);
    }
    ctx.weekAhead = calOk ? feBuildWeekAhead(safeCal, weekCurrencies) : { days: [], unavailable: true };
    if (calPartial) ctx.weekAhead.partial = true;
    else if (calOk) ctx.weekAhead.full = true;
    ctx.weekReview = feBuildWeekReview(mkts, gold, fx);
    summary += feWeekAheadSummary(ctx.weekAhead);
    ctx.summary = summary;
  }

  feWriteCache(scope, ctx);
  // Enregistrement dans l'historique : lancé sans attente (jamais bloquant).
  // Son résultat, échec compris, est rendu à l'appelant par onSnapshot.
  feSaveSnapshot(ctx).then((resultat) => {
    if (!onSnapshot) return;
    try { onSnapshot(resultat, ctx); } catch (e) {}
  });
  // Évaluation différée des snapshots daily en attente (track record) —
  // fire-and-forget elle aussi, après la sauvegarde, daily uniquement.
  if (scope === 'daily') { try { feEvaluatePending(ctx, intradayData && intradayData.quotes); } catch (e) {} }
  return ctx;
}

// ═══════════════════════════════════════════════════════════════════════════
//  EXPOSITION GLOBALE (pas d'import ES — tout via window)
// ═══════════════════════════════════════════════════════════════════════════

Object.assign(window, {
  buildFundamentalContext,
  // Enregistrement d'une analyse dans l'historique : rappelé par l'écran
  // (« Réessayer ») quand l'enregistrement fait à la fin de l'analyse a échoué.
  // Renvoie { ok } | { skipped } | { error } — voir feSaveSnapshot.
  fondSaveSnapshot: feSaveSnapshot,
  // v2.2 : péremption événementielle d'un ctx (utilisée par l'UI pour le bandeau
  // « événement majeur passé » sur les snapshots hydratés et le cache local).
  fondCtxOutdatedByEvent: feCtxOutdatedByEvent,
  // v3.1 : calcul pur du bilan d'un snapshot (track record) — exposé pour les
  // tests et d'éventuels usages UI ; ne fait AUCUN réseau.
  fondComputeEvaluation: feComputeEvaluation,
  // v35 : routine d'évaluation différée — exposée pour les tests (fixtures
  // window.sb) ; en production elle n'est appelée que par l'orchestrateur.
  fondEvaluatePending: feEvaluatePending,
  // Constantes utiles à l'UI.
  FE_STEPS,
  FE_DISCLAIMER,
  FE_LABELS,
});
