/**
 * hyro-backtest.ts
 *
 * HyroTrader challenge/funded-rule backtester for the validated BTC strategy.
 *
 * Models the prop-firm nuances that are NOT captured by ordinary P&L backtests:
 *   - One-Step / Two-Step phase targets
 *   - Standard trailing daily drawdown, including floating P&L
 *   - Swing/fixed daily drawdown, including floating P&L
 *   - Overall max loss barrier, including floating P&L
 *   - Minimum trading days
 *   - Profit distribution rule: no positive day > 40% of total positive profit
 *   - Stop cap per UTC day (total or per-side)
 *   - Risk based on initial balance by default, because Hyro's max-risk rule is
 *     stated against initial account balance
 *   - Optional funded-account notional cap (2x initial balance) and margin cap
 *
 * This intentionally duplicates the current full-validation strategy logic so it
 * can be audited side-by-side with src/research/full-validation.ts.
 */

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 }

type DrawdownType = 'trailing' | 'swing'
type ChallengeKind = 'one-step' | 'two-step-1' | 'two-step-2' | 'funded'
type StopCapMode = 'total' | 'per-side'
type RiskBase = 'initial' | 'equity' | 'min-initial-equity'
type IntrabarMode = 'close' | 'adverse' | 'ohlc-conservative'
type StartMode = 'monthly' | 'weekly' | 'daily'

interface HyroConfig {
  kind: ChallengeKind
  accountSize: number
  riskPct: number
  feeBps: number
  targetPct: number
  dailyDdPct: number
  ddType: DrawdownType
  maxLossPct: number
  minTradingDays: number
  profitDistributionPct: number
  enforceProfitDistribution: boolean
  stopCap: number
  stopCapMode: StopCapMode
  slCooldownBars: number
  riskBase: RiskBase
  intrabarMode: IntrabarMode
  maxCalendarDays: number
  maxNotionalMultiple?: number
  maxMarginPct?: number
  leverage: number
}

interface Position {
  dir: 1 | -1
  entryPx: number
  entryBar: number
  signalBar: number
  entryDay: string
  notional: number
  margin: number
  softSlPx: number
  hardSlPx: number
  softSlBps: number
  hardSlBps: number
  sizingBps: number
  deadline: number
  hardCap: number
}

interface AttemptResult {
  start: string
  end: string
  status: 'pass' | 'fail' | 'incomplete'
  reason: string
  equity: number
  returnPct: number
  days: number
  tradingDays: number
  trades: number
  stops: number
  hardStops: number
  maxOverallDdPct: number
  maxDailyDdPct: number
  maxNotionalMultiple: number
  notionalViolations: number
  marginViolations: number
  pdrOk: boolean
  maxPositiveDayShare: number
  targetTouched: boolean
  targetTouchedButIneligible: boolean
}

interface Summary {
  label: string
  n: number
  pass: number
  fail: number
  incomplete: number
  targetTouchedButIneligible: number
  passRate: number
  failRate: number
  avgDaysToPass: number
  avgRet: number
  medianRet: number
  avgTrades: number
  avgMaxDd: number
  avgMaxDailyDd: number
  p10Ret: number
  p90Ret: number
  reasons: Record<string, number>
}

// ─────────────────────────────────────────────────────────────
// Data loading
// ─────────────────────────────────────────────────────────────
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
}

// ─────────────────────────────────────────────────────────────
// Strategy helpers — mirrored from full-validation.ts
// ─────────────────────────────────────────────────────────────
function dayKey(ts: number): string { return new Date(ts).toISOString().slice(0, 10) }
function monthKey(ts: number): string { return new Date(ts).toISOString().slice(0, 7) }
function pct(n: number): string { return `${(n * 100).toFixed(1)}%` }
function money(n: number): string { return `${n < 0 ? '-' : ''}$${Math.abs(n).toFixed(0)}` }

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) => { 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
  const price = refPrice ?? bars[i].c
  const candidates: number[] = []
  candidates.push(
    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)
}

/** Round + PDH/PDL only, no swings/POC — current runner's stopLiquidityLevel. */
function stopLiquidityLevel(bars: Bar[], i: number, dir: number, refPrice: number, minDistanceBps = 0): 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)
    .sort((a, b) => Math.abs(price - a) - Math.abs(price - b))
  if (v.length === 0) return dir > 0 ? Math.floor(price / 250) * 250 : Math.ceil(price / 250) * 250
  if (minDistanceBps > 0) {
    const wideEnough = v.find(l => Math.abs(price - l) / price * 10000 >= minDistanceBps)
    if (wideEnough !== undefined) return wideEnough
    return v[v.length - 1]
  }
  return v[0]
}

// ─────────────────────────────────────────────────────────────
// Hyro challenge simulation
// ─────────────────────────────────────────────────────────────
const EXT = 8
const WAIT = 30  // current production entry wait (wait30 imm10)
const MIN_H = 60
const MAX_H = 240
const CAP = 420
const CONFIRM = 4
const LONG_ENTRY_THRESH = 16
const SHORT_ENTRY_THRESH = 8
const MIN_SOFT_SL_BPS = 25

function tradingDaysEligible(tradingDays: Set<string>, minTradingDays: number): boolean {
  return tradingDays.size >= minTradingDays
}

function profitDistribution(days: Map<string, number>, profitDistributionPct: number): { ok: boolean; maxShare: number; totalPositive: number } {
  const positives = [...days.values()].filter(x => x > 0)
  const totalPositive = positives.reduce((a, b) => a + b, 0)
  if (totalPositive <= 0) return { ok: false, maxShare: 1, totalPositive }
  const maxDay = Math.max(...positives, 0)
  const maxShare = maxDay / totalPositive
  return { ok: maxShare <= profitDistributionPct, maxShare, totalPositive }
}

function riskBaseEquity(cfg: HyroConfig, initial: number, equity: number): number {
  if (cfg.riskBase === 'initial') return initial
  if (cfg.riskBase === 'equity') return equity
  return Math.min(initial, equity)
}

function positionUnrealized(pos: Position, price: number): number {
  return pos.dir * (price - pos.entryPx) / pos.entryPx * pos.notional
}

function barEquitySamples(pos: Position | null, bar: Bar, realizedEquity: number, mode: IntrabarMode): { peak: number; trough: number; close: number } {
  if (!pos) return { peak: realizedEquity, trough: realizedEquity, close: realizedEquity }
  const closeEq = realizedEquity + positionUnrealized(pos, bar.c)
  if (mode === 'close') return { peak: closeEq, trough: closeEq, close: closeEq }

  const favorablePx = pos.dir > 0 ? bar.h : bar.l
  const adversePx = pos.dir > 0 ? bar.l : bar.h
  const peak = realizedEquity + positionUnrealized(pos, favorablePx)
  const trough = realizedEquity + positionUnrealized(pos, adversePx)
  if (mode === 'adverse') return { peak: closeEq, trough, close: closeEq }
  return { peak, trough, close: closeEq }
}

function attemptPassAllowed(cfg: HyroConfig, equity: number, initial: number, tradingDays: Set<string>, dailyPnl: Map<string, number>): { ok: boolean; pdrOk: boolean; maxShare: number; target: boolean } {
  const target = equity >= initial * (1 + cfg.targetPct)
  const minDaysOk = tradingDaysEligible(tradingDays, cfg.minTradingDays)
  const pdr = profitDistribution(dailyPnl, cfg.profitDistributionPct)
  const pdrOk = !cfg.enforceProfitDistribution || pdr.ok
  return { ok: target && minDaysOk && pdrOk, pdrOk, maxShare: pdr.maxShare, target }
}

function simulateAttempt(bars: Bar[], startIdx: number, cfg: HyroConfig): AttemptResult {
  const initial = cfg.accountSize
  const dailyDdLimit = initial * cfg.dailyDdPct
  const maxLossLimit = initial * cfg.maxLossPct
  const endTs = bars[startIdx].ts + cfg.maxCalendarDays * 86_400_000
  let endIdx = startIdx
  while (endIdx < bars.length && bars[endIdx].ts < endTs) endIdx++
  endIdx = Math.min(endIdx, bars.length - WAIT - 2)

  let equity = initial
  let pos: Position | null = null
  let cooldown = 0
  let trades = 0, stops = 0, hardStops = 0
  let notionalViolations = 0, marginViolations = 0
  let maxNotionalMultipleSeen = 0
  let maxOverallDdPct = 0, maxDailyDdPct = 0
  let targetTouched = false, targetTouchedButIneligible = false

  const stopCounts = new Map<string, number>()
  const tradingDays = new Set<string>()
  const dailyPnl = new Map<string, number>()

  let currentDay = dayKey(bars[startIdx].ts)
  let dayStartEquity = equity
  let dayPeakEquity = equity

  function failResult(i: number, reason: string): AttemptResult {
    const pdr = profitDistribution(dailyPnl, cfg.profitDistributionPct)
    return {
      start: dayKey(bars[startIdx].ts), end: dayKey(bars[Math.min(i, bars.length - 1)].ts),
      status: 'fail', reason, equity, returnPct: (equity - initial) / initial,
      days: Math.max(1, Math.ceil((bars[Math.min(i, bars.length - 1)].ts - bars[startIdx].ts) / 86_400_000)),
      tradingDays: tradingDays.size, trades, stops, hardStops,
      maxOverallDdPct, maxDailyDdPct, maxNotionalMultiple: maxNotionalMultipleSeen,
      notionalViolations, marginViolations, pdrOk: pdr.ok, maxPositiveDayShare: pdr.maxShare,
      targetTouched, targetTouchedButIneligible,
    }
  }

  function passOrIncomplete(i: number, status: 'pass' | 'incomplete', reason: string): AttemptResult {
    const pdr = profitDistribution(dailyPnl, cfg.profitDistributionPct)
    return {
      start: dayKey(bars[startIdx].ts), end: dayKey(bars[Math.min(i, bars.length - 1)].ts),
      status, reason, equity, returnPct: (equity - initial) / initial,
      days: Math.max(1, Math.ceil((bars[Math.min(i, bars.length - 1)].ts - bars[startIdx].ts) / 86_400_000)),
      tradingDays: tradingDays.size, trades, stops, hardStops,
      maxOverallDdPct, maxDailyDdPct, maxNotionalMultiple: maxNotionalMultipleSeen,
      notionalViolations, marginViolations, pdrOk: pdr.ok, maxPositiveDayShare: pdr.maxShare,
      targetTouched, targetTouchedButIneligible,
    }
  }

  for (let i = Math.max(startIdx, 20160); i < endIdx; i++) {
    const dkey = dayKey(bars[i].ts)
    if (dkey !== currentDay) {
      currentDay = dkey
      dayStartEquity = equity
      dayPeakEquity = equity
    }

    // Barrier checks with floating P&L.
    const samples = barEquitySamples(pos, bars[i], equity, cfg.intrabarMode)
    if (cfg.ddType === 'trailing') {
      dayPeakEquity = Math.max(dayPeakEquity, samples.peak)
      const dayDd = dayPeakEquity - samples.trough
      maxDailyDdPct = Math.max(maxDailyDdPct, dayDd / initial)
      if (dayDd > dailyDdLimit) return failResult(i, `daily_dd_${cfg.ddType}`)
    } else {
      const dayDd = dayStartEquity - samples.trough
      maxDailyDdPct = Math.max(maxDailyDdPct, dayDd / initial)
      if (dayDd > dailyDdLimit) return failResult(i, `daily_dd_${cfg.ddType}`)
    }
    const overallDd = initial - samples.trough
    maxOverallDdPct = Math.max(maxOverallDdPct, overallDd / initial)
    if (overallDd > maxLossLimit) return failResult(i, 'max_loss')

    const eligibility = attemptPassAllowed(cfg, equity, initial, tradingDays, dailyPnl)
    if (eligibility.target) {
      targetTouched = true
      if (eligibility.ok) return passOrIncomplete(i, 'pass', 'target_min_days_pdr')
      targetTouchedButIneligible = true
    }

    if (pos) {
      // Soft stop on close, hard stop intrabar. This mirrors full-validation.
      const softHit = pos.dir > 0 ? bars[i].c <= pos.softSlPx : bars[i].c >= pos.softSlPx
      const hardHit = pos.dir > 0 ? bars[i].l <= pos.hardSlPx : bars[i].h >= pos.hardSlPx
      if (softHit || hardHit) {
        const grossBps = hardHit ? -pos.hardSlBps : -pos.softSlBps
        const netBps = grossBps - cfg.feeBps // exit fee; entry fee was booked at entry
        const pnl = pos.notional * netBps / 10000
        equity += pnl
        dailyPnl.set(dayKey(bars[i].ts), (dailyPnl.get(dayKey(bars[i].ts)) ?? 0) + pnl)
        trades++; stops++; if (hardHit) hardStops++
        const stopKey = cfg.stopCapMode === 'total' ? `${dayKey(bars[i].ts)}_all` : `${dayKey(bars[i].ts)}_${pos.dir > 0 ? 'long' : 'short'}`
        stopCounts.set(stopKey, (stopCounts.get(stopKey) ?? 0) + 1)
        cooldown = i + cfg.slCooldownBars
        pos = null
        continue
      }

      if (i >= pos.signalBar + MIN_H) {
        const { score: es, hold: eh } = computeScore(bars, i)
        if (es * pos.dir >= EXT) {
          const proposed = Math.min(i + Math.max(MIN_H, Math.min(MAX_H, eh)), pos.hardCap)
          if (proposed > pos.deadline) pos.deadline = proposed
        }
      }

      if (i >= pos.deadline || i >= pos.hardCap) {
        const grossBps = pos.dir * (bars[i].c - pos.entryPx) / pos.entryPx * 10000
        const netBps = grossBps - cfg.feeBps // exit fee; entry fee was booked at entry
        const pnl = pos.notional * netBps / 10000
        equity += pnl
        dailyPnl.set(dayKey(bars[i].ts), (dailyPnl.get(dayKey(bars[i].ts)) ?? 0) + pnl)
        trades++
        cooldown = i + 5
        pos = null
        continue
      }
      continue
    }

    if (i < cooldown) continue

    const { score, hold } = computeScore(bars, i)
    if (score === 0) continue
    const dir = (score > 0 ? 1 : -1) as 1 | -1
    const side = dir > 0 ? 'long' : 'short'
    const thresh = dir > 0 ? LONG_ENTRY_THRESH : SHORT_ENTRY_THRESH
    if (Math.abs(score) < thresh) continue

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

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

    const stopKey = cfg.stopCapMode === 'total' ? `${dkey}_all` : `${dkey}_${side}`
    if ((stopCounts.get(stopKey) ?? 0) >= cfg.stopCap) continue

    const target = compositeLiquidityLevel(bars, i, dir)
    const distBps = Math.abs(bars[i].c - target) / bars[i].c * 10000
    let entryPx = bars[i].c
    let entryBar = i
    let filled = distBps <= 10
    if (!filled) {
      for (let j = i + 1; j <= Math.min(endIdx - 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, MIN_SOFT_SL_BPS)
    const slBps = Math.abs(target - slL2) / target * 10000
    const isStructL2 = slBps >= 15 && slBps <= 200
    const softSlBps = isStructL2 ? slBps : 100
    const softSlPx = isStructL2 ? slL2 : (dir > 0 ? target * 0.99 : target * 1.01)
    const l3ref = dir > 0 ? softSlPx - 0.01 : softSlPx + 0.01
    const slL3 = stopLiquidityLevel(bars, i, dir, l3ref)
    const l3Gap = Math.max(10, Math.min(100, Math.abs(softSlPx - slL3) / softSlPx * 10000))
    const sizingBps = isStructL2 ? softSlBps + l3Gap : 125
    const hardSlBps = isStructL2 ? softSlBps + l3Gap + 8 : 133
    const hardSlPx = isStructL2
      ? (dir > 0 ? softSlPx * (1 - l3Gap / 10000) : softSlPx * (1 + l3Gap / 10000))
      : (dir > 0 ? target * (1 - 1.25 / 100) : target * (1 + 1.25 / 100))

    const riskUsd = riskBaseEquity(cfg, initial, equity) * cfg.riskPct
    if (riskUsd <= 0 || equity <= 0) return failResult(i, 'equity_depleted')
    const notional = riskUsd / (sizingBps / 10000)
    const margin = notional / cfg.leverage
    const notionalMultiple = notional / initial
    maxNotionalMultipleSeen = Math.max(maxNotionalMultipleSeen, notionalMultiple)
    if (cfg.maxNotionalMultiple !== undefined && notional > initial * cfg.maxNotionalMultiple) notionalViolations++
    if (cfg.maxMarginPct !== undefined && margin > initial * cfg.maxMarginPct) marginViolations++

    // Entry fee booked immediately. This is conservative for drawdown barriers.
    const entryFee = notional * cfg.feeBps / 10000
    equity -= entryFee
    dailyPnl.set(dayKey(bars[entryBar].ts), (dailyPnl.get(dayKey(bars[entryBar].ts)) ?? 0) - entryFee)
    tradingDays.add(dayKey(bars[entryBar].ts))

    pos = {
      dir, entryPx, entryBar, signalBar: i, entryDay: dayKey(bars[entryBar].ts),
      notional, margin, softSlPx, hardSlPx, softSlBps, hardSlBps, sizingBps,
      deadline: i + Math.max(MIN_H, Math.min(MAX_H, hold)),
      hardCap: i + CAP,
    }
  }

  return passOrIncomplete(endIdx, 'incomplete', targetTouched ? 'target_touched_but_ineligible_or_no_pass' : 'max_days_or_data_end')
}

// ─────────────────────────────────────────────────────────────
// Starts and reporting
// ─────────────────────────────────────────────────────────────
function startIndices(bars: Bar[], mode: StartMode, from = '2022-04-01'): number[] {
  const starts: number[] = []
  let prev = ''
  for (let i = 20160; i < bars.length - WAIT - 2; i++) {
    const iso = new Date(bars[i].ts).toISOString()
    if (iso.slice(0, 10) < from) continue
    let key: string
    if (mode === 'monthly') key = iso.slice(0, 7)
    else if (mode === 'daily') key = iso.slice(0, 10)
    else {
      const d = new Date(bars[i].ts)
      // Monday UTC weekly starts only.
      if (d.getUTCDay() !== 1 || d.getUTCHours() !== 0 || d.getUTCMinutes() !== 0) continue
      key = iso.slice(0, 10)
    }
    if (key !== prev) { starts.push(i); prev = key }
  }
  return starts
}

function quantile(v: number[], q: number): number {
  if (v.length === 0) return 0
  const s = [...v].sort((a, b) => a - b)
  const idx = Math.min(s.length - 1, Math.max(0, Math.floor(q * (s.length - 1))))
  return s[idx]
}

function summarize(label: string, results: AttemptResult[]): Summary {
  const n = results.length
  const pass = results.filter(r => r.status === 'pass').length
  const fail = results.filter(r => r.status === 'fail').length
  const incomplete = results.filter(r => r.status === 'incomplete').length
  const targetTouchedButIneligible = results.filter(r => r.targetTouchedButIneligible && r.status !== 'pass').length
  const passDays = results.filter(r => r.status === 'pass').map(r => r.days)
  const rets = results.map(r => r.returnPct)
  const reasons: Record<string, number> = {}
  for (const r of results) reasons[r.reason] = (reasons[r.reason] ?? 0) + 1
  return {
    label, n, pass, fail, incomplete, targetTouchedButIneligible,
    passRate: n ? pass / n : 0, failRate: n ? fail / n : 0,
    avgDaysToPass: passDays.length ? passDays.reduce((a, b) => a + b, 0) / passDays.length : 0,
    avgRet: rets.reduce((a, b) => a + b, 0) / Math.max(1, rets.length),
    medianRet: quantile(rets, 0.5), avgTrades: results.reduce((a, r) => a + r.trades, 0) / Math.max(1, n),
    avgMaxDd: results.reduce((a, r) => a + r.maxOverallDdPct, 0) / Math.max(1, n),
    avgMaxDailyDd: results.reduce((a, r) => a + r.maxDailyDdPct, 0) / Math.max(1, n),
    p10Ret: quantile(rets, 0.1), p90Ret: quantile(rets, 0.9), reasons,
  }
}

function printSummary(s: Summary): void {
  console.log(`\n${s.label}`)
  console.log('─'.repeat(s.label.length))
  console.log(`runs=${s.n}  pass=${s.pass} (${pct(s.passRate)})  fail=${s.fail} (${pct(s.failRate)})  incomplete=${s.incomplete}`)
  console.log(`avgDaysToPass=${s.avgDaysToPass ? s.avgDaysToPass.toFixed(1) : 'n/a'}  targetTouchedButIneligible=${s.targetTouchedButIneligible}`)
  console.log(`return avg=${pct(s.avgRet)}  median=${pct(s.medianRet)}  p10=${pct(s.p10Ret)}  p90=${pct(s.p90Ret)}`)
  console.log(`avgTrades=${s.avgTrades.toFixed(1)}  avgMaxOverallDD=${pct(s.avgMaxDd)}  avgMaxDailyDD=${pct(s.avgMaxDailyDd)}`)
  console.log(`reasons: ${Object.entries(s.reasons).sort((a,b)=>b[1]-a[1]).map(([k,v]) => `${k}=${v}`).join(', ')}`)
}

function configFor(kind: ChallengeKind, ddType: DrawdownType, riskPct: number, overrides: Partial<HyroConfig> = {}): HyroConfig {
  const base: HyroConfig = {
    kind, accountSize: 100_000, riskPct, feeBps: 2,
    targetPct: kind === 'two-step-2' ? 0.05 : kind === 'two-step-1' ? 0.08 : kind === 'funded' ? Infinity : 0.10,
    dailyDdPct: ddType === 'swing' ? (kind === 'one-step' ? 0.04 : 0.05) : (kind === 'one-step' ? 0.04 : 0.05),
    ddType,
    maxLossPct: 0.06,
    minTradingDays: kind === 'funded' ? 0 : 10,
    profitDistributionPct: 0.40,
    enforceProfitDistribution: kind !== 'funded',
    stopCap: 1,
    stopCapMode: 'total',
    slCooldownBars: 30,
    riskBase: 'initial',
    intrabarMode: 'ohlc-conservative',
    maxCalendarDays: 90,
    leverage: 100,
    // Challenge pages say funded-account exposure limits apply only after passing.
    maxNotionalMultiple: kind === 'funded' ? 2 : undefined,
    maxMarginPct: kind === 'funded' ? 0.25 : undefined,
  }
  return { ...base, ...overrides }
}

function parseArgs(): { risks: number[]; starts: StartMode; maxDays: number; kind: ChallengeKind; account: number; noPdr: boolean; intrabar: IntrabarMode } {
  const args = process.argv.slice(2)
  const get = (name: string, dflt: string): string => {
    const p = args.find(a => a === `--${name}` || a.startsWith(`--${name}=`))
    if (!p) return dflt
    if (p.includes('=')) return p.split('=').slice(1).join('=')
    const idx = args.indexOf(p)
    return args[idx + 1] ?? dflt
  }
  const risks = get('risks', '0.005,0.01,0.015,0.02,0.025,0.03').split(',').map(Number).filter(Number.isFinite)
  const starts = get('starts', 'monthly') as StartMode
  const maxDays = Number(get('max-days', '90'))
  const kind = get('kind', 'one-step') as ChallengeKind
  const account = Number(get('account', '100000'))
  const intrabar = get('intrabar', 'ohlc-conservative') as IntrabarMode
  const noPdr = args.includes('--no-pdr')
  return { risks, starts, maxDays, kind, account, noPdr, intrabar }
}

// ─────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────
;(async () => {
  const opts = parseArgs()
  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',
  ])
  if (bars.length === 0) throw new Error('No kline data found')
  console.log(`${bars.length.toLocaleString()} bars (${dayKey(bars[0].ts)} → ${dayKey(bars[bars.length - 1].ts)})`)
  console.log(`starts=${opts.starts}  kind=${opts.kind}  account=${money(opts.account)}  maxDays=${opts.maxDays}  intrabar=${opts.intrabar}  PDR=${opts.noPdr ? 'off' : 'on'}`)

  const starts = startIndices(bars, opts.starts)
  console.log(`attempt starts: ${starts.length}`)

  for (const ddType of ['trailing', 'swing'] as DrawdownType[]) {
    console.log(`\n╔${'═'.repeat(70)}╗`)
    console.log(`║ ${`Hyro ${opts.kind} — ${ddType.toUpperCase()} daily DD`.padEnd(68)} ║`)
    console.log(`╚${'═'.repeat(70)}╝`)
    for (const risk of opts.risks) {
      const cfg = configFor(opts.kind, ddType, risk, {
        accountSize: opts.account,
        maxCalendarDays: opts.maxDays,
        enforceProfitDistribution: !opts.noPdr && opts.kind !== 'funded',
        intrabarMode: opts.intrabar,
      })
      const results = starts.map(s => simulateAttempt(bars, s, cfg))
      const summary = summarize(`risk=${(risk*100).toFixed(2)}%  stopCap=${cfg.stopCap}/${cfg.stopCapMode}  riskBase=${cfg.riskBase}`, results)
      printSummary(summary)

      const notionalBad = results.reduce((a, r) => a + r.notionalViolations, 0)
      const maxMult = Math.max(...results.map(r => r.maxNotionalMultiple), 0)
      if (cfg.maxNotionalMultiple !== undefined || maxMult > 2) {
        console.log(`exposure: maxNotional=${maxMult.toFixed(2)}x initial  violations=${notionalBad}`)
      } else {
        console.log(`exposure: maxNotional=${maxMult.toFixed(2)}x initial`)
      }
    }
  }

  console.log('\nNotes:')
  console.log('- Daily and overall drawdown barriers include floating P&L.')
  console.log('- ohlc-conservative mode assumes each 1m bar can print favourable peak and adverse trough; use --intrabar=close for less conservative checks.')
  console.log('- Profit distribution is evaluated at pass eligibility: positive daily profit max must be ≤40% of total positive daily profit.')
  console.log('- Challenge exposure caps are not enforced by default because Hyro states 2x notional / 25% margin caps apply only on funded accounts; use --kind=funded to audit them.')
})().catch(e => { console.error(e); process.exit(1) })
