// ─── Shared strategy: constants, types, signal, targets, sizing, and the engine ───
// Single source of truth. Callers inject data and execute fills — nothing else.

// ─── Constants ───
export const STARTING_CAPITAL = 500
export const LEVERAGE = 100
export const TAKER_FEE_BPS = 5.5
export const MAKER_FEE_BPS = 2.0
export const MAINTENANCE_MARGIN_RATE = 0.005
export const RISK_PER_TRADE_PCT = 0.015
export const MAX_NOTIONAL_PCT_OF_EQUITY = 0.5
export const MAX_CONSECUTIVE_SAME_DIR = 2
export const DIRECTION_COOLDOWN_MS = 300_000
export const DEAD_RANGE_BPS = 10
export const MAX_RV_BPS = 7

export function round(v: number, d = 2): number { const f = 10 ** d; return Math.round(v * f) / f }
export function formatUsd(v: number): string { return `${v >= 0 ? '+' : ''}$${v.toFixed(2)}` }

// ─── Profile ───
export interface Profile {
  name: string
  entryCooldownMs: number
  lossCooldownMs: number
  maxHoldMs: number
  trailActivationBps: number
  trailDistPct: number
  timeExitMinProfitBps: number
  coinbaseWeight: number
  entryFeeBps: number
  exitFeeBps: number
  fillProb: number
  maxChase: number
  chaseDelay: number
  minRealizedVolBps: number
  maxRealizedVolBps: number
  minVolPerBar: number
  sizingMode: 'flat' | 'conviction'
  maxTopShare?: number  // concentration gate override; default 0.65; set to 1.0 to disable for historical replay
}

export const PROFILES = {
  TAKER: {
    name: 'TAKER', entryCooldownMs: 300_000, lossCooldownMs: 600_000,
    maxHoldMs: 1_800_000, trailActivationBps: 999, trailDistPct: 0.4,
    timeExitMinProfitBps: 3, coinbaseWeight: 0.85,
    entryFeeBps: TAKER_FEE_BPS, exitFeeBps: TAKER_FEE_BPS,
    fillProb: 1.0, maxChase: 1, chaseDelay: 0,
    minRealizedVolBps: 0, minVolPerBar: 0, maxRealizedVolBps: 999, sizingMode: 'flat' as const
  },
  MAKER_OPT: {
    name: 'MAKER_OPT', entryCooldownMs: 300_000, lossCooldownMs: 600_000,
    maxHoldMs: 120_000, trailActivationBps: 5, trailDistPct: 0.30,
    timeExitMinProfitBps: 2, coinbaseWeight: 1.2,
    entryFeeBps: MAKER_FEE_BPS, exitFeeBps: MAKER_FEE_BPS,
    fillProb: 0.35, maxChase: 3, chaseDelay: 2,
    minRealizedVolBps: 5, minVolPerBar: 500_000, maxRealizedVolBps: MAX_RV_BPS, sizingMode: 'conviction' as const
  },
  MAKER_FAST: {
    name: 'MAKER_FAST', entryCooldownMs: 120_000, lossCooldownMs: 300_000,
    maxHoldMs: 90_000, trailActivationBps: 3, trailDistPct: 0.35,
    timeExitMinProfitBps: 1, coinbaseWeight: 1.2,
    entryFeeBps: MAKER_FEE_BPS, exitFeeBps: TAKER_FEE_BPS,
    fillProb: 0.35, maxChase: 3, chaseDelay: 2,
    minRealizedVolBps: 5, minVolPerBar: 500_000, maxRealizedVolBps: MAX_RV_BPS, sizingMode: 'conviction' as const
  }
} as const satisfies Record<string, Profile>

// ─── 10-second bar ───
export interface Bar10s {
  o: number; h: number; l: number; c: number
  buyVol: number; sellVol: number; n: number
  exVol: Record<string, number>
}

export class BarAggregator {
  private bars: Bar10s[] = []
  private currentKey = -1
  private current: Bar10s | null = null
  ingest(price: number, side: 'buy' | 'sell', notionalUsd: number, exchange: string, ts: number): void {
    const key = Math.floor(ts / 10_000)
    if (key !== this.currentKey) {
      if (this.current) this.bars.push(this.current)
      this.currentKey = key
      this.current = { o: price, h: price, l: price, c: price, buyVol: 0, sellVol: 0, n: 0, exVol: {} }
    }
    const b = this.current!
    b.h = Math.max(b.h, price); b.l = Math.min(b.l, price); b.c = price
    if (side === 'buy') b.buyVol += notionalUsd; else b.sellVol += notionalUsd
    b.n++; b.exVol[exchange] = (b.exVol[exchange] || 0) + notionalUsd
  }
  getBars(): Bar10s[] { return this.bars }
  getBarCount(): number { return this.bars.length }
}

export class ObRangeTracker {
  private readonly window: { ts: number; mid: number }[] = []
  constructor(private readonly windowMs = 300_000) {}
  update(mid: number, ts: number): void {
    this.window.push({ ts, mid })
    while (this.window.length > 0 && this.window[0].ts < ts - this.windowMs) this.window.shift()
  }
  getRangeBps(): number {
    if (this.window.length < 2) return 0
    let high = 0, low = Infinity
    for (const s of this.window) { if (s.mid > high) high = s.mid; if (s.mid < low) low = s.mid }
    const mid = (high + low) / 2
    return mid > 0 ? round(((high - low) / mid) * 10_000, 2) : 0
  }
}

// ─── Signal ───
export interface StrategySignal {
  score: number; delta: number; moveBps: number; fastVol: number
  topShare: number; surge: number; absorption: boolean; lastPrice: number
}

export function computeSignal(bars: Bar10s[], idx: number, coinbaseWeight: number): StrategySignal | null {
  const fastN = 5, slowN = 60
  const fast = bars.slice(Math.max(0, idx - fastN + 1), idx + 1)
  const slow = bars.slice(Math.max(0, idx - slowN + 1), idx + 1)
  if (fast.length < 3 || slow.length < 10) return null
  const fBuy = fast.reduce((s, b) => s + b.buyVol, 0)
  const fSell = fast.reduce((s, b) => s + b.sellVol, 0)
  const fTot = fBuy + fSell
  const fDelta = fTot > 0 ? (fBuy - fSell) / fTot : 0
  const fFirst = fast[0].o, fLast = fast[fast.length - 1].c
  const fMove = fFirst > 0 ? ((fLast - fFirst) / fFirst) * 10_000 : 0
  const slowExVol: Record<string, number> = {}
  for (const b of slow) for (const [ex, vol] of Object.entries(b.exVol)) {
    slowExVol[ex] = (slowExVol[ex] || 0) + vol * (ex === 'COINBASE' ? coinbaseWeight : 1.0)
  }
  const sTot = Object.values(slowExVol).reduce((a, b) => a + b, 0)
  const sTop = Object.entries(slowExVol).sort((a, b) => b[1] - a[1])
  const topShare = sTot > 0 && sTop.length > 0 ? sTop[0][1] / sTot : 0
  const avgVol = bars.slice(Math.max(0, idx - 20), idx).reduce((s, b) => s + b.buyVol + b.sellVol, 0) / Math.min(20, Math.max(1, idx))
  const surge = avgVol > 0 ? fTot / fastN / avgVol : 1
  const dScore = Math.min(1, Math.max(-1, fDelta / 0.35))
  const pScore = Math.min(1, Math.max(-1, fMove / 12))
  const score = dScore * 0.45 + pScore * 0.35
  const absorption = fDelta * fMove < 0 && Math.abs(fDelta) > 0.25 && Math.abs(fMove) > 0.5 && surge > 2
  return { score, delta: fDelta, moveBps: fMove, fastVol: fTot, topShare, surge, absorption, lastPrice: fLast }
}

export function shouldEnter(sig: StrategySignal, maxTopShare = 0.65): 'long' | 'short' | null {
  if (sig.topShare > maxTopShare) return null
  if (sig.absorption && Math.abs(sig.delta) > 0.30 && sig.surge > 2.5 && sig.fastVol > 80_000) return sig.delta > 0 ? 'long' : 'short'
  if (Math.abs(sig.score) > 0.55 && Math.abs(sig.delta) > 0.40 && sig.fastVol > 100_000 && sig.surge > 3 && sig.delta * sig.moveBps > 0) return sig.score > 0 ? 'long' : 'short'
  return null
}

// ─── Targets, sizing, rv ───
export interface AdaptiveTargets { stopBps: number; tp1Bps: number; tp2Bps: number; regime: 'normal' | 'volatile' }

export function computeAdaptiveTargets(rangeBps: number, feeRtBps: number): AdaptiveTargets | null {
  if (rangeBps < DEAD_RANGE_BPS) return null
  const regime: 'normal' | 'volatile' = rangeBps < 30 ? 'normal' : 'volatile'
  const baseStop = Math.max(20, rangeBps * 0.4)
  const stopBps = round(Math.min(baseStop, rangeBps * 0.6), 1)
  const tp1Mult = regime === 'normal' ? 0.35 : 0.40
  const tp2Mult = regime === 'normal' ? 0.65 : 0.75
  const minTp1 = (stopBps + feeRtBps) * 1.5 + feeRtBps
  const tp1Bps = round(Math.max(minTp1, Math.min(rangeBps * tp1Mult, rangeBps * 0.7)), 1)
  const tp2Bps = round(Math.max(tp1Bps * 1.5, Math.min(rangeBps * tp2Mult, rangeBps * 0.95)), 1)
  return { stopBps, tp1Bps, tp2Bps, regime }
}

export function computeConvictionMultiplier(score: number, regime: string, absorption: boolean, wins: number, losses: number): number {
  let m = 1.0; const a = Math.abs(score)
  if (a >= 0.80) m *= 1.5; else if (a >= 0.70) m *= 1.25; else if (a < 0.60) m *= 0.75
  if (regime === 'volatile') m *= 1.25; if (absorption) m *= 1.2
  if (wins >= 2) m *= 1.15; if (losses >= 1) m *= 0.75; if (losses >= 2) m *= 0.5
  return Math.max(0.25, Math.min(m, 3.0))
}

export function computePositionSize(equity: number, stopBps: number, price: number, feeRtBps: number, convMult = 1.0) {
  const riskPct = RISK_PER_TRADE_PCT * Math.min(convMult, 3.0)
  const maxRisk = equity * riskPct
  const maxNotional = Math.min(equity * LEVERAGE, equity * MAX_NOTIONAL_PCT_OF_EQUITY * LEVERAGE)
  const netRiskPerBtc = price * (stopBps / 10_000) + price * (feeRtBps / 10_000)
  if (netRiskPerBtc <= 0) return { sizeBtc: 0, notionalUsd: 0, riskUsd: 0 }
  const sizeBtc = Math.max(0.001, round(Math.min(maxRisk / netRiskPerBtc, maxNotional / price), 3))
  return { sizeBtc, notionalUsd: round(sizeBtc * price), riskUsd: round(sizeBtc * netRiskPerBtc) }
}

export function computeRealizedVolFromBars(bars: Bar10s[], endIdx: number, lookback = 180): number {
  const slice = bars.slice(Math.max(0, endIdx - lookback), endIdx + 1)
  const rets: number[] = []
  for (let j = 1; j < slice.length; j++) {
    if (slice[j - 1].c > 0) rets.push(((slice[j].c - slice[j - 1].c) / slice[j - 1].c) * 10_000)
  }
  return rets.length > 2 ? Math.sqrt(rets.reduce((s, r) => s + r * r, 0) / rets.length) : 0
}

export function computeAc1FromBars(bars: Bar10s[], endIdx: number, lookback = 30): number {
  const rets: number[] = []
  for (let j = Math.max(1, endIdx - lookback); j <= endIdx; j++) {
    if (bars[j - 1].c > 0) rets.push((bars[j].c - bars[j - 1].c) / bars[j - 1].c * 10_000)
  }
  if (rets.length < 6) return 0
  const n = rets.length, mean = rets.reduce((a, b) => a + b, 0) / n
  let num = 0, den = 0
  for (let j = 0; j < n; j++) den += (rets[j] - mean) ** 2
  for (let j = 1; j < n; j++) num += (rets[j] - mean) * (rets[j - 1] - mean)
  return den > 0 ? num / den : 0
}

export function computeAvgVolPerBar(bars: Bar10s[], endIdx: number, lookback = 180): number {
  const slice = bars.slice(Math.max(0, endIdx - lookback), endIdx + 1)
  return slice.reduce((s, b) => s + b.buyVol + b.sellVol, 0) / Math.max(1, slice.length)
}

// ─── Exit intents ───
export interface ExitIntent { posId: number; reason: 'trail' | 'time' | 'force_close' | 'tp1' | 'sl'; exitPrice: number }

export interface TrackablePosition {
  id: number; side: 'long' | 'short'; entryPrice: number; sizeBtc: number
  openedAt: number; stopPrice: number; tp1Price: number; bestBps: number
}

export function checkExits(positions: TrackablePosition[], mid: number, ts: number, profile: Profile): ExitIntent[] {
  const intents: ExitIntent[] = []
  for (const pos of positions) {
    const uBps = pos.side === 'long'
      ? ((mid - pos.entryPrice) / pos.entryPrice) * 10_000
      : ((pos.entryPrice - mid) / pos.entryPrice) * 10_000
    if (uBps > pos.bestBps) pos.bestBps = uBps
    const holdMs = ts - pos.openedAt
    const huntWindowMs = 60_000 // no fixed SL during first 60s (stop-hunt survival)

    // Trail: active anytime once we have enough favorable excursion
    if (pos.bestBps > profile.trailActivationBps) {
      const trail = pos.bestBps * (1 - profile.trailDistPct)
      // Fire when pullback from best exceeds trail distance AND still in profit vs entry
      if (uBps < trail && uBps > 0) {
        const exitPrice = pos.side === 'long'
          ? pos.entryPrice * (1 + trail / 10_000)
          : pos.entryPrice * (1 - trail / 10_000)
        intents.push({ posId: pos.id, reason: 'trail', exitPrice }); continue
      }
    }

    // Time exit: after max hold, exit if in small profit but NOT if winning big (let trail handle)
    if (holdMs > profile.maxHoldMs && uBps > profile.timeExitMinProfitBps && uBps < 10) { intents.push({ posId: pos.id, reason: 'time', exitPrice: mid }); continue }
    // Force close: at 1.5x max hold, exit regardless (cut losers faster)
    if (holdMs > profile.maxHoldMs * 1.5) { intents.push({ posId: pos.id, reason: 'force_close', exitPrice: mid }); continue }

    // TP1: take profit at target
    if (pos.side === 'long' && mid >= pos.tp1Price) { intents.push({ posId: pos.id, reason: 'tp1', exitPrice: pos.tp1Price }); continue }
    if (pos.side === 'short' && mid <= pos.tp1Price) { intents.push({ posId: pos.id, reason: 'tp1', exitPrice: pos.tp1Price }); continue }

    // Fixed SL: only active AFTER the hunt window (first 60s)
    // This is a catastrophic backstop, not the primary exit mechanism
    if (holdMs > huntWindowMs) {
      if (pos.side === 'long' && mid <= pos.stopPrice) { intents.push({ posId: pos.id, reason: 'sl', exitPrice: pos.stopPrice }); continue }
      if (pos.side === 'short' && mid >= pos.stopPrice) { intents.push({ posId: pos.id, reason: 'sl', exitPrice: pos.stopPrice }); continue }
    }
  }
  return intents
}

// ═══════════════════════════════════════════════════════════════════
//  StrategyEngine — single execution path for replay/demo/live
// ═══════════════════════════════════════════════════════════════════

export interface EntryRequest {
  type: 'entry'
  side: 'long' | 'short'
  sizeBtc: number
  refPrice: number
  stopPrice: number
  tp1Price: number
  regime: string
  score: number
  conviction: number
  absorption: boolean
  riskUsd: number
  rv: number
  rangeBps: number
}

export interface ExitRequest {
  type: 'exit'
  positionId: number
  side: 'long' | 'short'
  sizeBtc: number
  reason: 'trail' | 'time' | 'force_close' | 'tp1' | 'sl'
  targetPrice: number
}

export type StrategyEvent = EntryRequest | ExitRequest

interface EnginePosition extends TrackablePosition {
  style: string
  score: number
  entryFee: number
  exitPending: boolean
}

export interface EngineTrade {
  id: number; side: 'long' | 'short'
  entryPrice: number; exitPrice: number; sizeBtc: number
  gross: number; fees: number; net: number
  reason: string; dur: number; style: string; score: number
}

export interface EngineState {
  capital: number; peak: number; maxDd: number; maxDdPct: number
  fees: number; rpnl: number
  positions: readonly EnginePosition[]
  closed: readonly EngineTrade[]
}

export class StrategyEngine {
  private profile: Profile
  private capital: number
  private peak: number
  private maxDd = 0
  private maxDdPct = 0
  private fees = 0
  private rpnl = 0
  private positions: EnginePosition[] = []
  private closedTrades: EngineTrade[] = []
  private nextId = 1

  // Cooldown/streak state
  private lastEntryAt = 0
  private lastLossAt = 0
  private streakWins = 0
  private streakLosses = 0
  private consLossSide: string | null = null
  private consLossCount = 0
  private dirCdSide: string | null = null
  private dirCdUntil = 0

  // Data state
  private barAgg = new BarAggregator()
  private obRange = new ObRangeTracker(300_000)
  private preloadedBars: Bar10s[] | null = null
  private barIdx = -1
  private lastBarCount = 0
  private entryPending = false

  constructor(profile: Profile, startingCapital = STARTING_CAPITAL) {
    this.profile = profile
    this.capital = startingCapital
    this.peak = startingCapital
  }

  // ── Data injection ──

  /** Live mode: feed individual trades into the bar aggregator */
  ingestTrade(price: number, side: 'buy' | 'sell', notionalUsd: number, exchange: string, ts: number): void {
    this.barAgg.ingest(price, side, notionalUsd, exchange, ts)
  }

  /** Replay mode: set pre-loaded bars (skips BarAggregator) */
  setBars(bars: Bar10s[]): void { this.preloadedBars = bars }

  /** Replay mode: advance the bar index before each tick() */
  setBarIndex(idx: number): void { this.barIdx = idx; this.lastBarCount = idx }

  /** Update OB range (call on every OB update in live, or per-bar in replay) */
  updateRange(mid: number, ts: number): void { this.obRange.update(mid, ts) }

  // ── Main tick ──

  /** Process one OB snapshot. Returns entry/exit requests for the caller to fill. */
  tick(mid: number, bid: number, ask: number, ts: number): StrategyEvent[] {
    const events: StrategyEvent[] = []

    // 1. Check exits on existing positions
    const activePositions = this.positions.filter(p => !p.exitPending)
    if (activePositions.length > 0) {
      const intents = checkExits(activePositions, mid, ts, this.profile)
      for (const intent of intents) {
        const pos = this.positions.find(p => p.id === intent.posId)
        if (!pos) continue
        pos.exitPending = true
        events.push({ type: 'exit', positionId: pos.id, side: pos.side, sizeBtc: pos.sizeBtc, reason: intent.reason, targetPrice: intent.exitPrice })
      }
    }

    // 2. Check for new entry (only when no position and no pending)
    if (this.positions.length > 0 || this.entryPending) return events

    const bars = this.preloadedBars ?? this.barAgg.getBars()
    const barCount = this.preloadedBars ? this.barIdx + 1 : this.barAgg.getBarCount()
    if (barCount <= this.lastBarCount) return events
    this.lastBarCount = barCount
    const currentIdx = this.preloadedBars ? this.barIdx : bars.length - 1
    if (currentIdx < 60) return events

    // Cooldowns
    if (ts - this.lastEntryAt < this.profile.entryCooldownMs) return events
    if (this.profile.lossCooldownMs > 0 && ts - this.lastLossAt < this.profile.lossCooldownMs && this.lastLossAt > 0) return events

    // Signal
    const sig = computeSignal(bars, currentIdx, this.profile.coinbaseWeight)
    if (!sig) return events
    const direction = shouldEnter(sig, this.profile.maxTopShare ?? 0.65)
    if (!direction) return events
    if (this.dirCdSide === direction && ts < this.dirCdUntil) return events

    // rv gate
    const rv = computeRealizedVolFromBars(bars, currentIdx)
    // rv gate: primary (rv 5-7) or secondary (rv 3-5 with trend + MOM only)
    const rvOk = rv >= this.profile.minRealizedVolBps && rv <= this.profile.maxRealizedVolBps
    if (!rvOk) {
      // Secondary entry: rv 3-5, MOM signals only, requires 30-min trend > 20bps
      const inSecondaryRvBand = rv >= 3 && rv < this.profile.minRealizedVolBps
      if (!inSecondaryRvBand || sig.absorption) return events
      // Check 30-min trend (180 bars at 10s each)
      const trendLookback = Math.min(180, currentIdx)
      const trendBps = trendLookback > 10
        ? Math.abs((bars[currentIdx].c - bars[currentIdx - trendLookback].c) / bars[currentIdx - trendLookback].c * 10_000)
        : 0
      if (trendBps < 20) return events
    }

    // Volume gate
    const avgVol = computeAvgVolPerBar(bars, currentIdx)
    if (avgVol < this.profile.minVolPerBar) return events

    // Targets
    // Size conservatively: assume taker exit fee (SL exits are always taker)
    const feeRt = this.profile.entryFeeBps + TAKER_FEE_BPS
    const rangeBps = this.obRange.getRangeBps()
    const targets = computeAdaptiveTargets(rangeBps, feeRt)
    if (!targets) return events

    const refPrice = direction === 'long' ? (bid || sig.lastPrice) : (ask || sig.lastPrice)
    const conv = this.profile.sizingMode === 'conviction'
      ? computeConvictionMultiplier(sig.score, targets.regime, sig.absorption, this.streakWins, this.streakLosses) : 1.0
    const sizing = computePositionSize(this.capital, targets.stopBps, refPrice, feeRt, conv)
    if (sizing.sizeBtc < 0.001 || sizing.riskUsd > this.capital * 0.03) return events

    // Margin check
    const usedMargin = this.positions.reduce((s, p) => s + p.entryPrice * p.sizeBtc / LEVERAGE, 0)
    if (this.capital - usedMargin < refPrice * sizing.sizeBtc / LEVERAGE) return events

    const stop = direction === 'long' ? round(refPrice * (1 - targets.stopBps / 10_000), 1) : round(refPrice * (1 + targets.stopBps / 10_000), 1)
    const tp1 = direction === 'long' ? round(refPrice * (1 + targets.tp1Bps / 10_000), 1) : round(refPrice * (1 - targets.tp1Bps / 10_000), 1)

    this.entryPending = true
    events.push({
      type: 'entry', side: direction, sizeBtc: sizing.sizeBtc, refPrice,
      stopPrice: stop, tp1Price: tp1, regime: targets.regime, score: sig.score,
      conviction: conv, absorption: sig.absorption, riskUsd: sizing.riskUsd, rv, rangeBps
    })
    return events
  }

  // ── Fill callbacks ──

  confirmEntry(evt: EntryRequest, fillPrice: number, fillQty: number, slippageBps: number, ts: number): void {
    const fee = round(fillPrice * fillQty * (this.profile.entryFeeBps / 10_000))
    this.capital -= fee; this.fees += fee
    this.positions.push({
      id: this.nextId++, side: evt.side, entryPrice: fillPrice, sizeBtc: fillQty,
      openedAt: ts, stopPrice: evt.stopPrice, tp1Price: evt.tp1Price, bestBps: 0,
      style: this.profile.name + '-' + evt.regime, score: evt.score, entryFee: fee, exitPending: false
    })
    this.lastEntryAt = ts
    this.entryPending = false
  }

  rejectEntry(_evt: EntryRequest, _ts: number): void { this.entryPending = false }

  confirmExit(evt: ExitRequest, fillPrice: number, wasTaker: boolean, ts: number): void {
    const idx = this.positions.findIndex(p => p.id === evt.positionId)
    if (idx === -1) return
    const pos = this.positions.splice(idx, 1)[0]

    // Fee model: use actual execution type rather than assuming taker for SL
    const exitFeeBps = wasTaker ? TAKER_FEE_BPS : this.profile.exitFeeBps
    const slippageEstBps = wasTaker ? 1.0 : 0
    const exitFee = round(fillPrice * pos.sizeBtc * (exitFeeBps / 10_000))
    const slippageCost = round(fillPrice * pos.sizeBtc * (slippageEstBps / 10_000))

    const gross = pos.side === 'long' ? (fillPrice - pos.entryPrice) * pos.sizeBtc : (pos.entryPrice - fillPrice) * pos.sizeBtc
    const exitNet = round(gross - exitFee - slippageCost)       // what changes capital now
    const fullNet = round(gross - pos.entryFee - exitFee - slippageCost) // full round-trip net (matches Bybit rpnl)
    this.capital += exitNet; this.fees += exitFee + slippageCost; this.rpnl += fullNet
    this.peak = Math.max(this.peak, this.capital)
    const dd = this.peak - this.capital
    if (dd > this.maxDd) { this.maxDd = dd; this.maxDdPct = this.peak > 0 ? dd / this.peak : 0 }
    this.closedTrades.push({
      id: pos.id, side: pos.side, entryPrice: pos.entryPrice, exitPrice: fillPrice,
      sizeBtc: pos.sizeBtc, gross: round(gross), fees: round(pos.entryFee + exitFee),
      net: fullNet, reason: evt.reason, dur: ts - pos.openedAt, style: pos.style, score: pos.score
    })
    // Streaks
    if (evt.reason === 'sl') {
      this.lastLossAt = ts; this.streakWins = 0; this.streakLosses++
      if (pos.side === this.consLossSide) this.consLossCount++; else { this.consLossSide = pos.side; this.consLossCount = 1 }
      if (this.consLossCount >= MAX_CONSECUTIVE_SAME_DIR) { this.dirCdSide = pos.side; this.dirCdUntil = ts + DIRECTION_COOLDOWN_MS }
    } else if (fullNet > 0) {
      this.consLossSide = null; this.consLossCount = 0; this.dirCdSide = null; this.streakWins++; this.streakLosses = 0
    }
  }

  // ── State ──

  getState(): EngineState {
    return {
      capital: round(this.capital), peak: round(this.peak),
      maxDd: round(this.maxDd), maxDdPct: round(this.maxDdPct, 4),
      fees: round(this.fees), rpnl: round(this.rpnl),
      positions: this.positions, closed: this.closedTrades
    }
  }

  getRangeBps(): number { return this.obRange.getRangeBps() }
  getBarCount(): number { return this.preloadedBars ? this.barIdx + 1 : this.barAgg.getBarCount() }

  getRv(): number {
    const bars = this.preloadedBars ?? this.barAgg.getBars()
    const idx = this.preloadedBars ? this.barIdx : bars.length - 1
    return idx >= 60 ? computeRealizedVolFromBars(bars, idx) : 0
  }
}

// ═══════════════════════════════════════════════════════════════════
//  Chase-limit fill emulation (unified for replay and live-emulated fills)
// ═══════════════════════════════════════════════════════════════════

interface ObSnapshot { ts: number; bid: number; ask: number }

/** Replay mode: look ahead in recorded OB array to emulate a maker chase limit. */
export function emulateChaseEntry(
  side: 'long' | 'short', refPrice: number,
  obs: ObSnapshot[], startIdx: number,
  maxChase: number, waitMs = 3000
): { filled: boolean; fillPrice: number; endIdx: number } {
  let idx = startIdx
  for (let attempt = 0; attempt < maxChase; attempt++) {
    if (idx >= obs.length) break
    const limitPrice = attempt === 0 ? refPrice : (side === 'long' ? obs[idx].bid : obs[idx].ask)
    if (!limitPrice) break
    const endTs = obs[idx].ts + waitMs
    while (idx < obs.length && obs[idx].ts <= endTs) {
      // Buy limit at bid fills when ask drops to our level (seller crosses to us)
      if (side === 'long' && obs[idx].ask > 0 && obs[idx].ask <= limitPrice) {
        return { filled: true, fillPrice: limitPrice, endIdx: idx }
      }
      // Sell limit at ask fills when bid rises to our level (buyer crosses to us)
      if (side === 'short' && obs[idx].bid > 0 && obs[idx].bid >= limitPrice) {
        return { filled: true, fillPrice: limitPrice, endIdx: idx }
      }
      idx++
    }
  }
  return { filled: false, fillPrice: 0, endIdx: idx }
}

/** Live-emulated mode: track a pending chase limit, check on each OB update. */
export class ChaseFillTracker {
  private pending: {
    evt: EntryRequest | ExitRequest
    side: 'long' | 'short'
    limitPrice: number
    sizeBtc: number
    attempt: number
    attemptStartTs: number
    isEntry: boolean
  } | null = null

  constructor(private maxChase: number, private waitMs = 3000) {}

  startEntry(evt: EntryRequest, bid: number, ask: number, ts: number): void {
    this.pending = {
      evt, side: evt.side, limitPrice: evt.side === 'long' ? bid : ask,
      sizeBtc: evt.sizeBtc, attempt: 0, attemptStartTs: ts, isEntry: true
    }
  }

  /** Check on each OB update. Returns fill result or null if still pending. */
  check(bid: number, ask: number, ts: number): { filled: boolean; fillPrice: number; evt: EntryRequest | ExitRequest; isEntry: boolean } | null {
    if (!this.pending) return null
    const p = this.pending

    // Check if our limit is hit
    const hit = p.side === 'long'
      ? (ask > 0 && ask <= p.limitPrice)
      : (bid > 0 && bid >= p.limitPrice)

    if (hit) {
      const result = { filled: true, fillPrice: p.limitPrice, evt: p.evt, isEntry: p.isEntry }
      this.pending = null
      return result
    }

    // Check if this attempt timed out
    if (ts - p.attemptStartTs > this.waitMs) {
      p.attempt++
      if (p.attempt >= this.maxChase) {
        const result = { filled: false, fillPrice: 0, evt: p.evt, isEntry: p.isEntry }
        this.pending = null
        return result
      }
      // Retry at new best price
      p.limitPrice = p.side === 'long' ? bid : ask
      p.attemptStartTs = ts
    }

    return null // still pending
  }

  hasPending(): boolean { return this.pending !== null }
  clear(): void { this.pending = null }
}

// ─── IPC broadcast shape ───
export interface EngineStateBroadcast {
  mode: 'replay' | 'demo' | 'live'
  startingEquity: number
  barCount: number
  serverUptimeMs: number
  demoBalanceUsd: number | null
  equity: number; unrealizedPnl: number; realizedPnl: number
  totalFees: number; openCount: number; closedCount: number
  maxDrawdownPct: number; midPrice: number | null
  rangeBps: number; realizedVol: number
  positions: Array<{ id: string; side: 'long' | 'short'; sizeBtc: number; entryPrice: number; unrealizedBps: number; stopPrice: number; tp1Price: number; holdMs: number }>
  closedTrades: Array<{ id: string; side: 'long' | 'short'; exitReason: string; entryPrice: number; exitPrice: number; sizeBtc: number; netPnlUsd: number; durationMs: number; style: string }>
}
