// 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 éco maison, Frankfurter/BCE, Binance PAXG, CBOE best-effort,
// news RSS déjà en prod) 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 }) → Promise<ctx>.
// 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';

// CBOE : pas de CORS → cascade de relais (best-effort). Dégrade proprement.
const FE_CBOE = {
  NDX: 'https://cdn.cboe.com/api/global/delayed_quotes/quotes/_NDX.json',
  DJI: 'https://cdn.cboe.com/api/global/delayed_quotes/quotes/_DJI.json',
  VIX: 'https://cdn.cboe.com/api/global/delayed_quotes/quotes/_VIX.json',
};

// Cascade de relais CORS, identique en esprit à news.jsx (allorigins/raw → corsproxy →
// codetabs → allorigins/get avec champ `contents`). Chaque relais a un timeout court ;
// le premier qui répond gagne, sinon on passe au suivant.
const FE_PROXIES = [
  { make: (u) => 'https://api.allorigins.win/raw?url=' + encodeURIComponent(u), json: false },
  { make: (u) => 'https://corsproxy.io/?url=' + encodeURIComponent(u),          json: false },
  { make: (u) => 'https://api.codetabs.com/v1/proxy?quest=' + encodeURIComponent(u), json: false },
  { make: (u) => 'https://api.allorigins.win/get?url=' + encodeURIComponent(u), json: true  },
];

const FE_ECON_TIMEOUT   = 9000;   // calendrier : notre worker, un peu plus de marge
const FE_DIRECT_TIMEOUT = 8000;   // Frankfurter / Binance (directs)
const FE_CBOE_TIMEOUT   = 7000;   // par relais CBOE (court, on enchaîne vite)
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;
}

// Formate un pourcentage à la française : 0.834 → « +0,8% ».
function fePct(x, digits = 1) {
  if (!Number.isFinite(x)) return '—';
  const s = x.toFixed(digits).replace('.', ',');
  return (x > 0 ? '+' : '') + s + '%';
}

// Formate un nombre à la française (VIX 15.8 → « 15,8 »).
function feFixed(x, digits = 1) {
  if (!Number.isFinite(x)) return '—';
  return x.toFixed(digits).replace('.', ',');
}

// 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}`;
}

// Fuseau d'affichage : Europe/Paris par défaut, respecte __appTz si présent.
function feTz() {
  return (typeof window !== 'undefined' && window.__appTz) || 'Europe/Paris';
}

// Formate une date d'événement en heure de Paris : « mar. 14:30 ».
// Parse une ISO avec offset (ex '2026-06-28T08:15:00-04:00') — Date() gère l'offset,
// Intl reprojette sur le fuseau cible.
function feWhenFR(iso) {
  try {
    const d = new Date(iso);
    if (isNaN(d.getTime())) return '';
    const jour = new Intl.DateTimeFormat('fr-FR', {
      weekday: 'short', timeZone: feTz(),
    }).format(d).replace('.', '');         // « mar » (sans point ajouté par certains locales)
    const heure = new Intl.DateTimeFormat('fr-FR', {
      hour: '2-digit', minute: '2-digit', hour12: false, timeZone: feTz(),
    }).format(d);
    // On reforme « mar. 14:30 »
    return `${jour}. ${heure}`;
  } catch (e) {
    return '';
  }
}

// Formate un entier à la française (12345 → « 12 345 »). Pour les contrats COT.
function feIntFR(n) {
  if (!Number.isFinite(n)) return '—';
  try { return Math.round(n).toLocaleString('fr-FR'); }
  catch (e) { return String(Math.round(n)); }
}

// Clé de jour 'YYYY-MM-DD' PROJETÉE sur le fuseau d'affichage (Paris) — pour
// grouper les événements de la semaine à venir par jour local, pas par jour UTC.
function feDateKeyTZ(ts) {
  try {
    return new Intl.DateTimeFormat('en-CA', {
      timeZone: feTz(), year: 'numeric', month: '2-digit', day: '2-digit',
    }).format(new Date(ts));
  } catch (e) {
    try { return new Date(ts).toISOString().slice(0, 10); } catch (e2) { return ''; }
  }
}

// Heure locale « 14:30 » (fuseau Paris) pour un timestamp.
function feTimeFR(ts) {
  try {
    return new Intl.DateTimeFormat('fr-FR', {
      hour: '2-digit', minute: '2-digit', hour12: false, timeZone: feTz(),
    }).format(new Date(ts));
  } catch (e) { return ''; }
}

// Libellé de jour « Lundi 6 juil. » à partir d'une clé 'YYYY-MM-DD'.
// On ancre à midi UTC pour éviter toute bascule de jour lors de la reprojection.
function feDayLabelFR(dateKey) {
  try {
    const d = new Date(dateKey + 'T12:00:00Z');
    if (isNaN(d.getTime())) return dateKey;
    const s = new Intl.DateTimeFormat('fr-FR', {
      weekday: 'long', day: 'numeric', month: 'short', timeZone: feTz(),
    }).format(d);
    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.
function feWeekdayFR(dateKey) {
  try {
    const d = new Date(dateKey + 'T12:00:00Z');
    if (isNaN(d.getTime())) return '';
    return new Intl.DateTimeFormat('fr-FR', { weekday: 'long', timeZone: feTz() }).format(d);
  } catch (e) { return ''; }
}

// ── 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
// ═══════════════════════════════════════════════════════════════════════════

// Récupère la semaine EN COURS uniquement : le flux « nextweek » est MORT côté
// FairEconomy (404 définitif, décision utilisateur 2026-08 « semaine en cours
// seule ») — et le worker actuellement déployé re-bootstrappe nextweek à chaque
// requête (~22 s → 502), ce qui figeait l'étape « Lecture du calendrier… » de
// CHAQUE analyse ~9 s (FE_ECON_TIMEOUT). La fenêtre 48 h / 7 j, la dédup et
// l'état vide de FondWeekAhead gèrent déjà un flux unique.
// Renvoie : tableau trié (éventuellement VIDE = calendrier calme, source OK) ;
// null = AUCUN flux n'a répondu (vraie dégradation réseau).
async function feFetchCalendar(scope) {
  const urls = [FE_ECON_BASE + '/'];

  const now = Date.now();
  const windowMs = scope === 'weekly' ? 7 * 24 * 3600 * 1000 : 48 * 3600 * 1000;
  const horizon = now + windowMs;

  const raws = await Promise.all(urls.map(u => feFetchJSON(u, FE_ECON_TIMEOUT)));
  let anyOk = false;
  const all = [];
  for (const raw of raws) {
    const arr = Array.isArray(raw) ? raw : (raw && Array.isArray(raw.events) ? raw.events : null);
    if (!arr) continue;
    anyOk = true;
    for (const ev of arr) {
      if (!ev || !ev.date) continue;
      const ts = new Date(ev.date).getTime();
      if (!Number.isFinite(ts)) continue;
      all.push({
        title: String(ev.title || '').trim(),
        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)),
      });
    }
  }
  // Aucun flux n'a répondu → dégradation réseau réelle (≠ calendrier vide).
  if (!anyOk) return null;
  // Déduplication (v2) : les feeds thisweek/nextweek peuvent se recouvrir en
  // bordure de semaine → clé titre+devise+timestamp, première occurrence gagne.
  const seen = new Set();
  const uniq = [];
  for (const e of all) {
    const key = `${e.title.toLowerCase()}|${e.country}|${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);
  return upcoming;
}

// 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 »).
function feCalNumTxt(v) {
  const s = Number.isInteger(v.n) ? String(v.n) : feFixed(v.n, 1);
  return s + (v.unit || '');
}

// Filtre les événements d'une devise donnée, ne garde que High/Medium pour les catalyseurs.
function feCatalystsFor(events, currency, max = 4) {
  return events
    .filter(e => !e.passed && e.country === currency && /high|medium/i.test(e.impact))
    .slice(0, max)
    .map(e => ({
      title: e.title || 'Événement',
      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),
  };
}

// GET /fond/news → { updatedAt, items:[{t,link,src,lang,ts}] }.
// Renvoie un tableau au MÊME format que window.loadNews ({headline,source,time,link,ts})
// pour se fondre dans le pipeline news v1 (scoring + headlines), ou null si échec.
// v2.2 : le `ts` (ms) est CONSERVÉ sur chaque item — comme le fait déjà loadNews —
// pour permettre la pondération par fraîcheur dans feNewsFor.
async function feFetchNews2() {
  const raw = await feFetchJSON(FE_ECON_BASE + '/fond/news', FE_FOND_TIMEOUT);
  if (!raw || !Array.isArray(raw.items) || !raw.items.length) return null;
  const out = [];
  for (const it of raw.items) {
    if (!it) continue;
    const title = String(it.t || '').trim();
    if (!title) continue;
    // ts peut être en secondes ou millisecondes selon le flux : on normalise.
    const tsRaw = feNum(it.ts);
    const tsMs = tsRaw == null ? null : (tsRaw > 1e12 ? tsRaw : tsRaw * 1000);
    let time = '';
    if (tsMs != null) {
      try { time = feWhenFR(new Date(tsMs).toISOString()); } catch (e) { time = ''; }
    }
    out.push({
      headline: title,
      source: String(it.src || '').trim(),
      time,
      link: String(it.link || '').trim(),
      // ts en ms (normalisé ci-dessus) ou null — même convention que loadNews.
      ts: tsMs,
    });
  }
  return out.length ? out : null;
}

// ═══════════════════════════════════════════════════════════════════════════
//  É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 };
}

// CBOE via cascade de relais. Renvoie l'objet .data ({ current_price, price_change_percent })
// ou null. Chaque relais a un timeout court ; on enchaîne au premier échec.
async function feFetchCboeOne(url) {
  for (const proxy of FE_PROXIES) {
    try {
      const r = await feFetch(proxy.make(url), FE_CBOE_TIMEOUT);
      if (!r || !r.ok) continue;
      let json;
      if (proxy.json) {
        const wrapper = await r.json();
        const txt = wrapper && wrapper.contents;
        if (!txt) continue;
        json = JSON.parse(txt);
      } else {
        json = await r.json();
      }
      if (json && json.data) return json.data;
    } catch (e) {
      // relais suivant
    }
  }
  return null;
}

// Récupère NDX, DJI, VIX en parallèle. Renvoie { NDX, DJI, VIX } (valeurs null si KO)
// + degraded=true si TOUT a échoué.
async function feFetchIndices() {
  const [ndx, dji, vix] = await Promise.all([
    feFetchCboeOne(FE_CBOE.NDX),
    feFetchCboeOne(FE_CBOE.DJI),
    feFetchCboeOne(FE_CBOE.VIX),
  ]);
  const degraded = !ndx && !dji && !vix;
  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 100% client : CBOE via la cascade de
// relais existante (NDX/DJI/VIX, déjà lancée par feFetchIndices — on réutilise
// sa promesse, 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 du jour » côté client,
// même convention que le kline 1d UTC Binance utilisé par le worker.
function feUtcMidnight(nowMs) {
  const d = new Date(Number.isFinite(nowMs) ? nowMs : Date.now());
  return Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}

// Session cash US ouverte ? (9h30-16h00 New York, jours ouvrés — soit
// 15h30-22h00 à Paris hors décalages DST). false par prudence en cas de doute.
function feUsCashOpen(nowMs) {
  try {
    const parts = new Intl.DateTimeFormat('en-US', {
      timeZone: 'America/New_York', weekday: 'short',
      hour: '2-digit', minute: '2-digit', hour12: false,
    }).formatToParts(new Date(Number.isFinite(nowMs) ? nowMs : Date.now()));
    const get = (t) => { const p = parts.find(x => x.type === t); return p ? p.value : ''; };
    const wd = get('weekday');
    if (wd === 'Sat' || wd === 'Sun') return false;
    let h = parseInt(get('hour'), 10);
    if (h === 24) h = 0;                       // certains moteurs rendent '24' à minuit
    const m = parseInt(get('minute'), 10);
    if (!Number.isFinite(h) || !Number.isFinite(m)) return false;
    const mins = h * 60 + m;
    return mins >= 9 * 60 + 30 && mins < 16 * 60;
  } catch (e) { return false; }
}

// 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
// intraday' + 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}]} }.
async function feFetchQuotesWorker() {
  const raw = await feFetchJSON(FE_ECON_BASE + '/fond/quotes', FE_QUOTES_TIMEOUT);
  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 du jour + klines.
// chgPct = depuis l'OPEN du jour UTC courant (repli : première bougie de la
// fenêtre ~24 h — cas week-end FX où aucune bougie ne tombe « aujourd'hui »).
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);
}

// 2) Repli client complet (ancien worker → /fond/quotes 404). `indicesP` est la
//    promesse CBOE déjà lancée par l'orchestrateur (cascade de relais existante).
async function feFetchQuotesClient(indicesP) {
  const settled = await Promise.allSettled([
    Promise.resolve(indicesP),
    feFetchBinance15m('EURUSDT'),
    feFetchBinance15m('PAXGUSDT'),
    feFetchKraken15m('GBPUSD'),
    feFetchKraken15m('AUDUSD'),
  ]);
  const val = (i) => (settled[i] && settled[i].status === 'fulfilled' ? settled[i].value : null);
  const idx = val(0), eur = val(1), gld = val(2), gbp = val(3), aud = val(4);

  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' };
    }
  }
  for (const [key, r] of [['eurusd', eur], ['gold', gld], ['gbpusd', gbp], ['audusd', aud]]) {
    if (!r || !r.quote) continue;
    if (!feQuoteFresh(r.quote.ts)) continue;   // carnet creux : dernière bougie trop vieille
    quotes[key] = r.quote;
    if (Array.isArray(r.klines) && r.klines.length >= 2) klines[key] = r.klines;
  }
  return Object.keys(quotes).length ? { quotes, klines } : null;
}

// Chaîne complète : worker ET repli client lancés EN PARALLÈLE (worker injoignable
// = 6 s d'abort ; en séquence l'étape 1 pouvait durer ~14 s). Le résultat worker
// est préféré s'il arrive ; sinon le client, déjà en vol, répond sans repartir de
// zéro. Coût : quelques requêtes Binance/Kraken superflues quand le worker répond
// (bornées, sans effet de bord). null si tout échoue (→ degradedSources
// 'cotations intraday', conviction Jour plafonnée à 2).
async function feFetchQuotes(indicesP) {
  const clientP = feFetchQuotesClient(indicesP).catch(() => null);
  try {
    const w = await feFetchQuotesWorker();
    if (w) return w;
  } catch (e) {}
  try {
    return await clientP;
  } catch (e) { return null; }
}

// ═══════════════════════════════════════════════════════════════════════════
//  É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 items de loadNews (news.jsx)
// comme ceux de /fond/news 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 ${net >= 0 ? '+' : ''}${feIntFR(net)} contrats`;
  if (delta == null || Math.abs(delta) < 1500) {
    return {
      text: `COT ${labelFR} : positionnement des fonds stable (${netTxt})`,
      tone: 'neutre', kind: 'positionnement', contrib: 0,
    };
  }
  const dTxt = `${delta > 0 ? '+' : ''}${feIntFR(delta)} sur la semaine`;
  let tone = delta > 0 ? 'hausse' : 'baisse';
  let text = `COT ${labelFR} : 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' : 'au-dessus de';
    const txt = `${e.title} attendu ${feCalNumTxt(f)} ${sens} le 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: contrib > 0 ? 'hausse' : (contrib < 0 ? 'baisse' : 'neutre'),
      kind: 'marché',
    });
  }

  // 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 à ${feFixed(us10D.last, 2)}% (${bp > 0 ? '+' : ''}${bp} pb 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)} pt) — signal 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) {
        drivers.push({
          text: `USD ${fePct(p)} vs ${sym} sur ${obj.seances} séances`,
          tone: p > 0 ? 'hausse' : 'baisse',
          kind: 'marché',
        });
      }
    }
  }

  // 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é',
    });
  }

  return feFinalize('USD', score, drivers, cal, newsRes, false);
}

// ── 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: `${sym} ${fePct(symMove)} vs USD sur ${obj.seances} séances`,
        tone: symMove > 0 ? 'hausse' : 'baisse',
        kind: 'marché',
      });
    }
  }

  // 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 (+${bp} pb sur ${y10Pair.seances} séances) — différentiel de taux défavorable`
          : `Rendements US en baisse (${bp} pb 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é',
    });
  }

  return feFinalize(sym, score, drivers, cal, newsRes, false);
}

// ── 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;
    drivers.push({
      text: `Or ${fePct(p)} sur ${gold.seances} séances`,
      tone: p > 0 ? 'hausse' : (p < 0 ? 'baisse' : 'neutre'),
      kind: 'marché',
    });
  }

  // 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)} moyen vs devises) — vent contraire`
            : `Dollar en repli (${fePct(usdMove)} moyen vs 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 (${bp} pb sur ${y10Gold.seances} séances) — soutien pour l'or`
          : `Tension des taux US (+${bp} pb 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).
  return feFinalize('GOLD', score, drivers, cal, newsRes, false, 'USD');
}

// ── INDICES ────────────────────────────────────────────────────────────────────
function feScoreIndices(data) {
  const { indices, cal, newsRes, mkts, cot, fng } = data;
  const drivers = [];
  let score = 0;

  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 = [];
    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) score += dd.pct > 0 ? 2 : -2;
        else if (Math.abs(dd.pct) >= 0.6) score += dd.pct > 0 ? 1 : -1;
      }
    }
    const comptes = [spxD, ndqD].filter(Boolean).map(d => d.pct);
    const moyenne = comptes.length ? comptes.reduce((a, b) => a + b, 0) / comptes.length : 0;
    const ref = spxD || ndqD || djiD;
    drivers.push({
      text: `${bits.join(', ')} sur ${ref.seances} séances`,
      tone: moyenne > 0.15 ? 'hausse' : (moyenne < -0.15 ? 'baisse' : 'neutre'),
      kind: 'marché',
    });
  }

  // 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)} en séance`,
        tone: p > 0 ? 'hausse' : (p < 0 ? 'baisse' : 'neutre'),
        kind: 'marché',
      });
    }
  }

  // 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)})` : '';
      drivers.push({
        text: `VIX ${feFixed(vixLvl)}${chgTxt} — volatilité ${vixLvl < 14 ? 'contenue' : (vixLvl > 20 ? 'élevée' : 'modérée')}`,
        tone: vixLvl < 14 ? 'hausse' : (vixLvl > 20 ? 'baisse' : 'neutre'),
        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: lvl < 14 ? 'hausse' : (lvl > 20 ? 'baisse' : 'neutre'),
        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. ${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 (proxy risque) ${v} « ${labFR} »${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');
}

// ═══════════════════════════════════════════════════════════════════════════
//  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'); }

// Panier dollar INVERSÉ : moyenne des chgPct du jour des paires (EUR/USD,
// GBP/USD, AUD/USD), signe inversé = variation implicite du dollar. null si
// aucune paire disponible.
function feUsdBasket(quotes) {
  const parts = [];
  for (const k of ['eurusd', 'gbpusd', 'audusd']) {
    const q = quotes && quotes[k];
    if (q && Number.isFinite(q.chgPct)) parts.push(q.chgPct);
  }
  if (!parts.length) return null;
  return { usd: -(parts.reduce((a, b) => a + b, 0) / parts.length), n: parts.length };
}

// 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: `${ev.title} publié à ${timeTxt} — réaction : ${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: `${ev.title} 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: `${ev.title} 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 { quotes, klines, cal, newsRes, news, mkts, cot, meta } = data;
  const drivers = [];
  let score = 0;
  let intra = 0;

  // 1) Panier de devises INVERSÉ (EUR/GBP/AUD vs USD) → ±3.
  const basket = feUsdBasket(quotes);
  if (basket) {
    const contrib = feIntradayContrib3(basket.usd);
    score += contrib;
    drivers.push({
      text: `Dollar ${fePct(basket.usd)} aujourd'hui face au panier de devises (${basket.n} paire${basket.n > 1 ? 's' : ''})`,
      tone: feToneOf(contrib), kind: 'marché',
    });
    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 à ${feFixed(us10D.last, 2)}% (${bp > 0 ? '+' : ''}${bp} pb 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);
}

// ── 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;

  // 1) Composite NDX/DJI du jour (SPX en appui) → ±3 ; session cash fermée →
  //    dernier échange connu, poids réduit à ±2 et mention honnête.
  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);
    let suffix = 'en séance';
    if (!sessionOpen) {
      contrib = Math.max(-2, Math.min(2, contrib));
      suffix = 'au dernier échange (session cash fermée)';
    }
    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é' });
    intra++;
  }

  // 2) VIX du jour → ±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)} aujourd'hui)` : '';
    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 : session ouverte → chgPct composite en
  //    approximation « depuis l'événement » ; session 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
               && Date.now() - evIdx.ts < 3 * 3600e3) {
      // Session fermée : le VIX n'est qu'un PROXY — vixChg est la variation du
      // JOUR (vs close de la veille), pas depuis l'événement. Garde-fous : cote
      // datée et POSTÉRIEURE à la publication (jamais le chgPct de la veille
      // servi avant l'ouverture), é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: `${evIdx.title} publié à ${feTimeFR(evIdx.ts) || '—'} — session cash fermée, VIX ${fePct(vixChg)} aujourd'hui (proxy, 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 (proxy 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. ${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 (proxy risque) ${v} « ${labFR} »${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 : ${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');
}

// ── JOUR · Or ────────────────────────────────────────────────────────────────
function feScoreDailyGold(data) {
  const { quotes, klines, cal, newsRes, news, mkts, cot, meta } = data;
  const drivers = [];
  let score = 0;
  let intra = 0;

  // 1) PAXG du jour → ±3.
  const g = quotes.gold;
  if (g && Number.isFinite(g.chgPct)) {
    const contrib = feIntradayContrib3(g.chgPct);
    score += contrib;
    const prixTxt = Number.isFinite(g.price) ? ` (${feFixed(g.price, 0)} USD)` : '';
    drivers.push({
      text: `Or ${fePct(g.chgPct)} aujourd'hui${prixTxt}`,
      tone: feToneOf(contrib), kind: 'marché',
    });
    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 intraday INVERSÉ → ±1.
  const basketG = feUsdBasket(quotes);
  if (basketG && Math.abs(basketG.usd) >= 0.4) {
    const contrib = basketG.usd > 0 ? -1 : 1;
    score += contrib;
    drivers.push({
      text: basketG.usd > 0
        ? `Dollar ${fePct(basketG.usd)} aujourd'hui — vent contraire pour l'or`
        : `Dollar ${fePct(basketG.usd)} aujourd'hui — 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 (${bp} pb sur ${y10Gold.seances} séances) — soutien de fond pour l'or`
          : `Tension des taux US (+${bp} pb 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');
}

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

  // 1) chgPct du jour de la paire → ±3.
  const q = quotes[key];
  if (q && Number.isFinite(q.chgPct)) {
    const contrib = feIntradayContrib3(q.chgPct);
    score += contrib;
    const prixTxt = Number.isFinite(q.price) ? ` (${feFixed(q.price, 4)})` : '';
    drivers.push({
      text: `${pairLabel} ${fePct(q.chgPct)} aujourd'hui${prixTxt}`,
      tone: feToneOf(contrib), kind: 'marché',
    });
    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);
}

// 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);
}

// Summary JOUR mené par l'INTRADAY : « En séance : Nasdaq +2,3%, Dow +1,8%,
// dollar −0,6% face au panier… », puis pivot/risque, puis volatilité.
function feBuildDailySummary(quotes, sessionOpen, pivot, riskEvent, calOk) {
  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 '));
  const basket = feUsdBasket(quotes);
  if (basket) bits.push(`dollar ${fePct(basket.usd)} face au panier`);
  const g = quotes.gold;
  if (g && Number.isFinite(g.chgPct) && Math.abs(g.chgPct) >= 0.3) bits.push(`or ${fePct(g.chgPct)}`);

  const parts = [];
  if (bits.length) parts.push(`${sessionOpen ? 'en séance' : 'au dernier échange'} : ${bits.join(', ')}`);
  if (riskEvent === 'eleve' && pivot) {
    parts.push(`risque événementiel élevé — ${pivot.title} ${pivot.whenFR}`);
  } else if (riskEvent === 'moyen' && pivot) {
    parts.push(`risque événementiel modéré — ${pivot.title} ${pivot.whenFR}`);
  } else if (pivot) {
    parts.push(`prochain temps fort : ${pivot.title} ${pivot.whenFR}`);
  } else if (calOk === false) {
    parts.push('calendrier économique indisponible sur la période (source en panne)');
  } else {
    parts.push('calendrier économique allégé sur la période');
  }

  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)} aujourd'hui` : '';
    s += ` Volatilité ${qual} (VIX ${feFixed(vixQ.price)}${chg}).`;
  }
  return s;
}

// ── Finalisation commune ───────────────────────────────────────────────────────
// Assemble la catégorie : biais, conviction, drivers (2-5), catalystes, headlines, risque.
function feFinalize(catId, score, drivers, cal, newsRes, degraded, catalystCurrency) {
  // 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 v2 (documentée) ──
  // 1) Base = paliers sur |score| comme en v1 (feConviction : 1→2→3→4→5 à 1/2/3/4).
  // 2) Ajustement par COHÉRENCE des drivers directionnels (tone hausse/baisse) :
  //    - ≥3 drivers directionnels TOUS dans le même sens → +1 (max 5) : faisceau concordant ;
  //    - au moins 1 driver dans CHAQUE sens → plafond 3 : signaux contradictoires.
  // 3) Garde-fous v1 conservés : sources 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 >= 3 && nbDown === 0) || (nbDown >= 3 && nbUp === 0)) conviction = Math.min(5, conviction + 1);
  if (nbUp > 0 && nbDown > 0) conviction = Math.min(conviction, 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é.
  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));
  let finalDrivers = sorted.slice(0, 5);
  if (finalDrivers.length === 0) {
    finalDrivers.push({
      text: degraded
        ? 'Données de marché indisponibles — contexte fondé sur le calendrier et la presse'
        : 'Pas de biais directionnel marqué sur la période',
      tone: 'neutre',
      kind: 'marché',
    });
  }
  if (finalDrivers.length === 1) {
    finalDrivers.push({
      text: 'Contexte à confirmer par les prochains catalyseurs',
      tone: 'neutre',
      kind: 'calendrier',
    });
  }

  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,
  };
}

// ═══════════════════════════════════════════════════════════════════════════
//  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: e.title || 'Événement',
    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).
function feBuildSummary(catsById, cal, indices, fx, pivot, riskEvent, mkts, calOk = true) {
  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)} vs EUR 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)} vs EUR)`);
  }

  // Risque événementiel dominant + pivot.
  if (riskEvent === 'eleve' && pivot) {
    parts.push(`dans un contexte de risque événementiel élevé — ${pivot.title} ${pivot.whenFR}`);
  } else if (riskEvent === 'moyen' && pivot) {
    parts.push(`risque événementiel modéré — ${pivot.title} ${pivot.whenFR}`);
  } else if (pivot) {
    parts.push(`prochain temps fort : ${pivot.title} ${pivot.whenFR}`);
  } else if (calOk === false) {
    parts.push('calendrier économique indisponible sur la période (source en panne)');
  } else {
    parts.push('calendrier économique allégé sur la période');
  }

  // 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.', events:[{title,country,impact,timeFR}]}] }.
// Événements FUTURS uniquement (High/Medium), groupés par JOUR LOCAL (Paris),
// semaine glissante de 7 jours max, ≤8 événements par jour (les plus tôt d'abord).
function feBuildWeekAhead(cal) {
  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 => ({
    date: key,
    label: feDayLabelFR(key),
    events: (byDay.get(key) || [])
      .sort((a, b) => a.ts - b.ts)
      .slice(0, 8)
      .map(e => ({
        title: e.title || 'Événement',
        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 ${bp > 0 ? '+' : ''}${bp} pb à ${feFixed(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 : ${sIndices.join(', ')}.`);
  if (sTaux.length) phrases.push(`Côté dollar et taux : ${sTaux.join(', ')}.`);
  if (sAutres.length) phrases.push(`Or et changes : ${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 : ${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;
  }
}

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 || !parsed.savedAt) 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 (Date.now() - parsed.savedAt > 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) {}
}
function fePurgeCache(scope) {
  try {
    if (scope) localStorage.removeItem(FE_CACHE_PREFIX + scope);
    else {
      localStorage.removeItem(FE_CACHE_PREFIX + 'daily');
      localStorage.removeItem(FE_CACHE_PREFIX + 'weekly');
    }
  } 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 (fire-and-forget, échec TOUJOURS silencieux)
// ═══════════════════════════════════════════════════════════════════════════

// 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.
// 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) {
  try {
    const sb = (typeof window !== 'undefined') ? window.sb : null;
    if (!sb || !sb.auth || typeof sb.from !== 'function') return;
    const res = await sb.auth.getUser();
    const user = res && res.data && res.data.user;
    if (!user) return;                                   // non connecté → pas de snapshot

    const ins = await sb.from('fondamental_snapshots').insert({
      scope: ctx.scope,
      generated_at: new Date(ctx.generatedAt).toISOString(),
      ctx,
    });
    if (ins && ins.error) {
      console.warn('Tempo fondamental — snapshot non sauvegardé :', ins.error.message || ins.error);
      return;
    }

    // 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 — snapshot non sauvegardé :', (e && e.message) || e); } catch (e2) {}
  }
}

// ═══════════════════════════════════════════════════════════════════════════
//  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 }
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 () {};

  // 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]);
  const indicesP = feFetchIndices();
  // 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);
  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');
  if (!indices || indices.degraded) degradedSources.push('indices');
  if (scope === 'daily' && !intradayData) degradedSources.push('cotations intraday');

  // ── É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 (socle v1) ──
  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.
  if (calRaw === null) degradedSources.push('calendrier');
  const cal = calRaw || [];

  // ── Étape 5 : news presse (socle v1) + actualités étendues (v2), fusionnées ──
  onStep(4, FE_STEPS[4]);
  let newsV1 = [];
  try {
    if (typeof window !== 'undefined' && typeof window.loadNews === 'function') {
      newsV1 = await window.loadNews(false);
    }
  } catch (e) { newsV1 = []; }
  if (!Array.isArray(newsV1)) newsV1 = [];
  if (!newsV1.length) degradedSources.push('news');

  const news2 = await feFetchNews2();
  if (!news2) degradedSources.push('actualités étendues');

  // Fusion + déduplication par titre normalisé, cap 150 (le scoring reste borné).
  const news = [];
  const seenTitles = new Set();
  for (const n of newsV1.concat(news2 || [])) {
    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;
  }

  // ── É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.
  const sessionOpen = feUsCashOpen(Date.now());
  const dailyMeta = { intraday: 0 };
  const useIntraday = (scope === 'daily') && !!intradayData;
  for (const catId of categories) {
    const newsRes = feNewsFor(news, catId);
    let cat;
    if (useIntraday) {
      const dataD = {
        quotes: (intradayData && intradayData.quotes) || {},
        klines: (intradayData && intradayData.klines) || {},
        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 };
      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);
    }
    // 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 calOk = calRaw !== null; // false = panne réseau calendrier (≠ période calme)
  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).
  const riskEvent = calOk ? feGlobalRisk(safeCal) : '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);
  } else {
    summary = feBuildSummary(catsById, safeCal, indices, fx, pivotEvent, riskEvent, mkts, calOk);
    if (scope === 'daily') {
      summary += ' Cotations du jour indisponibles — biais fondé sur les données de fond, conviction limitée.';
    }
  }

  const ctx = {
    v: 2,
    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') {
    ctx.weekAhead = feBuildWeekAhead(safeCal);
    ctx.weekReview = feBuildWeekReview(mkts, gold, fx);
    summary += feWeekAheadSummary(ctx.weekAhead);
    ctx.summary = summary;
  }

  feWriteCache(scope, ctx);
  // Snapshot Supabase fire-and-forget (jamais bloquant, échec silencieux).
  try { feSaveSnapshot(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,
  // Helpers exposés pour l'UI (bouton Régénérer / affichage instantané) et tests éventuels.
  fondReadCtxCache: feReadCache,
  fondPurgeCtxCache: fePurgeCache,
  // 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,
});
