/**
 * flip-cap-validation.ts
 *
 * Validates the idea:
 *   Once a direction hits its stop cap for the UTC day, treat future same-side
 *   signals as an opposite-direction continuation signal for the rest of the day.
 *
 * The promising candidate from the first ad-hoc sweep was:
 *   flip only if flippedDir * 1h_return >= +5 bps
 *            and flippedDir * 4h_return >=  0 bps
 *
 * This file makes the test reproducible and adds robustness checks:
 *   - cap=1 and cap=2
 *   - Bybit-like 4bps round-trip fees and MEXC/no-fee
 *   - baseline vs unconditional flip vs gated flip
 *   - threshold neighbourhood sweep
 *   - per-year, half-split, monthly paired diffs, worst months
 *   - flipped-subset stats
 */

import * as fs from 'fs'
import * as readline from 'readline'

interface Bar { ts: number; o: number; h: number; l: number; c: number; v: number }
interface ScoreResult { score: number; hold: number }
interface Trade {
  net: number
  gross: number
  isSl: boolean
  isHardSl: boolean
  hold: number
  month: string
  year: number
  bar: number
  softSlBps: number
  sizingBps: number
  flipped: boolean
  rawSide: 'long' | 'short'
  side: 'long' | 'short'
}
interface Meta {
  blocked: number
  flippedSignals: number
  flippedTrades: number
  flippedSl: number
  flippedNet: number
}
type TradeSet = Trade[] & { meta?: Meta }
interface SimOpts {
  stopCap: number
  flipAfterCap: boolean
  /** Total round-trip fee in bps (entry + exit combined). NOT per-leg.
   *  Bybit-like: 4 bps (2 entry + 2 exit). MEXC/no-fee: 0. */
  feeBpsRT: number
  flipMinAbsScore?: number
  flipMom1hBps?: number
  flipMom4hBps?: number
}
interface Stats { n: number; mean: number; sd: number; t: number; wr: number }
interface Metrics {
  label: string
  trades: TradeSet
  n: number
  mean: number
  t: number
  wr: number
  moIR: number
  maxDD: number
  mp: number
  mt: number
  sl: number
  hard: number
  flipped: number
  flipMean: number
  flipT: number
  flipWR: number
  flipSL: number
  flipAvgSizing: number   // avg sizingBps for flipped trades (vs base)
  baseAvgSizing: number   // avg sizingBps for non-flipped trades
}

async function loadBars(files: string[]): Promise<Bar[]> {
  const seen = new Set<number>()
  const all: Bar[] = []
  for (const f of files) {
    if (!fs.existsSync(f)) continue
    const r = readline.createInterface({ input: fs.createReadStream(f) })
    for await (const line of r) {
      if (!line.trim()) continue
      const b = JSON.parse(line) as Bar
      if (!seen.has(b.ts)) { seen.add(b.ts); all.push(b) }
    }
  }
  all.sort((a, b) => a.ts - b.ts)
  return all
}

function lbRet(bars: Bar[], i: number, n: number): number {
  return i >= n && bars[i-n].c > 0 ? (bars[i].c - bars[i-n].c) / bars[i-n].c * 10000 : 0
}
function closePos(b: Bar): number { return b.h > b.l ? (b.c - b.l) / (b.h - b.l) : 0.5 }
function avgCP(bars: Bar[], i: number, n: number): number {
  let s = 0
  for (let j = Math.max(0, i - n + 1); j <= i; j++) s += closePos(bars[j])
  return s / Math.min(n, i + 1)
}
function rsi(bars: Bar[], i: number, n: number): number {
  if (i < n) return 50
  let u = 0, d = 0
  for (let j = i - n + 1; j <= i; j++) {
    const dd = bars[j].c - bars[j-1].c
    if (dd > 0) u += dd; else d -= dd
  }
  return d === 0 ? 100 : 100 - 100 / (1 + u / d)
}

function computeScore(bars: Bar[], i: number): ScoreResult {
  const d = new Date(bars[i].ts)
  const h = d.getUTCHours(), m = d.getUTCMinutes(), dow = d.getUTCDay()
  let w = 0, hW = 0, tW = 0
  const add = (dir: number, wt: number, hold: number): void => { w += dir * wt; hW += hold * wt; tW += wt }
  if (dow === 4) add(-1, 20.00, 240)
  if (dow === 3) add(+1, 19.05, 240)
  if (dow === 0) add(+1, 15.05, 240)
  if (dow === 1) add(+1, 10.61, 240)
  if (dow === 5) add(-1,  6.00, 240)
  if (h === 22)  add(+1,  2.86, 120)
  if (h === 21)  add(+1, 17.90, 120)
  if (h === 20)  add(+1,  9.49,  60)
  if (h === 23)  add(-1,  8.94,  30)
  if (i >= 60) {
    const bp = avgCP(bars, i, 60)
    if (bp > 0.58) add(+1, 15.49, 240)
    if (bp < 0.42) add(-1,  8.00, 240)
  }
  if (i >= 60 && (h === 13 || (h === 14 && m <= 15)) && m <= 15) {
    const gap = lbRet(bars, i, 60)
    if (Math.abs(gap) > 5) add(gap > 0 ? -1 : +1, 15.00, 120)
  }
  if (i >= 60) {
    const rv = rsi(bars, i, 60)
    if (rv < 30) add(+1, 4.63, 120)
  }
  if (i >= 1440) {
    const dy = lbRet(bars, i, 1440)
    if (Math.abs(dy) > 30) add(dy > 0 ? -1 : +1, 5.0, 240)
  }
  return { score: w, hold: tW > 0 ? Math.max(60, Math.min(240, Math.round(hW / tW))) : 120 }
}

const SWING_N = 5
const SWING_LOOKBACK = 480
function compositeLiquidityLevel(bars: Bar[], i: number, dir: number, refPrice?: number): number {
  const N = i + 1, price = refPrice ?? bars[i].c
  const candidates: number[] = [
    dir > 0 ? Math.floor(price / 1000) * 1000 : Math.ceil(price / 1000) * 1000,
    dir > 0 ? Math.floor(price /  500) *  500 : Math.ceil(price /  500) *  500,
    dir > 0 ? Math.floor(price /  250) *  250 : Math.ceil(price /  250) *  250,
  ]

  let bestSwing: number | null = null, bestDist = Infinity
  const scanEnd = N - SWING_N - 1
  const scanStart = Math.max(SWING_N, N - SWING_LOOKBACK - SWING_N)
  for (let j = scanEnd; j >= scanStart; j--) {
    let isSwing = true
    for (let k = 1; k <= SWING_N && isSwing; k++) {
      if (dir > 0) {
        if ((bars[j-k]?.l ?? Infinity) <= bars[j].l) isSwing = false
        if ((bars[j+k]?.l ?? Infinity) <= bars[j].l) isSwing = false
      } else {
        if ((bars[j-k]?.h ?? 0) >= bars[j].h) isSwing = false
        if ((bars[j+k]?.h ?? 0) >= bars[j].h) isSwing = false
      }
    }
    if (!isSwing) continue
    const lvl = dir > 0 ? bars[j].l : bars[j].h
    if (dir > 0 ? lvl >= price : lvl <= price) continue
    const dist = Math.abs(price - lvl)
    if (dist < bestDist) { bestDist = dist; bestSwing = lvl }
  }
  if (bestSwing !== null) candidates.push(bestSwing)

  {
    const d = new Date(bars[N-1].ts); d.setUTCHours(0,0,0,0)
    const today = d.getTime()
    let pdH = 0, pdL = Infinity
    for (let j = N - 1; j >= 0; j--) {
      const ts = bars[j].ts
      if (ts >= today) continue
      if (ts < today - 86_400_000) break
      if (bars[j].h > pdH) pdH = bars[j].h
      if (bars[j].l < pdL) pdL = bars[j].l
    }
    if (dir > 0 && pdL < price && pdL > 0) candidates.push(pdL)
    if (dir < 0 && pdH > price && pdH > 0) candidates.push(pdH)
  }
  {
    const d = new Date(bars[N-1].ts); d.setUTCHours(0,0,0,0)
    const dayStart = d.getTime()
    const volMap = new Map<number, number>()
    for (let j = N - 1; j >= 0 && bars[j].ts >= dayStart; j--) {
      const bucket = Math.round(bars[j].c / 50) * 50
      volMap.set(bucket, (volMap.get(bucket) ?? 0) + (bars[j].v ?? 0))
    }
    let maxV = 0, poc = 0
    volMap.forEach((v, p) => { if (v > maxV) { maxV = v; poc = p } })
    if (poc > 0 && (dir > 0 ? poc < price : poc > price)) candidates.push(poc)
  }

  const valid = candidates.filter(l => dir > 0 ? l <= price : l >= price)
  if (valid.length === 0) return dir > 0 ? Math.floor(price / 250) * 250 : Math.ceil(price / 250) * 250
  return valid.reduce((best, c) => Math.abs(price - c) < Math.abs(price - best) ? c : best)
}

function stopLiquidityLevel(bars: Bar[], i: number, dir: number, refPrice: number): number {
  const N = i + 1, price = refPrice
  const c: number[] = [
    dir > 0 ? Math.floor(price / 1000) * 1000 : Math.ceil(price / 1000) * 1000,
    dir > 0 ? Math.floor(price /  500) *  500 : Math.ceil(price /  500) *  500,
    dir > 0 ? Math.floor(price /  250) *  250 : Math.ceil(price /  250) *  250,
  ]
  const d = new Date(bars[N-1].ts); d.setUTCHours(0,0,0,0)
  const today = d.getTime()
  let pdH = 0, pdL = Infinity
  for (let j = N - 1; j >= 0; j--) {
    if (bars[j].ts >= today) continue
    if (bars[j].ts < today - 86_400_000) break
    if (bars[j].h > pdH) pdH = bars[j].h
    if (bars[j].l < pdL) pdL = bars[j].l
  }
  if (dir > 0 && pdL < price && pdL > 0) c.push(pdL)
  if (dir < 0 && pdH > price && pdH > 0) c.push(pdH)
  const v = c.filter(l => dir > 0 ? l <= price : l >= price)
  if (v.length === 0) return dir > 0 ? Math.floor(price / 250) * 250 : Math.ceil(price / 250) * 250
  return v.reduce((b, x) => Math.abs(price - x) < Math.abs(price - b) ? x : b)
}

/**
 * Design choices for flipped trades (all intentional):
 *
 * CONFIRMATION  The 4-bar confirmation gate checks the RAW (capped) direction, not the
 *               flipped direction. This ensures the level has been repeatedly failing
 *               before the flip is attempted. The flip itself is not score-confirmed;
 *               it is only momentum-gated (1h/4h return). This is appropriate for a
 *               counter-trend thesis but means flipped entries are confirmed differently
 *               from normal entries.
 *
 * HOLD TIME     sc.hold comes from the raw signal's weighted hold estimate. For a
 *               flipped trade, this was calibrated against the original direction.
 *               In practice the extension check (es.score * dir >= EXT) almost never
 *               fires for a flipped trade because the underlying score is still in the
 *               original direction, so flipped trades effectively get exactly MIN_H to
 *               MAX_H hold and are never extended. This is conservative and appropriate
 *               for counter-trend positions.
 *
 * STOP COUNTS   A flipped trade's stop-out is counted against the FLIPPED side's daily
 *               cap, not the raw side's cap. The raw side remains capped all day. The
 *               global 30-bar cooldown after any stop-out can briefly suppress entries
 *               in both directions, including valid natural signals in the non-capped
 *               direction.
 *
 * @param tradeStart  bar index from which to start generating signals (0 = beginning).
 *                    Context lookback (PDH/PDL, swings, momentum) uses all bars before
 *                    tradeStart — exits can also extend beyond tradeEnd.
 * @param tradeEnd    bar index at which to stop generating signals (bars.length = end).
 */
function simulate(bars: Bar[], opts: SimOpts, tradeStart = 0, tradeEnd = bars.length): TradeSet {
  const trades = [] as TradeSet
  const stopCounts = new Map<string, number>()
  let cooldown = 0
  const N = bars.length   // full array for exit simulation and context
  const EXT = 8, WAIT = 20, MIN_H = 60, MAX_H = 240, CAP = 360, CONFIRM = 4
  const SL_COOLDOWN_BARS = 30
  const meta: Meta = { blocked: 0, flippedSignals: 0, flippedTrades: 0, flippedSl: 0, flippedNet: 0 }

  for (let i = Math.max(tradeStart, 20160); i < tradeEnd - WAIT; i++) {
    if (i < cooldown) continue
    const sc = computeScore(bars, i)
    if (sc.score === 0) continue
    const rawDir = sc.score > 0 ? +1 : -1
    const rawSide: 'long' | 'short' = rawDir > 0 ? 'long' : 'short'
    const rawThresh = rawDir > 0 ? 10 : 8
    if (Math.abs(sc.score) < rawThresh) continue

    let confirmed = true
    for (let k = 1; k < CONFIRM; k++) {
      const sk = computeScore(bars, i-k).score
      if (sk * rawDir < rawThresh) { confirmed = false; break }
    }
    if (!confirmed) continue

    if (rawDir * lbRet(bars, i,  4320) < -600) continue
    if (rawDir * lbRet(bars, i, 10080) < -700) continue
    if (rawDir * lbRet(bars, i, 20160) < -800) continue

    const day = new Date(bars[i].ts).toISOString().slice(0, 10)
    const rawCk = `${day}_${rawSide}`
    let dir = rawDir
    let side: 'long' | 'short' = rawSide
    let wasFlipped = false

    if ((stopCounts.get(rawCk) ?? 0) >= opts.stopCap) {
      // ── Flip branch ───────────────────────────────────────────────────────
      // The raw-direction momentum halts do NOT apply here. In fact they are
      // often INVERTED: if rawDir=SHORT halts because price went up strongly
      // (rawDir * 72h < -600), that means the flipped direction (LONG) has
      // 72h momentum in its favour. Blocking the flip with the raw halt was
      // the pre-fix bug. Only the flipped direction's own halt check applies.
      if (!opts.flipAfterCap) { meta.blocked++; continue }
      dir = -rawDir
      side = dir > 0 ? 'long' : 'short'
      wasFlipped = true
      meta.flippedSignals++
      // Bug fix: entry threshold must match the flipped entry direction, not the raw direction.
      // A flip to LONG must pass the LONG threshold (10); a flip to SHORT must pass SHORT (8).
      const flipThresh = dir > 0 ? 10 : 8
      if (Math.abs(sc.score) < flipThresh) { meta.blocked++; continue }
      if (opts.flipMinAbsScore !== undefined && Math.abs(sc.score) < opts.flipMinAbsScore) { meta.blocked++; continue }
      if (opts.flipMom1hBps !== undefined && dir * lbRet(bars, i, 60)  < opts.flipMom1hBps) { meta.blocked++; continue }
      if (opts.flipMom4hBps !== undefined && dir * lbRet(bars, i, 240) < opts.flipMom4hBps) { meta.blocked++; continue }
      if ((stopCounts.get(`${day}_${side}`) ?? 0) >= opts.stopCap) { meta.blocked++; continue }
      // Momentum halts checked for the FLIPPED direction
      if (dir * lbRet(bars, i,  4320) < -600) continue
      if (dir * lbRet(bars, i, 10080) < -700) continue
      if (dir * lbRet(bars, i, 20160) < -800) continue
    } else {
      // ── Normal (non-flipped) branch ───────────────────────────────────────
      // Momentum halts apply to the raw (entry) direction as in full-validation.ts
      if (rawDir * lbRet(bars, i,  4320) < -600) continue
      if (rawDir * lbRet(bars, i, 10080) < -700) continue
      if (rawDir * lbRet(bars, i, 20160) < -800) continue
    }

    const target = compositeLiquidityLevel(bars, i, dir)
    const distBps = Math.abs(bars[i].c - target) / bars[i].c * 10000
    let entryPx = bars[i].c, entryBar = i, filled = distBps <= 10
    if (!filled) {
      for (let j = i + 1; j <= Math.min(N - 1, i + WAIT); j++) {
        const hit = dir > 0 ? bars[j].l <= target : bars[j].h >= target
        if (hit) { filled = true; entryBar = j; entryPx = target; break }
      }
    }
    if (!filled) continue

    const slRef = dir > 0 ? target - 0.01 : target + 0.01
    const slL2 = stopLiquidityLevel(bars, i, dir, slRef)
    const slBps = Math.abs(target - slL2) / target * 10000
    const isStruct = slBps >= 15 && slBps <= 200
    const softSlBps = isStruct ? slBps : 100
    const slPx = isStruct ? slL2 : (dir > 0 ? target * 0.99 : target * 1.01)
    const l3ref = dir > 0 ? slPx - 0.01 : slPx + 0.01
    const slL3 = stopLiquidityLevel(bars, i, dir, l3ref)
    const l3Gap = Math.max(10, Math.min(100, Math.abs(slPx - slL3) / slPx * 10000))
    const hardSlBps = isStruct ? softSlBps + l3Gap + 8 : 133
    const hardSlPx = isStruct
      ? (dir > 0 ? slPx * (1 - l3Gap / 10000) : slPx * (1 + l3Gap / 10000))
      : (dir > 0 ? target * (1 - 1.25 / 100) : target * (1 + 1.25 / 100))
    const sizingBps = isStruct ? softSlBps + l3Gap : 125

    const hardCap = i + CAP
    let deadline = i + Math.max(MIN_H, Math.min(MAX_H, sc.hold))
    let exitBar = -1, isSl = false, isHardSl = false
    for (let j = entryBar + 1; j < Math.min(N, hardCap + 1); j++) {
      if (dir > 0 ? bars[j].c <= slPx : bars[j].c >= slPx) {
        exitBar = j; isSl = true
        isHardSl = dir > 0 ? bars[j].l <= hardSlPx : bars[j].h >= hardSlPx
        break
      }
      if (j >= i + MIN_H) {
        const es = computeScore(bars, j)
        if (es.score * dir >= EXT) {
          const proposed = Math.min(j + Math.max(MIN_H, Math.min(MAX_H, es.hold)), hardCap)
          if (proposed > deadline) deadline = proposed
        }
      }
      if (j >= deadline) { exitBar = j; break }
    }
    if (exitBar < 0) exitBar = Math.min(N - 2, hardCap)

    const gross = isSl ? (isHardSl ? -hardSlBps : -softSlBps) : dir * (bars[exitBar].c - entryPx) / entryPx * 10000
    const net = gross - opts.feeBpsRT
    trades.push({
      net, gross, isSl, isHardSl, hold: exitBar - entryBar,
      month: new Date(bars[i].ts).toISOString().slice(0, 7),
      year: new Date(bars[i].ts).getUTCFullYear(), bar: i,
      softSlBps, sizingBps, flipped: wasFlipped, rawSide, side
    })
    if (wasFlipped) { meta.flippedTrades++; meta.flippedNet += net; if (isSl) meta.flippedSl++ }
    if (isSl) stopCounts.set(`${day}_${side}`, (stopCounts.get(`${day}_${side}`) ?? 0) + 1)
    cooldown = exitBar + (isSl ? SL_COOLDOWN_BARS : 5)
  }
  trades.meta = meta
  return trades
}

function stats(v: number[]): Stats {
  const n = v.length
  if (n < 3) return { n, mean: 0, sd: 0, t: 0, wr: 0 }
  const mean = v.reduce((a, b) => a + b, 0) / n
  const sd = Math.sqrt(v.reduce((s, x) => s + (x - mean) ** 2, 0) / (n - 1))
  return { n, mean, sd, t: sd > 0 ? mean / (sd / Math.sqrt(n)) : 0, wr: v.filter(x => x > 0).length / n }
}
function sig(t: number): string { const a = Math.abs(t); return a > 3.89 ? '****' : a > 3.29 ? '***' : a > 2.58 ? '**' : a > 1.96 ? '*' : '' }
function maxDD(trades: TradeSet, risk = 0.03): number {
  let eq = 500, pk = 500, dd = 0
  for (const t of trades) {
    eq += eq * risk / (t.sizingBps / 10000) * t.net / 10000
    if (eq > pk) pk = eq
    dd = Math.max(dd, (pk - eq) / pk)
  }
  return dd
}
function monthlyPnl(trades: TradeSet, fixedNotional = 1500): Record<string, number> {
  const out: Record<string, number> = {}
  for (const t of trades) out[t.month] = (out[t.month] ?? 0) + fixedNotional * t.net / 10000
  return out
}
function moIR(trades: TradeSet): number {
  const mp = Object.values(monthlyPnl(trades))
  if (mp.length < 3) return 0
  const m = mp.reduce((a, b) => a + b, 0) / mp.length
  const sd = Math.sqrt(mp.reduce((s, x) => s + (x - m) ** 2, 0) / (mp.length - 1))
  return sd > 0 ? m / sd : 0
}
function monthsPos(trades: TradeSet): [number, number] {
  const vals = Object.values(monthlyPnl(trades))
  return [vals.filter(x => x > 0).length, vals.length]
}
function metrics(label: string, trades: TradeSet): Metrics {
  const s = stats(trades.map(t => t.net))
  const [mp, mt] = monthsPos(trades)
  const ft = trades.filter(t => t.flipped)
  const bt = trades.filter(t => !t.flipped)
  const fs = stats(ft.map(t => t.net))
  const flipAvgSizing = ft.length ? ft.reduce((a, t) => a + t.sizingBps, 0) / ft.length : 0
  const baseAvgSizing = bt.length ? bt.reduce((a, t) => a + t.sizingBps, 0) / bt.length : 0
  return {
    label, trades, n: s.n, mean: s.mean, t: s.t, wr: s.wr, moIR: moIR(trades), maxDD: maxDD(trades), mp, mt,
    sl: trades.filter(t => t.isSl).length, hard: trades.filter(t => t.isHardSl).length,
    flipped: ft.length, flipMean: fs.mean, flipT: fs.t, flipWR: fs.wr, flipSL: ft.filter(t => t.isSl).length,
    flipAvgSizing, baseAvgSizing,
  }
}
function fmt(m: Metrics): string {
  const sizingNote = m.flipped && m.flipAvgSizing > 0
    ? `  flipSiz=${m.flipAvgSizing.toFixed(0)} baseSiz=${m.baseAvgSizing.toFixed(0)}` : ''
  return `${m.label.padEnd(38)} n=${String(m.n).padStart(5)} mean=${m.mean.toFixed(2).padStart(6)} t=${m.t.toFixed(2).padStart(5)}${sig(m.t).padEnd(4)} moIR=${m.moIR.toFixed(2).padStart(4)} maxDD=${(m.maxDD*100).toFixed(1).padStart(5)}% m+=${String(m.mp).padStart(2)}/${m.mt} flipN=${String(m.flipped).padStart(4)} flipMean=${m.flipped ? m.flipMean.toFixed(2).padStart(6) : '   n/a'} flipT=${m.flipped ? (m.flipT.toFixed(2) + sig(m.flipT)).padEnd(9) : 'n/a'.padEnd(9)} flipWR=${m.flipped ? (m.flipWR*100).toFixed(1).padStart(5)+'%' : '  n/a'}${sizingNote}`
}
function yearLines(trades: TradeSet): string[] {
  const out: string[] = []
  for (const yr of [2022, 2023, 2024, 2025, 2026]) {
    const yt = trades.filter(t => t.year === yr) as TradeSet
    if (yt.length < 5) continue
    const s = stats(yt.map(t => t.net)); const [mp, mt] = monthsPos(yt)
    out.push(`${yr}: n=${String(yt.length).padStart(4)} mean=${s.mean.toFixed(2).padStart(6)} t=${s.t.toFixed(2)}${sig(s.t).padEnd(4)} m+=${mp}/${mt}`)
  }
  return out
}
function halfSplit(trades: TradeSet): string {
  const h = Math.floor(trades.length / 2)
  const a = stats(trades.slice(0, h).map(t => t.net))
  const b = stats(trades.slice(h).map(t => t.net))
  return `H1 mean=${a.mean.toFixed(2)} t=${a.t.toFixed(2)}${sig(a.t)} | H2 mean=${b.mean.toFixed(2)} t=${b.t.toFixed(2)}${sig(b.t)}`
}
function pairedMonthlyDiff(a: TradeSet, b: TradeSet): string {
  const ma = monthlyPnl(a), mb = monthlyPnl(b)
  const months = [...new Set([...Object.keys(ma), ...Object.keys(mb)])].sort()
  const diffs = months.map(m => (mb[m] ?? 0) - (ma[m] ?? 0))
  const s = stats(diffs)
  const improved = diffs.filter(x => x > 0).length
  const worst = months.map((m, idx) => ({ month: m, diff: diffs[idx], a: ma[m] ?? 0, b: mb[m] ?? 0 })).sort((x, y) => x.diff - y.diff).slice(0, 5)
  const best = months.map((m, idx) => ({ month: m, diff: diffs[idx], a: ma[m] ?? 0, b: mb[m] ?? 0 })).sort((x, y) => y.diff - x.diff).slice(0, 5)
  return [
    `paired monthly diff: improved=${improved}/${months.length} meanDiff=$${s.mean.toFixed(2)} t=${s.t.toFixed(2)}${sig(s.t)}`,
    `  worst diffs: ${worst.map(x => `${x.month}:${x.diff.toFixed(0)}`).join(' ')}`,
    `  best diffs:  ${best.map(x => `${x.month}:+${x.diff.toFixed(0)}`).join(' ')}`,
  ].join('\n')
}

async function main(): Promise<void> {
  console.log('Loading bars...')
  const bars = await loadBars([
    'data/klines/BTCUSDT-1m-2022-2025.jsonl',
    'data/klines/BTCUSDT-1m-2022-2025b.jsonl',
    'data/klines/BTCUSDT-1m.jsonl',
  ])
  console.log(`${bars.length.toLocaleString()} bars (${new Date(bars[0].ts).toISOString().slice(0,10)} → ${new Date(bars[bars.length - 1].ts).toISOString().slice(0,10)})`)

  const variants: Array<[string, Partial<SimOpts>]> = [
    ['baseline', { flipAfterCap: false }],
    ['unconditional', { flipAfterCap: true }],
    ['candidate_m1>=5_m4>=0', { flipAfterCap: true, flipMom1hBps: 5, flipMom4hBps: 0 }],
    ['candidate_m1>=5_m4>=10', { flipAfterCap: true, flipMom1hBps: 5, flipMom4hBps: 10 }],
    ['score20_only', { flipAfterCap: true, flipMinAbsScore: 20 }],
    ['strict_s20_m1>=5_m4>=10', { flipAfterCap: true, flipMinAbsScore: 20, flipMom1hBps: 5, flipMom4hBps: 10 }],
  ]

  for (const feeBps of [4, 0]) {
    console.log(`\n╔════════════════════════════════════════════════════════════╗`)
    console.log(`║ Fee regime: ${feeBps === 0 ? 'MEXC/no-fee' : 'Bybit-like 4bps RT'}${' '.repeat(feeBps === 0 ? 29 : 23)}║`)
    console.log(`╚════════════════════════════════════════════════════════════╝`)
    for (const stopCap of [2, 1]) {
      console.log(`\n--- STOP_CAP_PER_DAY=${stopCap} ---`)
      const ms: Metrics[] = []
      for (const [name, partial] of variants) {
        ms.push(metrics(name, simulate(bars, { stopCap, feeBpsRT: feeBps, flipAfterCap: false, ...partial })))
      }
      for (const m of ms) console.log(fmt(m))
      const base = ms.find(m => m.label === 'baseline')!
      const candidate = ms.find(m => m.label === 'candidate_m1>=5_m4>=0')!
      console.log(`\nCandidate vs baseline monthly:`)
      console.log(pairedMonthlyDiff(base.trades, candidate.trades))
      console.log(`\nCandidate half split: ${halfSplit(candidate.trades)}`)
      console.log(`Candidate per year:`)
      for (const y of yearLines(candidate.trades)) console.log(`  ${y}`)
    }
  }

  console.log(`\n╔════════════════════════════════════════════════════════════╗`)
  console.log(`║ Threshold neighbourhood: cap=2 candidate robustness         ║`)
  console.log(`╚════════════════════════════════════════════════════════════╝`)
  for (const feeBps of [4, 0]) {
    console.log(`\nfeeBps=${feeBps}`)
    const rows: Metrics[] = []
    for (const m1 of [0, 5, 10, 20]) {
      for (const m4 of [0, 10, 20, 40]) {
        rows.push(metrics(`m1>=${m1} m4>=${m4}`, simulate(bars, { stopCap: 2, feeBpsRT: feeBps, flipAfterCap: true, flipMom1hBps: m1, flipMom4hBps: m4 })))
      }
    }
    for (const r of rows.sort((a, b) => b.moIR - a.moIR).slice(0, 12)) console.log(fmt(r))
  }

  // ─── Walk-forward: temporal stability check ─────────────────────────────────
  // IMPORTANT: the candidate threshold (m1>=5, m4>=0) was identified by sweeping
  // the full 4-year dataset. There is no strictly out-of-sample period in our data.
  // What follows shows per-year and period-split consistency. If the improvement is
  // concentrated in one or two years it is likely data-mined. If it appears across
  // most years and both period halves, it is more likely structural.
  //
  // True OOS validation would require extending the dataset beyond Apr 2026.

  console.log(`\n╔════════════════════════════════════════════════════════════╗`)
  console.log(`║ Walk-forward: temporal stability (MEXC/no-fee, cap=2)       ║`)
  console.log(`║ Candidate threshold m1>=5 m4>=0 selected on FULL 4yr data.  ║`)
  console.log(`║ Per-year and split results show regime consistency only.     ║`)
  console.log(`╚════════════════════════════════════════════════════════════╝`)

  // Build year boundary indices
  const yrStart = new Map<number, number>(), yrEnd = new Map<number, number>()
  for (let i = 0; i < bars.length; i++) {
    const yr = new Date(bars[i].ts).getUTCFullYear()
    if (!yrStart.has(yr)) yrStart.set(yr, i)
    yrEnd.set(yr, i)
  }

  const CAND_OPTS = { flipAfterCap: true  as const, flipMom1hBps: 5, flipMom4hBps: 0 }
  const BASE_OPTS = { flipAfterCap: false as const }
  const WF_CAP = 2, WF_FEE = 0

  function wfLine(period: string, base: Metrics, cand: Metrics): void {
    const dMoIR = cand.moIR  - base.moIR
    const dDD   = base.maxDD - cand.maxDD   // positive = DD improved
    const dMP   = cand.mp    - base.mp
    const candFlipNote = cand.flipped > 0
      ? ` flipN=${cand.flipped} flipMean=${cand.flipMean.toFixed(2)} flipT=${cand.flipT.toFixed(2)}${sig(cand.flipT)} flipSiz=${cand.flipAvgSizing.toFixed(0)}/base=${cand.baseAvgSizing.toFixed(0)}`
      : ''
    console.log(
      `${period.padEnd(22)} ` +
      `base: moIR=${base.moIR.toFixed(2)} t=${base.t.toFixed(2)}${sig(base.t).padEnd(4)} maxDD=${(base.maxDD*100).toFixed(1)}% m+=${base.mp}/${base.mt} ` +
      `cand: moIR=${cand.moIR.toFixed(2)} t=${cand.t.toFixed(2)}${sig(cand.t).padEnd(4)} maxDD=${(cand.maxDD*100).toFixed(1)}% m+=${cand.mp}/${cand.mt} ` +
      `Δ moIR=${dMoIR >= 0 ? '+' : ''}${dMoIR.toFixed(2)} DD=${dDD >= 0 ? '+' : ''}${(dDD*100).toFixed(1)}pp m+=${dMP >= 0 ? '+' : ''}${dMP}` +
      candFlipNote
    )
  }

  console.log('\nPer-year:')
  for (const yr of [2022, 2023, 2024, 2025, 2026]) {
    const s = yrStart.get(yr), e = yrEnd.get(yr)
    if (s === undefined || e === undefined) continue
    const b = metrics(`${yr}-base`, simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...BASE_OPTS }, s, e + 1))
    const c = metrics(`${yr}-cand`, simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...CAND_OPTS }, s, e + 1))
    if (b.n < 5) continue
    wfLine(`${yr} (n=${b.n}→${c.n})`, b, c)
  }

  // Period splits — two ways of cutting the data
  const yr2024s = yrStart.get(2024)!
  const yr2025s = yrStart.get(2025)!
  const yr2022s = yrStart.get(2022)!

  console.log('\nPeriod splits:')
  // Split A: 2022-2023 vs 2024-2026
  const spA_b = metrics('A-base 2022-2023', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...BASE_OPTS }, yr2022s, yr2024s))
  const spA_c = metrics('A-cand 2022-2023', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...CAND_OPTS }, yr2022s, yr2024s))
  const spB_b = metrics('B-base 2024-2026', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...BASE_OPTS }, yr2024s))
  const spB_c = metrics('B-cand 2024-2026', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...CAND_OPTS }, yr2024s))
  wfLine(`2022-2023 (n=${spA_b.n}→${spA_c.n})`, spA_b, spA_c)
  wfLine(`2024-2026 (n=${spB_b.n}→${spB_c.n})`, spB_b, spB_c)

  // Split B: 2022-2024 vs 2025-2026 (treats last two years as quasi-OOS)
  const spC_b = metrics('C-base 2022-2024', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...BASE_OPTS }, yr2022s, yr2025s))
  const spC_c = metrics('C-cand 2022-2024', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...CAND_OPTS }, yr2022s, yr2025s))
  const spD_b = metrics('D-base 2025-2026', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...BASE_OPTS }, yr2025s))
  const spD_c = metrics('D-cand 2025-2026', simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...CAND_OPTS }, yr2025s))
  wfLine(`2022-2024 (n=${spC_b.n}→${spC_c.n})`, spC_b, spC_c)
  wfLine(`2025-2026 (n=${spD_b.n}→${spD_c.n})`, spD_b, spD_c)

  console.log('\nSizing check (moIR on fixed $1500 notional may overstate flipped trades')
  console.log('if their sizingBps is larger, meaning smaller actual positions):')
  for (const yr of [2022, 2023, 2024, 2025, 2026]) {
    const s = yrStart.get(yr), e = yrEnd.get(yr)
    if (s === undefined || e === undefined) continue
    const c = metrics(`${yr}`, simulate(bars, { stopCap: WF_CAP, feeBpsRT: WF_FEE, ...CAND_OPTS }, s, e + 1))
    if (c.flipped < 5) continue
    const sizeBias = c.flipAvgSizing - c.baseAvgSizing
    console.log(`  ${yr}: flipAvgSiz=${c.flipAvgSizing.toFixed(0)} baseAvgSiz=${c.baseAvgSizing.toFixed(0)} diff=${sizeBias >= 0 ? '+' : ''}${sizeBias.toFixed(0)} bps → flipped positions ${Math.abs(sizeBias) < 5 ? 'same size' : sizeBias > 0 ? 'SMALLER (wider stop)' : 'LARGER (tighter stop)'}`)
  }
}

main().catch(e => { console.error(e); process.exit(1) })
