// detect.jsx — v2 confidence-graded pattern detection
//
// PHASE 2 EXIT STATE (2026-04-27):
//   Synthetic self-detect rate: 8/23 at geo>=0.85, 10/23 at geo>=0.70
//     (synthetic fixtures are mild archetypes, not edge-case stress tests)
//   Real-data geo distribution: 0.52–0.99, median 0.72
//     0.85+ achievable on strong geometry (NVDA 0.99, AMD 0.93)
//   Architecture verdict: geometric-mean aggregation is structurally correct
//
// PHASE 3 CPCV TODOs:
//   - Pattern disambiguation: CUP_HANDLE, HEAD_SHOULDERS, INV_HEAD_SHOULDERS —
//     slope-reversal and U-shape detectors overlap in runDetectors winner-take-all.
//     Fix via per-pattern reliability weighting (PBO/DSR) or cross-pattern gates.
//   - Multiplier table calibration against real-market base rates
//   - Synthetic fixture revision for flag/pennant family (self-detect 0.65-0.78;
//     detector tuned for stronger real-market setups with 8%+ pole, tighter
//     consolidation — fixtures are archetype tests, not the calibration target)

(function () {
  const WINDOW = 3;
  const MIN_CONFIDENCE = 0.50;

  const clamp01 = (v) => Math.max(0, Math.min(1, v));
  const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v));
  const sigmoid = (x) => 1 / (1 + Math.exp(-x));
  const sim = (a, b) => 1 - Math.min(1, Math.abs(a - b) / Math.max(a, b, 1e-10));
  const geoMean = (...vals) => Math.pow(vals.reduce((a, b) => a * b, 1), 1 / vals.length);

  function ema(series, period) {
    const alpha = 2 / (period + 1);
    const result = [series[0]];
    for (let i = 1; i < series.length; i++)
      result.push(alpha * series[i] + (1 - alpha) * result[i - 1]);
    return result;
  }

  function findSwings(series, win = WINDOW) {
    const peaks = [], troughs = [];
    for (let i = win; i < series.length - win; i++) {
      let isPeak = true, isTrough = true;
      for (let j = 1; j <= win; j++) {
        if (series[i] <= series[i - j] || series[i] <= series[i + j]) isPeak = false;
        if (series[i] >= series[i - j] || series[i] >= series[i + j]) isTrough = false;
      }
      if (isPeak) peaks.push(i);
      if (isTrough) troughs.push(i);
    }
    return { peaks, troughs };
  }

  function linSlope(indices, values) {
    const n = indices.length;
    if (n < 2) return 0;
    const mx = indices.reduce((a, b) => a + b, 0) / n;
    const my = values.reduce((a, b) => a + b, 0) / n;
    let num = 0, den = 0;
    for (let i = 0; i < n; i++) {
      num += (indices[i] - mx) * (values[i] - my);
      den += Math.pow(indices[i] - mx, 2);
    }
    return den === 0 ? 0 : num / den;
  }

  function countTouches(series, level, tolerance) {
    let count = 0;
    for (let i = 0; i < series.length; i++) {
      if (Math.abs(series[i] - level) / level <= tolerance) count++;
    }
    return count;
  }

  // ── per-pattern geometric detectors ─────────────────────────

  function detectDoubleTop(s, peaks, troughs) {
    if (peaks.length < 2) return null;
    if (peaks.length >= 3 && troughs.length >= 3) return null;
    const last = s.length - 1;
    const [p1, p2] = peaks.slice(-2);
    if (p2 - p1 < 8) return null;
    if (s[p2] < s[p1] * 0.985) return null;
    const peakSym = clamp01(sim(s[p1], s[p2]));
    if (peakSym < 0.95) return null;
    const spacing = clamp01(Math.exp(-Math.pow(((p2 - p1) - 20) / 10, 2)));
    const between = Math.min(...s.slice(p1, p2 + 1));
    const avgPeak = (s[p1] + s[p2]) / 2;
    const depthPct = ((avgPeak - between) / avgPeak) * 100;
    if (depthPct < 2.5) return null;
    const valleyDepth = clamp01(sigmoid((depthPct - 2) / 2));
    const neckline = between;
    const broke = s[last] < neckline;
    const patHeight = avgPeak - neckline || 1;
    const breakScore = broke
      ? clamp01((neckline - s[last]) / patHeight)
      : s[last] < neckline * 1.01 ? 0.4 : 0.2;
    const geo = geoMean(peakSym, spacing, valleyDepth, breakScore);
    return {
      key: 'DOUBLE_TOP', geo, breakIdx: broke ? last : null,
      evidence: [
        `Peak symmetry: ${(peakSym * 100).toFixed(1)}% (${s[p1].toFixed(2)} / ${s[p2].toFixed(2)})`,
        `Spacing: ${p2 - p1} bars`,
        `Valley depth: ${depthPct.toFixed(1)}%`,
        broke ? `Neckline broken at ${neckline.toFixed(2)}` : 'Awaiting neckline break',
      ],
    };
  }

  function detectDoubleBottom(s, peaks, troughs) {
    if (troughs.length < 2) return null;
    if (peaks.length >= 3 && troughs.length >= 3) return null;
    const last = s.length - 1;
    const [b1, b2] = troughs.slice(-2);
    if (b2 - b1 < 8) return null;
    if (s[b2] > s[b1] * 1.015) return null;
    const troughSym = clamp01(sim(s[b1], s[b2]));
    if (troughSym < 0.95) return null;
    const spacing = clamp01(Math.exp(-Math.pow(((b2 - b1) - 20) / 10, 2)));
    const between = Math.max(...s.slice(b1, b2 + 1));
    const avgTrough = (s[b1] + s[b2]) / 2;
    const depthPct = ((between - avgTrough) / avgTrough) * 100;
    if (depthPct < 2.5) return null;
    const valleyDepth = clamp01(sigmoid((depthPct - 2) / 2));
    const resistance = between;
    const broke = s[last] > resistance;
    const patHeight = resistance - avgTrough || 1;
    const breakScore = broke
      ? clamp01((s[last] - resistance) / patHeight)
      : s[last] > resistance * 0.99 ? 0.4 : 0.2;
    const geo = geoMean(troughSym, spacing, valleyDepth, breakScore);
    return {
      key: 'DOUBLE_BOTTOM', geo, breakIdx: broke ? last : null,
      evidence: [
        `Trough symmetry: ${(troughSym * 100).toFixed(1)}% (${s[b1].toFixed(2)} / ${s[b2].toFixed(2)})`,
        `Spacing: ${b2 - b1} bars`,
        `Valley depth: ${depthPct.toFixed(1)}%`,
        broke ? `Resistance broken at ${resistance.toFixed(2)}` : 'Awaiting resistance break',
      ],
    };
  }

  function detectHeadShoulders(s, peaks, troughs, inverse = false) {
    const list = inverse ? troughs : peaks;
    const oppositeList = inverse ? peaks : troughs;
    if (list.length < 3) return null;
    const [a, b, c] = list.slice(-3);
    const sa = s[a], sb = s[b], sc = s[c];
    const shoulderSym = clamp01(sim(sa, sc));
    if (shoulderSym < 0.97) return null;
    const hasBetweenAB = oppositeList.some(idx => idx > a && idx < b);
    const hasBetweenBC = oppositeList.some(idx => idx > b && idx < c);
    if (!hasBetweenAB || !hasBetweenBC) return null;
    const avgShoulder = (sa + sc) / 2;
    const middleDiffPct = Math.abs(sb - avgShoulder) / avgShoulder * 100;
    if (middleDiffPct < 0.5) return null;
    const headProm = clamp01(sigmoid((middleDiffPct - 1.0) / 1.5));
    const gapAB = b - a, gapBC = c - b;
    const spacingReg = clamp01(1 - Math.abs(gapAB - gapBC) / Math.max(gapAB, gapBC, 1));
    const last = s.length - 1;
    const neckline = avgShoulder;
    const broke = inverse ? s[last] > neckline : s[last] < neckline;
    const patHeight = Math.abs(sb - neckline) || (avgShoulder * 0.02);
    const breakScore = broke
      ? clamp01(Math.abs(s[last] - neckline) / patHeight)
      : 0.3;
    const geo = geoMean(shoulderSym, headProm, spacingReg, breakScore);
    const key = inverse ? 'INV_HEAD_SHOULDERS' : 'HEAD_SHOULDERS';
    return {
      key, geo, breakIdx: broke ? last : null,
      evidence: [
        `Shoulders: ${sa.toFixed(2)} / ${sc.toFixed(2)} (sym ${(shoulderSym * 100).toFixed(1)}%)`,
        `Head: ${sb.toFixed(2)} (prominence ${middleDiffPct.toFixed(1)}%)`,
        `Spacing: ${gapAB}/${gapBC} bars (regularity ${(spacingReg * 100).toFixed(0)}%)`,
        broke ? 'Neckline broken' : 'Awaiting neckline break',
      ],
    };
  }

  function detectTripleTop(s, peaks) {
    if (peaks.length < 3) return null;
    const last = s.length - 1;
    const [p1, p2, p3] = peaks.slice(-3);
    if (p2 - p1 < 8 || p3 - p2 < 8) return null;
    const pairSym = clamp01(Math.min(sim(s[p1], s[p2]), sim(s[p2], s[p3]), sim(s[p1], s[p3])));
    if (pairSym < 0.95) return null;
    const avgOuter = (s[p1] + s[p3]) / 2;
    const middleDiffPct = Math.abs(s[p2] - avgOuter) / avgOuter * 100;
    if (middleDiffPct > 1.5) return null;
    const avgPeak = (s[p1] + s[p2] + s[p3]) / 3;
    const support = Math.min(...s.slice(p1, p3 + 1));
    const depthPct = (avgPeak - support) / avgPeak * 100;
    if (depthPct < 2.0) return null;
    const gaps = [p2 - p1, p3 - p2];
    const gapMean = (gaps[0] + gaps[1]) / 2;
    const gapStd = Math.sqrt(gaps.reduce((a, g) => a + Math.pow(g - gapMean, 2), 0) / gaps.length);
    const spacingReg = clamp01(1 - gapStd / (gapMean || 1));
    const broke = s[last] < support;
    const patHeight = avgPeak - support || 1;
    const breakScore = broke ? clamp01((support - s[last]) / patHeight) : 0.25;
    const touches = countTouches(s.slice(p1, last + 1), avgPeak, 0.015);
    const countBonus = clamp01(0.8 + 0.05 * Math.min(4, touches));
    const geo = geoMean(pairSym, spacingReg, breakScore, countBonus);
    return {
      key: 'TRIPLE_TOP', geo, breakIdx: broke ? last : null,
      evidence: [
        `Three peaks: ${s[p1].toFixed(2)} / ${s[p2].toFixed(2)} / ${s[p3].toFixed(2)} (sym ${(pairSym * 100).toFixed(0)}%)`,
        `Depth: ${depthPct.toFixed(1)}%, ${touches} touches near resistance`,
        broke ? `Support broken at ${support.toFixed(2)}` : 'Awaiting support break',
      ],
    };
  }

  function detectTripleBottom(s, troughs) {
    if (troughs.length < 3) return null;
    const last = s.length - 1;
    const [t1, t2, t3] = troughs.slice(-3);
    if (t2 - t1 < 8 || t3 - t2 < 8) return null;
    const pairSym = clamp01(Math.min(sim(s[t1], s[t2]), sim(s[t2], s[t3]), sim(s[t1], s[t3])));
    if (pairSym < 0.95) return null;
    const avgOuter = (s[t1] + s[t3]) / 2;
    const middleDiffPct = Math.abs(s[t2] - avgOuter) / avgOuter * 100;
    if (middleDiffPct > 1.5) return null;
    const avgTrough = (s[t1] + s[t2] + s[t3]) / 3;
    const resistance = Math.max(...s.slice(t1, t3 + 1));
    const depthPct = (resistance - avgTrough) / avgTrough * 100;
    if (depthPct < 2.0) return null;
    const gaps = [t2 - t1, t3 - t2];
    const gapMean = (gaps[0] + gaps[1]) / 2;
    const gapStd = Math.sqrt(gaps.reduce((a, g) => a + Math.pow(g - gapMean, 2), 0) / gaps.length);
    const spacingReg = clamp01(1 - gapStd / (gapMean || 1));
    const broke = s[last] > resistance;
    const patHeight = resistance - avgTrough || 1;
    const breakScore = broke ? clamp01((s[last] - resistance) / patHeight) : 0.25;
    const touches = countTouches(s.slice(t1, last + 1), avgTrough, 0.015);
    const countBonus = clamp01(0.8 + 0.05 * Math.min(4, touches));
    const geo = geoMean(pairSym, spacingReg, breakScore, countBonus);
    return {
      key: 'TRIPLE_BOTTOM', geo, breakIdx: broke ? last : null,
      evidence: [
        `Three troughs: ${s[t1].toFixed(2)} / ${s[t2].toFixed(2)} / ${s[t3].toFixed(2)} (sym ${(pairSym * 100).toFixed(0)}%)`,
        `Depth: ${depthPct.toFixed(1)}%, ${touches} touches near support`,
        broke ? `Resistance broken at ${resistance.toFixed(2)}` : 'Awaiting resistance break',
      ],
    };
  }

  function detectCupHandle(s) {
    if (s.length < 45) return null;
    const last = s.length - 1;
    const cupEnd = Math.floor(s.length * 0.75);
    const leftRim = s[0];
    const rightRim = s[cupEnd];
    const cupSlice = s.slice(0, cupEnd + 1);
    const cupMin = Math.min(...cupSlice);
    const cupMinIdx = cupSlice.indexOf(cupMin);
    if (cupMinIdx < 3 || cupMinIdx > cupEnd - 3) return null;
    const cupSym = clamp01(sim(leftRim, rightRim));
    const avgRim = (leftRim + rightRim) / 2;
    const depthPct = ((avgRim - cupMin) / avgRim) * 100;
    if (depthPct < 2) return null;
    const cupDepthScore = clamp01(1 - Math.abs(depthPct - 6) / 10);
    const handleSlice = s.slice(cupEnd);
    const handleMin = Math.min(...handleSlice);
    const handleDepth = rightRim - handleMin;
    const cupDepth = avgRim - cupMin;
    const handleRatio = cupDepth > 0 ? handleDepth / cupDepth : 0;
    const handleScore = handleRatio > 0 ? clamp01(1 - Math.abs(handleRatio - 0.30) / 0.5) : 0.2;
    const rim = Math.max(leftRim, rightRim);
    const above = s[last] > rim * 1.01;
    const atRim = Math.abs(s[last] - rim) / rim < 0.02;
    const breakScore = above ? 1.0 : atRim ? 0.6 : clamp01(0.6 - ((rim - s[last]) / rim) * 10);
    const geo = geoMean(cupSym, cupDepthScore, handleScore, breakScore);
    return {
      key: 'CUP_HANDLE', geo, breakIdx: above ? last : null,
      evidence: [
        `Cup symmetry: ${(cupSym * 100).toFixed(0)}% (rims ${leftRim.toFixed(2)} / ${rightRim.toFixed(2)})`,
        `Cup depth: ${depthPct.toFixed(1)}%`,
        `Handle retrace: ${(handleRatio * 100).toFixed(0)}% of cup`,
        above ? 'Breakout above rim' : atRim ? 'At rim level' : 'Below rim',
      ],
    };
  }

  function detectConvergence(s, v) {
    if (s.length < 20) return null;
    const sliceLen = Math.min(s.length, 60);
    const slice = s.slice(-sliceLen);
    const volSlice = v ? v.slice(-sliceLen) : null;
    const sw = findSwings(slice, 2);
    if (sw.peaks.length + sw.troughs.length < 3) return null;
    if (sw.peaks.length < 1 || sw.troughs.length < 1) return null;

    const peakSlope = sw.peaks.length >= 2
      ? linSlope(sw.peaks, sw.peaks.map(i => slice[i])) : null;
    const troughSlope = sw.troughs.length >= 2
      ? linSlope(sw.troughs, sw.troughs.map(i => slice[i])) : null;

    const preBreakLen = Math.floor(sliceLen * 0.82);
    const preBreak = slice.slice(0, preBreakLen);
    const trendSlope = linSlope(
      Array.from({ length: preBreak.length }, (_, i) => i),
      preBreak
    );
    const trendPct = trendSlope * preBreak.length / (preBreak[0] || 1) * 100;

    let key = null, convergence;

    if (peakSlope != null && troughSlope != null) {
      const flatThresh = 0.05;
      const flatPeak = Math.abs(peakSlope) < flatThresh;
      const flatTrough = Math.abs(troughSlope) < flatThresh;

      if (flatPeak && troughSlope > 0.03) {
        key = 'ASC_TRIANGLE';
        convergence = clamp01(Math.min(1 - Math.abs(peakSlope) / 0.1, troughSlope / 0.05));
      } else if (flatTrough && peakSlope < -0.03) {
        key = 'DESC_TRIANGLE';
        convergence = clamp01(Math.min(1 - Math.abs(troughSlope) / 0.1, -peakSlope / 0.05));
      } else if (peakSlope < -0.02 && troughSlope > 0.02) {
        if (trendPct < -1.5) key = 'FALLING_WEDGE';
        else if (trendPct > 1.5) key = 'RISING_WEDGE';
        else key = 'SYM_TRIANGLE';
        convergence = clamp01(Math.min(-peakSlope / 0.05, troughSlope / 0.05));
      }
    }

    if (!key && sw.peaks.length >= 2 && sw.troughs.length >= 1) {
      const halfLen = Math.floor(slice.length / 2);
      const firstHalf = slice.slice(0, halfLen);
      const secondHalf = slice.slice(halfLen);
      const firstRange = Math.max(...firstHalf) - Math.min(...firstHalf);
      const secondRange = Math.max(...secondHalf) - Math.min(...secondHalf);
      if (firstRange > 0 && secondRange < firstRange * 0.7) {
        convergence = clamp01(1 - secondRange / firstRange);
        if (trendPct < -1.5) key = 'FALLING_WEDGE';
        else if (trendPct > 1.5) key = 'RISING_WEDGE';
        else key = 'SYM_TRIANGLE';
      }
    }

    if (!key) return null;

    const touchesPeak = sw.peaks.length >= 1 ? countTouches(slice, slice[sw.peaks[0]], 0.015) : 0;
    const touchesTrough = sw.troughs.length >= 1 ? countTouches(slice, slice[sw.troughs[0]], 0.015) : 0;
    const totalTouches = touchesPeak + touchesTrough;
    const touchScore = clamp01((totalTouches - 4) / 4);

    let volContraction = 0.5;
    if (volSlice && volSlice.length >= 10) {
      const vSlope = linSlope(
        Array.from({ length: volSlice.length }, (_, i) => i),
        volSlice
      );
      volContraction = clamp01(sigmoid(-vSlope * 100));
    }

    const duration = (sw.peaks.length >= 1 && sw.troughs.length >= 1)
      ? Math.abs(sw.peaks[sw.peaks.length - 1] - sw.troughs[0])
      : sliceLen;
    const durationScore = clamp01(Math.exp(-Math.pow(((duration || sliceLen) - 30) / 15, 2)));

    const geo = geoMean(convergence, touchScore, volContraction, durationScore);
    const last = slice.length - 1;
    let broke = false;
    if (key === 'ASC_TRIANGLE' && sw.peaks.length >= 1) broke = slice[last] > slice[sw.peaks[0]] * 1.005;
    else if (key === 'DESC_TRIANGLE' && sw.troughs.length >= 1) broke = slice[last] < slice[sw.troughs[0]] * 0.995;
    else if (key === 'FALLING_WEDGE') broke = slice[last] > (slice[sw.peaks?.[sw.peaks.length - 1]] || slice[0]);
    else if (key === 'RISING_WEDGE') broke = slice[last] < (slice[sw.troughs?.[sw.troughs.length - 1]] || slice[0]);
    else broke = sw.peaks.length >= 1 && sw.troughs.length >= 1 &&
      Math.abs(slice[last] - (slice[sw.peaks[sw.peaks.length - 1]] + slice[sw.troughs[sw.troughs.length - 1]]) / 2)
        / slice[last] > 0.02;

    return {
      key, geo, breakIdx: broke ? s.length - 1 : null,
      evidence: [
        `Convergence: ${(convergence * 100).toFixed(0)}%`,
        `${totalTouches} trendline touches`,
        `Volume contraction: ${(volContraction * 100).toFixed(0)}%`,
        `Trend: ${trendPct > 0 ? '+' : ''}${trendPct.toFixed(1)}%`,
        broke ? 'Breakout detected' : 'Consolidating',
      ],
    };
  }

  function detectFlag(s, v) {
    if (s.length < 15) return null;
    const poleFrac = 0.4;
    const poleEnd = Math.floor(s.length * poleFrac);
    const flagEnd = Math.floor(s.length * 0.85);
    if (poleEnd < 3 || flagEnd - poleEnd < 5) return null;

    const poleChange = (s[poleEnd] - s[0]) / s[0] * 100;
    const isBull = poleChange > 0;
    const poleStrength = clamp01(Math.abs(poleChange) / 8);
    if (poleStrength < 0.2) return null;

    const flagSlice = s.slice(poleEnd, flagEnd + 1);
    const flagHigh = Math.max(...flagSlice);
    const flagLow = Math.min(...flagSlice);
    const flagRange = flagHigh - flagLow;
    const poleHeight = Math.abs(s[poleEnd] - s[0]);
    const tightness = clamp01(1 - flagRange / (poleHeight || 1));

    const flagSlopeVal = linSlope(
      Array.from({ length: flagSlice.length }, (_, i) => i),
      flagSlice
    );
    const expectedSign = isBull ? -1 : 1;
    const slopeScore = clamp01(0.5 + expectedSign * flagSlopeVal * 20);

    const flagDur = flagEnd - poleEnd;
    const durScore = clamp01(Math.exp(-Math.pow((flagDur - 27) / 9, 2)));

    const geo = geoMean(poleStrength, tightness, slopeScore, durScore);
    const key = isBull ? 'BULL_FLAG' : 'BEAR_FLAG';

    const last = s.length - 1;
    const broke = isBull ? s[last] > flagHigh * 1.005 : s[last] < flagLow * 0.995;
    return {
      key, geo, breakIdx: broke ? last : null,
      evidence: [
        `Pole: ${poleChange > 0 ? '+' : ''}${poleChange.toFixed(1)}% (strength ${(poleStrength * 100).toFixed(0)}%)`,
        `Flag tightness: ${(tightness * 100).toFixed(0)}%`,
        `Flag slope: ${flagSlopeVal > 0 ? 'up' : 'down'} (score ${(slopeScore * 100).toFixed(0)}%)`,
        broke ? 'Breakout from flag' : 'Within flag',
      ],
    };
  }

  function detectPennant(s, v) {
    if (s.length < 18) return null;
    const poleFrac = 0.4;
    const poleEnd = Math.floor(s.length * poleFrac);
    const flagEnd = Math.floor(s.length * 0.85);
    if (poleEnd < 3 || flagEnd - poleEnd < 8) return null;

    const poleChange = (s[poleEnd] - s[0]) / s[0] * 100;
    const isBull = poleChange > 0;
    const poleStrength = clamp01(Math.abs(poleChange) / 8);
    if (poleStrength < 0.2) return null;

    const penSlice = s.slice(poleEnd, flagEnd + 1);
    if (penSlice.length < 6) return null;
    const halfLen = Math.floor(penSlice.length / 2);
    const firstHalf = penSlice.slice(0, halfLen);
    const secondHalf = penSlice.slice(halfLen);
    const firstRange = Math.max(...firstHalf) - Math.min(...firstHalf);
    const secondRange = Math.max(...secondHalf) - Math.min(...secondHalf);
    const rangeRatio = firstRange > 0 ? secondRange / firstRange : 1;
    const convergenceScore = clamp01(1 - rangeRatio);
    if (convergenceScore < 0.1) return null;

    const durScore = clamp01(Math.exp(-Math.pow(((flagEnd - poleEnd) - 27) / 9, 2)));
    const geo = geoMean(poleStrength, convergenceScore, durScore);

    const key = isBull ? 'BULL_PENNANT' : 'BEAR_PENNANT';
    const last = s.length - 1;
    const penHigh = Math.max(...penSlice);
    const penLow = Math.min(...penSlice);
    const broke = isBull ? s[last] > penHigh * 1.005 : s[last] < penLow * 0.995;
    return {
      key, geo, breakIdx: broke ? last : null,
      evidence: [
        `Pole: ${poleChange > 0 ? '+' : ''}${poleChange.toFixed(1)}%`,
        `Pennant convergence: ${(convergenceScore * 100).toFixed(0)}%`,
        broke ? 'Breakout from pennant' : 'Consolidating in pennant',
      ],
    };
  }

  function detectRectangle(s, v) {
    if (s.length < 25) return null;
    const startIdx = Math.floor(s.length * 0.15);
    const endIdx = Math.floor(s.length * 0.85);
    const rangeSlice = s.slice(startIdx, endIdx + 1);
    if (rangeSlice.length < 10) return null;

    const top = Math.max(...rangeSlice);
    const bot = Math.min(...rangeSlice);
    const rangeHeight = top - bot;
    const mid = (top + bot) / 2;
    if (rangeHeight / mid < 0.03) return null;
    if (rangeHeight / mid > 0.08) return null;

    const mean = rangeSlice.reduce((a, b) => a + b, 0) / rangeSlice.length;
    const variance = rangeSlice.reduce((a, val) => a + Math.pow(val - mean, 2), 0) / rangeSlice.length;
    const cv = Math.sqrt(variance) / (mean || 1);
    const rangeQuality = clamp01(1 - cv * 15);
    if (rangeQuality < 0.55) return null;

    const slopeVal = linSlope(
      Array.from({ length: rangeSlice.length }, (_, i) => i),
      rangeSlice
    );
    const slopePct = Math.abs(slopeVal) / (mean || 1) * rangeSlice.length * 100;
    if (slopePct > 2.0) return null;
    const flatScore = clamp01(1 - slopePct / 2.0);

    const crossings = rangeSlice.reduce((count, val, i) => {
      if (i === 0) return 0;
      if ((rangeSlice[i - 1] < mean && val >= mean) || (rangeSlice[i - 1] >= mean && val < mean)) return count + 1;
      return count;
    }, 0);
    if (crossings < 5) return null;

    const touchesTop = countTouches(rangeSlice, top, 0.008);
    const touchesBot = countTouches(rangeSlice, bot, 0.008);
    if (touchesTop < 2 || touchesBot < 2) return null;
    const touchScore = clamp01((touchesTop + touchesBot - 4) / 6);

    const last = s.length - 1;
    const breakUp = s[last] > top * 1.005;
    const breakDown = s[last] < bot * 0.995;

    const trendBefore = s[startIdx] - s[0];
    const isTop = trendBefore > 0 || breakUp;

    const breakMag = breakUp ? (s[last] - top) / rangeHeight
      : breakDown ? (bot - s[last]) / rangeHeight : 0;
    const breakScore = clamp01(breakMag > 0 ? breakMag : 0.2);

    const geo = geoMean(rangeQuality, flatScore, touchScore, breakScore);
    const key = isTop ? 'RECT_TOP' : 'RECT_BOTTOM';
    return {
      key, geo, breakIdx: (breakUp || breakDown) ? last : null,
      evidence: [
        `Range: ${bot.toFixed(2)} - ${top.toFixed(2)} (quality ${(rangeQuality * 100).toFixed(0)}%)`,
        `${touchesTop + touchesBot} S/R touches, ${crossings} midline crossings`,
        breakUp ? 'Breakout above range' : breakDown ? 'Breakdown below range' : 'Within range',
      ],
    };
  }

  function detectVolumeBreakout(s, v) {
    if (!v || v.length < 31) return null;
    const last = v.length - 1;
    const avg30 = v.slice(last - 30, last).reduce((a, b) => a + b, 0) / 30;
    const ratio = v[last] / (avg30 || 1);
    if (ratio < 1.3) return null;
    const prevHigh = Math.max(...s.slice(-31, -1));
    if (s[s.length - 1] <= prevHigh) return null;
    const volScore = clamp01(sigmoid((ratio - 2.0) / 0.8));
    const breakDist = (s[s.length - 1] - prevHigh) / (prevHigh * 0.01 || 1);
    const breakMag = clamp01(breakDist);
    const geo = geoMean(volScore, breakMag, 0.5);
    return {
      key: 'VOL_BREAKOUT', geo, breakIdx: s.length - 1,
      evidence: [
        `Volume: ${ratio.toFixed(2)}x 30d avg`,
        `Closed above 30d high (${prevHigh.toFixed(2)})`,
      ],
    };
  }

  function detectGoldenDeath(s) {
    if (s.length < 50) return null;
    const smoothed = ema(s, 3);
    const sw = findSwings(smoothed);
    if (sw.peaks.length + sw.troughs.length > 2) return null;

    const last = s.length - 1;
    const midPoint = Math.floor(s.length * 0.45);
    const firstHalf = s.slice(0, midPoint);
    const secondHalf = s.slice(midPoint);

    const firstSlope = linSlope(Array.from({ length: firstHalf.length }, (_, i) => i), firstHalf);
    const secondSlope = linSlope(Array.from({ length: secondHalf.length }, (_, i) => i), secondHalf);
    const firstPct = firstSlope * firstHalf.length / (firstHalf[0] || 1) * 100;
    const secondPct = secondSlope * secondHalf.length / (secondHalf[0] || 1) * 100;

    let key = null;
    if (firstPct < -1 && secondPct > 2) key = 'GOLDEN_CROSS';
    else if (firstPct > 1 && secondPct < -2) key = 'DEATH_CROSS';
    if (!key) return null;

    const trendStrength = clamp01(Math.abs(secondPct) / 10);
    const reversal = clamp01(Math.abs(firstPct - secondPct) / 10);
    const priceAligned = key === 'GOLDEN_CROSS'
      ? s[last] > s[0] ? 1.0 : 0.3
      : s[last] < s[0] ? 1.0 : 0.3;

    const geo = geoMean(trendStrength, reversal, priceAligned);
    return {
      key, geo, breakIdx: null,
      evidence: [
        `${key === 'GOLDEN_CROSS' ? 'Golden' : 'Death'} cross pattern`,
        `First half: ${firstPct > 0 ? '+' : ''}${firstPct.toFixed(1)}%, second half: ${secondPct > 0 ? '+' : ''}${secondPct.toFixed(1)}%`,
        `Price ${priceAligned >= 0.8 ? 'aligned' : 'misaligned'} with trend`,
      ],
    };
  }

  function detectSupportResistance(s, v) {
    const last = s.length - 1;
    if (last < 30) return null;
    const windowSlice = s.slice(last - 30, last);
    const high = Math.max(...windowSlice);
    const low = Math.min(...windowSlice);

    let key = null, level, breakDist;
    if (s[last] > high * 1.003) {
      key = 'RESISTANCE_BREAK';
      level = high;
      breakDist = (s[last] - high) / (high * 0.01 || 1);
    } else if (s[last] < low * 0.997) {
      key = 'SUPPORT_BREAK';
      level = low;
      breakDist = (low - s[last]) / (low * 0.01 || 1);
    }
    if (!key) return null;

    const touches = countTouches(windowSlice, level, 0.01);
    const levelStr = clamp01((touches - 1) / 4);
    const breakMag = clamp01(breakDist);

    let volConf = 0.5;
    if (v && v.length > 30) {
      const avg30 = v.slice(-31, -1).reduce((a, b) => a + b, 0) / 30;
      const volRatio = v[v.length - 1] / (avg30 || 1);
      volConf = clamp01(sigmoid((volRatio - 1.5) / 0.5));
    }

    const geo = geoMean(levelStr, breakMag, volConf);
    return {
      key, geo, breakIdx: last,
      evidence: [
        `${key === 'RESISTANCE_BREAK' ? 'Resistance' : 'Support'} at ${level.toFixed(2)} (${touches} touches)`,
        `Break magnitude: ${(breakDist).toFixed(1)}%`,
      ],
    };
  }

  // ── indicator multiplier ────────────────────────────────────
  function computeIndicatorMult(key, s, v, peaks, troughs, ohlc) {
    if (!window.indicators) return 1.0;
    const { computeRSI, computeADX, computeATR, lastValue, valueAt } = window.indicators;
    let mult = 1.0;
    const rsiArr = computeRSI(s);
    const high = ohlc?.high, low = ohlc?.low;
    const hasOHLC = high && low && high.length === s.length;
    const adxArr = hasOHLC ? computeADX(high, low, s) : null;
    const adxVal = lastValue(adxArr)?.adx ?? null;

    const REVERSAL = ['DOUBLE_TOP', 'DOUBLE_BOTTOM', 'HEAD_SHOULDERS', 'INV_HEAD_SHOULDERS', 'TRIPLE_TOP', 'TRIPLE_BOTTOM'];
    const TRIANGLES = ['ASC_TRIANGLE', 'DESC_TRIANGLE', 'SYM_TRIANGLE', 'FALLING_WEDGE', 'RISING_WEDGE'];
    const FLAGS = ['BULL_FLAG', 'BEAR_FLAG', 'BULL_PENNANT', 'BEAR_PENNANT'];
    const RECTS = ['RECT_TOP', 'RECT_BOTTOM'];
    const MA_CROSS = ['GOLDEN_CROSS', 'DEATH_CROSS'];
    const BREAKOUTS = ['VOL_BREAKOUT', 'RESISTANCE_BREAK', 'SUPPORT_BREAK'];

    if (REVERSAL.includes(key)) {
      if (rsiArr && rsiArr.length > 10) {
        const swingList = ['DOUBLE_BOTTOM', 'TRIPLE_BOTTOM', 'INV_HEAD_SHOULDERS'].includes(key) ? troughs : peaks;
        if (swingList.length >= 2) {
          const [i1, i2] = swingList.slice(-2);
          const rsi1 = valueAt(rsiArr, i1, s.length);
          const rsi2 = valueAt(rsiArr, i2, s.length);
          if (rsi1 != null && rsi2 != null) {
            const isBullish = ['DOUBLE_BOTTOM', 'TRIPLE_BOTTOM', 'INV_HEAD_SHOULDERS'].includes(key);
            const diverges = isBullish ? rsi2 > rsi1 : rsi2 < rsi1;
            const wrongDiv = isBullish ? rsi2 < rsi1 - 5 : rsi2 > rsi1 + 5;
            if (diverges) mult *= 1.12;
            else if (wrongDiv) mult *= 0.80;
          }
        }
      }
    } else if (TRIANGLES.includes(key)) {
      if (adxVal != null) {
        if (adxVal < 20) mult *= 1.12;
        else if (adxVal > 25) mult *= 0.85;
      }
    } else if (FLAGS.includes(key)) {
      if (rsiArr) {
        const rsiLast = lastValue(rsiArr);
        if (rsiLast != null) {
          const isBull = key === 'BULL_FLAG' || key === 'BULL_PENNANT';
          if ((isBull && rsiLast > 80) || (!isBull && rsiLast < 20)) mult *= 0.80;
          else if (rsiLast >= 30 && rsiLast <= 70) mult *= 1.05;
        }
      }
    } else if (RECTS.includes(key)) {
      if (adxVal != null) {
        if (adxVal < 20) mult *= 1.10;
        else if (adxVal > 30) mult *= 0.80;
      }
    } else if (MA_CROSS.includes(key)) {
      if (adxVal != null) {
        if (adxVal > 25) mult *= 1.15;
        else if (adxVal < 15) mult *= 0.75;
      }
    } else if (BREAKOUTS.includes(key)) {
      if (v && v.length >= 4) {
        const avg30 = v.slice(-31, -1).reduce((a, b) => a + b, 0) / Math.min(30, v.length - 1);
        const last3Avg = (v[v.length - 1] + v[v.length - 2] + v[v.length - 3]) / 3;
        if (last3Avg > avg30 * 1.3) mult *= 1.10;
      }
    } else if (key === 'CUP_HANDLE') {
      if (v && v.length > 10) {
        const cupEnd = Math.floor(v.length * 0.75);
        const midVol = v.slice(Math.floor(cupEnd * 0.3), Math.floor(cupEnd * 0.7)).reduce((a, b) => a + b, 0)
          / (Math.floor(cupEnd * 0.7) - Math.floor(cupEnd * 0.3));
        const lateVol = v.slice(cupEnd).reduce((a, b) => a + b, 0) / (v.length - cupEnd);
        if (lateVol > midVol) mult *= 1.10;
      }
    }

    return clamp(mult, 0.7, 1.15);
  }

  // ── multi-timeframe multiplier ──────────────────────────────
  function computeMultitimeframeMult(dailyKey, dailyDir, weekly) {
    if (!weekly || !weekly.series || weekly.series.length < 20) return 1.0;
    const weeklyResult = runDetectors(weekly.series, weekly.volume || null, null);
    if (!weeklyResult) return 1.0;
    const weeklyDir = window.PATTERNS[weeklyResult.key]?.dir;
    if (!weeklyDir) return 1.0;
    if (weeklyResult.key === dailyKey && weeklyDir === dailyDir) return 1.15;
    if (weeklyDir === dailyDir) return 1.10;
    if (weeklyDir !== dailyDir && weeklyDir !== 'WATCH') return 0.85;
    return 1.0;
  }

  // ── false-breakout filter ───────────────────────────────────
  const BREAKOUT_PATTERNS = new Set([
    'VOL_BREAKOUT', 'RESISTANCE_BREAK', 'SUPPORT_BREAK',
    'ASC_TRIANGLE', 'DESC_TRIANGLE', 'SYM_TRIANGLE',
    'RECT_TOP', 'RECT_BOTTOM',
  ]);

  function computeFalseBreakoutMult(key, breakIdx, s) {
    if (!BREAKOUT_PATTERNS.has(key)) return 1.0;
    if (breakIdx == null) return 1.0;
    const last = s.length - 1;
    const barsAfter = last - breakIdx;
    if (barsAfter <= 0) return 0.6;
    if (barsAfter <= 2) return 0.65;
    const breakLevel = s[breakIdx];
    const isUpBreak = ['VOL_BREAKOUT', 'RESISTANCE_BREAK', 'ASC_TRIANGLE', 'RECT_TOP'].includes(key);
    let hold = 0;
    const checkBars = Math.min(3, barsAfter);
    for (let i = 1; i <= checkBars; i++) {
      const idx = breakIdx + i;
      if (idx >= s.length) break;
      if (isUpBreak ? s[idx] >= breakLevel : s[idx] <= breakLevel) hold++;
    }
    if (hold >= 3) return 1.0;
    if (hold >= 2) return 0.85;
    return 0.5;
  }

  // ── run all geometric detectors ─────────────────────────────
  function runDetectors(s, v, ohlc) {
    const smoothed = ema(s, 3);
    const { peaks, troughs } = findSwings(smoothed);

    const candidates = [];
    const add = (result) => {
      if (result && result.geo >= 0.35) candidates.push(result);
    };

    add(detectHeadShoulders(s, peaks, troughs, false));
    add(detectHeadShoulders(s, peaks, troughs, true));
    add(detectDoubleTop(s, peaks, troughs));
    add(detectDoubleBottom(s, peaks, troughs));
    add(detectTripleTop(s, peaks));
    add(detectTripleBottom(s, troughs));
    if (s.length >= 45) add(detectCupHandle(s));
    add(detectConvergence(s, v));
    add(detectFlag(s, v));
    add(detectPennant(s, v));
    add(detectRectangle(s, v));
    add(detectVolumeBreakout(s, v));
    add(detectGoldenDeath(s));
    add(detectSupportResistance(s, v));

    if (candidates.length === 0) return null;
    candidates.sort((a, b) => b.geo - a.geo);
    return candidates[0];
  }

  // ── main entrypoint ─────────────────────────────────────────
  function detectPattern(closes, volumes, ohlc) {
    if (!closes || closes.length < 20) return null;

    const best = runDetectors(closes, volumes, ohlc);
    if (!best) return null;

    const smoothed = ema(closes, 3);
    const { peaks, troughs } = findSwings(smoothed);

    const indicatorMult = computeIndicatorMult(best.key, closes, volumes, peaks, troughs, ohlc);

    const dir = window.PATTERNS[best.key]?.dir || 'WATCH';
    const weekly = ohlc?.weekly || null;
    const mtfMult = clamp(computeMultitimeframeMult(best.key, dir, weekly), 0.85, 1.15);

    const regime = window.classifyRegime ? window.classifyRegime(closes, ohlc) : 'SIDEWAYS';
    const regimeMult = clamp(
      window.getRegimeMultiplier ? window.getRegimeMultiplier(best.key, regime) : 1.0,
      0.7, 1.15
    );

    const fbMult = clamp(computeFalseBreakoutMult(best.key, best.breakIdx, closes), 0.5, 1.0);

    const raw = best.geo * indicatorMult * mtfMult * regimeMult * fbMult;
    const confidence = clamp01(raw);

    if (confidence < MIN_CONFIDENCE) return null;

    const evidence = [
      ...best.evidence,
      `Indicator: x${indicatorMult.toFixed(2)}`,
      `Regime: ${regime} (x${regimeMult.toFixed(2)})`,
    ];
    if (mtfMult !== 1.0) evidence.push(`Multi-timeframe: x${mtfMult.toFixed(2)}`);
    if (BREAKOUT_PATTERNS.has(best.key)) evidence.push(`Breakout hold: x${fbMult.toFixed(2)}`);

    return {
      key: best.key,
      confidence: Math.round(confidence * 100),
      geometric_score: best.geo,
      indicator_multiplier: indicatorMult,
      multitimeframe_multiplier: mtfMult,
      regime_multiplier: regimeMult,
      falsebreakout_multiplier: fbMult,
      regime,
      evidence,
    };
  }

  window.detectPattern = detectPattern;
  window.findSwings = findSwings;
})();
