/**
 * CryptoEngine.js
 *
 * Realistic 24/7 cryptocurrency market simulation engine.
 *
 * Key differences from HybridEngine (stocks) and ForexEngine:
 *
 *  SESSIONS
 *   • No hard open/close — crypto trades 24/7/365
 *   • Soft activity rhythm by UTC hour:
 *     Asia (00–08 UTC): moderate vol, medium liquidity
 *     Europe (08–13 UTC): rising vol, good liquidity
 *     US overlap (13–21 UTC): peak vol and liquidity
 *     Late US / Asia pre-open (21–24 UTC): lower vol
 *   • No weekend shutdown (unlike FX)
 *
 *  VOLATILITY
 *   • Daily vol: BTC 3–5%, ETH 4–6%, large-caps 5–8%, altcoins 8–15%
 *   • GARCH persistence higher than stocks (α+β=0.96) — vol clusters persist longer
 *   • Liquidation cascade jumps: large directional moves that self-reinforce
 *   • Funding rate creates carry-like drift (can be positive or negative)
 *
 *  PRICING & PRECISION
 *   • Prices span 8 orders of magnitude ($0.000001 to $100,000+)
 *   • Tick size auto-selected by price level (6dp for <$0.01, 2dp for >$1000)
 *   • Spreads quoted as basis points (bps), not fixed ticks
 *   • BTC/ETH: ~1–3 bps spread; altcoins: 5–30 bps
 *
 *  ORDER BOOK
 *   • Sizes in base coin (BTC, ETH, etc.), not shares or lots
 *   • Depth in USD equivalent shown alongside coin amount
 *   • Exchange labeled (Binance, Coinbase, Kraken, OKX, Bybit)
 *
 *  TRADE TAPE
 *   • Volume in base coin with USD equivalent
 *   • Liquidation trades flagged separately (large, directional)
 *   • Taker/maker side tracked
 *
 *  JUMP MODEL
 *   • Jumps are NOT random per tick — they are rare, meaningful events
 *   • jumpProb is the probability of a jump STARTING (not per tick once running)
 *   • A jump unfolds over several ticks (jumpDuration), not instantly
 *   • Jump magnitude is calibrated to real crypto: BTC 0.5–1.5%, alts 1–5%
 *   • Cascade adds a second impulse in the same direction after the first settles
 *   • All jump sizes are scaled by sqrt(100 / ticksPerDay) so frequency and
 *     magnitude stay correct regardless of how often tick() is called
 */

const RNG = require("../RNG.js");

// ─────────────────────────────────────────────────────────────────────────────
// Crypto activity sessions (UTC) — soft rhythm, never closes
// [startHour, endHour, label, volMult, spreadMult, liquidityScore]
// ─────────────────────────────────────────────────────────────────────────────
const CRYPTO_SESSIONS = [
  [0.00,  4.00, "asia-early",    0.75, 1.4, 5],
  [4.00,  8.00, "asia-peak",     0.90, 1.2, 6],
  [8.00, 13.00, "europe",        1.10, 1.0, 7],
  [13.00, 17.00, "us-open",      1.50, 0.8, 10],
  [17.00, 21.00, "us-afternoon", 1.20, 0.9, 8],
  [21.00, 24.00, "us-late",      0.80, 1.2, 5],
];

function getCryptoSession(nowMs) {
  const d = new Date(nowMs);
  const t = d.getUTCHours() + d.getUTCMinutes() / 60 + d.getUTCSeconds() / 3600;
  for (const [start, end, label, volMult, spreadMult, liquidityScore] of CRYPTO_SESSIONS) {
    if (t >= start && t < end) return { label, volMult, spreadMult, liquidityScore };
  }
  return { label: "asia-early", volMult: 0.75, spreadMult: 1.4, liquidityScore: 5 };
}

// ─────────────────────────────────────────────────────────────────────────────
// Coin profile registry
// All vol / jump values are calibrated at 100 ticks/day baseline.
// createCoinState() rescales them to the actual ticksPerDay via jumpScale.
// ─────────────────────────────────────────────────────────────────────────────
const COIN_PROFILES = {
  // ── Large caps ────────────────────────────────────────────────────────────
  BTCUSDT:  { dailyVol: 0.035, spreadBps: 1.0, fundingRate:  0.0001, reversionSpeed: 0.015, jumpProbDaily: 0.8, jumpMagPct: 0.010, momentumWeight: 0.12 },
  BTCUSD:   { dailyVol: 0.035, spreadBps: 1.0, fundingRate:  0.0001, reversionSpeed: 0.015, jumpProbDaily: 0.8, jumpMagPct: 0.010, momentumWeight: 0.12 },
  ETHUSDT:  { dailyVol: 0.045, spreadBps: 1.5, fundingRate:  0.0001, reversionSpeed: 0.018, jumpProbDaily: 1.0, jumpMagPct: 0.015, momentumWeight: 0.13 },
  ETHUSD:   { dailyVol: 0.045, spreadBps: 1.5, fundingRate:  0.0001, reversionSpeed: 0.018, jumpProbDaily: 1.0, jumpMagPct: 0.015, momentumWeight: 0.13 },
  BNBUSDT:  { dailyVol: 0.050, spreadBps: 2.0, fundingRate:  0.0001, reversionSpeed: 0.020, jumpProbDaily: 1.0, jumpMagPct: 0.018, momentumWeight: 0.14 },
  SOLUSDT:  { dailyVol: 0.060, spreadBps: 2.5, fundingRate:  0.0001, reversionSpeed: 0.022, jumpProbDaily: 1.2, jumpMagPct: 0.020, momentumWeight: 0.15 },
  XRPUSDT:  { dailyVol: 0.055, spreadBps: 2.5, fundingRate:  0.0000, reversionSpeed: 0.025, jumpProbDaily: 1.2, jumpMagPct: 0.022, momentumWeight: 0.14 },
  ADAUSDT:  { dailyVol: 0.060, spreadBps: 3.0, fundingRate:  0.0000, reversionSpeed: 0.025, jumpProbDaily: 1.0, jumpMagPct: 0.022, momentumWeight: 0.14 },
  DOGEUSDT: { dailyVol: 0.075, spreadBps: 4.0, fundingRate:  0.0001, reversionSpeed: 0.030, jumpProbDaily: 1.5, jumpMagPct: 0.030, momentumWeight: 0.18 },
  AVAXUSDT: { dailyVol: 0.070, spreadBps: 3.0, fundingRate:  0.0001, reversionSpeed: 0.022, jumpProbDaily: 1.2, jumpMagPct: 0.025, momentumWeight: 0.15 },
  DOTUSDT:  { dailyVol: 0.065, spreadBps: 3.5, fundingRate:  0.0000, reversionSpeed: 0.024, jumpProbDaily: 1.0, jumpMagPct: 0.023, momentumWeight: 0.14 },
  MATICUSDT:{ dailyVol: 0.070, spreadBps: 4.0, fundingRate:  0.0001, reversionSpeed: 0.025, jumpProbDaily: 1.2, jumpMagPct: 0.025, momentumWeight: 0.15 },
  LINKUSDT: { dailyVol: 0.065, spreadBps: 3.0, fundingRate:  0.0001, reversionSpeed: 0.022, jumpProbDaily: 1.0, jumpMagPct: 0.023, momentumWeight: 0.14 },
  LTCUSDT:  { dailyVol: 0.050, spreadBps: 2.5, fundingRate:  0.0000, reversionSpeed: 0.022, jumpProbDaily: 0.8, jumpMagPct: 0.018, momentumWeight: 0.13 },
  ATOMUSDT: { dailyVol: 0.070, spreadBps: 3.5, fundingRate:  0.0001, reversionSpeed: 0.025, jumpProbDaily: 1.2, jumpMagPct: 0.025, momentumWeight: 0.15 },
  UNIUSDT:  { dailyVol: 0.070, spreadBps: 4.0, fundingRate:  0.0001, reversionSpeed: 0.025, jumpProbDaily: 1.2, jumpMagPct: 0.025, momentumWeight: 0.15 },
  NEARUSDT: { dailyVol: 0.075, spreadBps: 4.0, fundingRate:  0.0001, reversionSpeed: 0.025, jumpProbDaily: 1.2, jumpMagPct: 0.028, momentumWeight: 0.15 },
  APTUSDT:  { dailyVol: 0.080, spreadBps: 4.5, fundingRate:  0.0001, reversionSpeed: 0.028, jumpProbDaily: 1.5, jumpMagPct: 0.030, momentumWeight: 0.16 },
  ARBUSDT:  { dailyVol: 0.080, spreadBps: 4.5, fundingRate:  0.0001, reversionSpeed: 0.028, jumpProbDaily: 1.5, jumpMagPct: 0.030, momentumWeight: 0.16 },
  OPUSDT:   { dailyVol: 0.080, spreadBps: 5.0, fundingRate:  0.0001, reversionSpeed: 0.028, jumpProbDaily: 1.5, jumpMagPct: 0.030, momentumWeight: 0.16 },
  // ── Memecoins / high-vol ──────────────────────────────────────────────────
  SHIBUSDT: { dailyVol: 0.100, spreadBps: 8.0, fundingRate:  0.0002, reversionSpeed: 0.035, jumpProbDaily: 2.0, jumpMagPct: 0.045, momentumWeight: 0.22 },
  PEPEUSDT: { dailyVol: 0.120, spreadBps:10.0, fundingRate:  0.0002, reversionSpeed: 0.040, jumpProbDaily: 2.5, jumpMagPct: 0.055, momentumWeight: 0.25 },
  FLOKIUSDT:{ dailyVol: 0.110, spreadBps: 9.0, fundingRate:  0.0002, reversionSpeed: 0.038, jumpProbDaily: 2.2, jumpMagPct: 0.050, momentumWeight: 0.23 },
};

// ─────────────────────────────────────────────────────────────────────────────
// Coin profile lookup — strips quote currency, classifies unknown coins
// properly instead of using a broken length heuristic
// ─────────────────────────────────────────────────────────────────────────────

const MEMECOIN_BASES = new Set([
  "SHIB", "PEPE", "FLOKI", "DOGE", "BONK", "WIF", "MEME",
  "BOME", "DOGS", "NEIRO", "MOG", "TURBO", "BRETT", "POPCAT",
  "BABYDOGE", "SAMO", "CATE", "KISHU", "ELON",
]);

const LARGECAP_BASES = new Set([
  "BTC", "ETH", "BNB", "SOL", "XRP", "ADA", "AVAX", "DOT",
  "MATIC", "LINK", "LTC", "ATOM", "UNI", "NEAR", "APT", "ARB",
  "OP", "FIL", "ICP", "INJ", "SUI", "SEI", "TIA", "PYTH",
]);

function stripQuote(sym) {
  return sym
    .replace(/USDT$/, "")
    .replace(/USDC$/, "")
    .replace(/BUSD$/, "")
    .replace(/USD$/, "")
    .replace(/BTC$/, "")
    .replace(/ETH$/, "");
}

function getCoinProfile(symbol) {
  const sym = symbol.toUpperCase();
  if (COIN_PROFILES[sym]) return COIN_PROFILES[sym];

  const base = stripQuote(sym);

  if (MEMECOIN_BASES.has(base)) {
    return {
      dailyVol:       0.110,
      spreadBps:      9.0,
      fundingRate:    0.0002,
      reversionSpeed: 0.038,
      jumpProbDaily:  2.2,
      jumpMagPct:     0.050,
      momentumWeight: 0.23,
    };
  }

  if (LARGECAP_BASES.has(base)) {
    return {
      dailyVol:       0.050,
      spreadBps:      2.0,
      fundingRate:    0.0001,
      reversionSpeed: 0.020,
      jumpProbDaily:  1.0,
      jumpMagPct:     0.018,
      momentumWeight: 0.14,
    };
  }

  // Default: mid-cap altcoin
  return {
    dailyVol:       0.075,
    spreadBps:      4.5,
    fundingRate:    0.0001,
    reversionSpeed: 0.025,
    jumpProbDaily:  1.5,
    jumpMagPct:     0.028,
    momentumWeight: 0.16,
  };
}

// ─────────────────────────────────────────────────────────────────────────────
// Auto tick-size by price magnitude
// ─────────────────────────────────────────────────────────────────────────────
function autoTickSize(price) {
  if (price < 0.000001) return 0.0000001;
  if (price < 0.0001)   return 0.000001;
  if (price < 0.01)     return 0.000001;
  if (price < 0.10)     return 0.00001;
  if (price < 1.00)     return 0.0001;
  if (price < 10)       return 0.001;
  if (price < 100)      return 0.01;
  if (price < 1_000)    return 0.1;
  if (price < 10_000)   return 1.0;
  if (price < 100_000)  return 0.1;
  return 1.0;
}

function autoDecimals(price) {
  if (price < 0.000001) return 8;
  if (price < 0.0001)   return 7;
  if (price < 0.01)     return 6;
  if (price < 0.10)     return 5;
  if (price < 1.00)     return 4;
  if (price < 10)       return 3;
  if (price < 100)      return 2;
  if (price < 1_000)    return 2;
  if (price < 10_000)   return 1;
  return 1;
}

function roundTick(price, tickSize) {
  return Math.round(price / tickSize) * tickSize;
}

function fmtCrypto(price, decimals) {
  return parseFloat(price.toFixed(decimals));
}

// ─────────────────────────────────────────────────────────────────────────────
// Per-coin state factory
//
// ALL time-sensitive parameters are scaled by ticksPerDay so the engine
// behaves correctly whether tick() is called every 1s or every 60s.
//
// Scaling rules:
//   baseTickVol  = dailyVol / sqrt(ticksPerDay)          — standard diffusion
//   jumpProbTick = jumpProbDaily / ticksPerDay            — Poisson rate
//   jumpMagLog   = jumpMagPct * sqrt(100 / ticksPerDay)  — magnitude shrinks
//                                                            with frequency
//   jumpDuration = max(1, round(ticksPerDay / 200))      — ticks to unfold
//                  at 8640 t/d → ~43 ticks (~7 min for 10s tick)
//                  at  100 t/d →  1 tick  (instant)
// ─────────────────────────────────────────────────────────────────────────────
function createCoinState(symbol, price, opts = {}) {
  const profile      = getCoinProfile(symbol);
  const p            = Math.max(Number(price) || 1, 1e-10);
  const tickSize     = opts.tickSize  ?? autoTickSize(p);
  const decimals     = autoDecimals(p);
  const ticksPerDay  = opts.ticksPerDay ?? 100;

  // ── Volatility (diffusion) ────────────────────────────────────────────────
  const dailyVol    = opts.dailyVol ?? profile.dailyVol;
  const baseTickVol = dailyVol / Math.sqrt(ticksPerDay);

  // ── Jump calibration ──────────────────────────────────────────────────────
  //
  // jumpProbDaily: expected number of jumps per day (e.g. 1.0 = once a day).
  // Convert to per-tick probability: jumpProbTick = jumpProbDaily / ticksPerDay.
  //   At 8640 ticks/day with jumpProbDaily=1.0 → P(jump/tick) = 0.000116
  //   At 100  ticks/day with jumpProbDaily=1.0 → P(jump/tick) = 0.010
  //
  // jumpMagPct: the TOTAL log-move of the jump (e.g. 0.010 = 1% for BTC).
  // This is the full magnitude at 100 ticks/day baseline (where jump unfolds
  // in 1 tick). At higher tick frequencies the jump unfolds over jumpDuration
  // ticks, so per-tick contribution = jumpMagPct / jumpDuration.
  //
  // jumpDuration: how many ticks a jump unfolds over. Keeps the move realistic
  // regardless of tick speed — a 1% BTC move should take ~5–10 minutes, not
  // appear instantaneously on a 10s chart.
  //   At  100 ticks/day (864s/tick)  → 1 tick   (~14 min, acceptable)
  //   At 8640 ticks/day (10s/tick)   → ~43 ticks (~7 min)
  //   At 86400 ticks/day (1s/tick)   → ~432 ticks (~7 min)
  //
  const jumpProbDaily  = opts.jumpProbDaily ?? profile.jumpProbDaily ?? 1.0;
  const jumpMagPct     = opts.jumpMagPct    ?? profile.jumpMagPct    ?? 0.020;
  const jumpProbTick   = jumpProbDaily / ticksPerDay;
  const jumpDuration   = Math.max(1, Math.round(ticksPerDay / 200));
  // Per-tick log contribution while jump is unfolding
  const jumpTickMag    = jumpMagPct / jumpDuration;

  // Cascade: a second impulse in the same direction once the first settles.
  // Magnitude is 1.5–2× the original jump, also unfolded over jumpDuration ticks.
  const cascadeProb    = 0.20;
  const cascadeMagMult = 1.8;

  return {
    // ── Price state ───────────────────────────────────────────────────────
    price:       p,
    logPrice:    Math.log(p),
    fairValue:   p,
    prevLogRet:  0,

    // ── Precision ─────────────────────────────────────────────────────────
    tickSize,
    decimals,

    // ── Tick calibration (stored so updatePrice can reference it) ─────────
    ticksPerDay,
    baseTickVol,

    // ── GARCH(1,1) ────────────────────────────────────────────────────────
    // α+β=0.96 — crypto vol clusters persist longer than stocks
    garchVar:   baseTickVol * baseTickVol,
    garchOmega: 0.10,
    garchAlpha: 0.09,
    garchBeta:  0.87,

    // ── Momentum ─────────────────────────────────────────────────────────
    momentumEMA:    0,
    momentumDecay:  0.88,
    momentumWeight: profile.momentumWeight ?? 0.12,
    momentumCap:    baseTickVol * 1.5,

    // ── Mean reversion ────────────────────────────────────────────────────
    reversionSpeed: opts.reversionSpeed ?? profile.reversionSpeed,

    // ── Funding rate drift ────────────────────────────────────────────────
    fundingRate: opts.fundingRate ?? profile.fundingRate,

    // ── Jump state ────────────────────────────────────────────────────────
    // A jump is a multi-tick event. When triggered, jumpTicksLeft counts down.
    // Each tick while jumpTicksLeft > 0 applies jumpTickMag in jumpDirection.
    jumpProbTick,
    jumpTickMag,
    jumpDuration,
    jumpTicksLeft:   0,       // ticks remaining in current jump
    jumpDirection:   0,       // +1 or -1
    jumpInCascade:   false,   // true while serving the cascade phase

    cascadeProb,
    cascadeMagMult,

    // ── Spread ────────────────────────────────────────────────────────────
    spreadBps: opts.spreadBps ?? profile.spreadBps,

    // ── OHLCV accumulator ─────────────────────────────────────────────────
    open:   p, high: p, low: p, close: p,
    volume: 0,
    volumeUsd: 0,

    // ── Quote cache ───────────────────────────────────────────────────────
    bid: 0, ask: 0,
  };
}

// Exchange labels for trade tape
const EXCHANGES = ["Binance", "Coinbase", "Kraken", "OKX", "Bybit"];

// ─────────────────────────────────────────────────────────────────────────────
// CryptoEngine
// ─────────────────────────────────────────────────────────────────────────────
class CryptoEngine {
  /**
   * @param {string|string[]} symbols   e.g. ["BTCUSDT", "ETHUSDT"]
   * @param {number}          seed      RNG seed (default 42)
   * @param {object}          options
   * @param {number}          options.ticksPerDay  MUST match your tick interval.
   *   Derive as: Math.round(86400_000 / tickMs)
   *   e.g. tickMs=10000 → ticksPerDay=8640
   *   Defaulting to 100 gives massively over-scaled volatility at real tick rates.
   */
  constructor(symbols = [], seed = 42, options = {}) {
    if (!options.ticksPerDay) {
      console.warn(
        "[CryptoEngine] ticksPerDay not provided — defaulting to 100. " +
        "Pass the real value (Math.round(86400000 / tickMs)) to avoid " +
        "over-scaled volatility. e.g. tickMs=10000 → ticksPerDay=8640."
      );
    }

    this._rng  = new RNG(seed);
    this._opts = {
      ticksPerDay:   options.ticksPerDay   ?? 100,
      maxBookLevels: options.maxBookLevels ?? 12,
      maxTrades:     options.maxTrades     ?? 50,
      ...options,
    };

    this.states       = {};
    this.assets       = {};
    this.quotes       = {};
    this.orderBooks   = {};
    this.recentTrades = {};
    this.sessions     = {};

    const syms = Array.isArray(symbols) ? symbols : [symbols];
    for (const s of syms) if (s) this._registerSymbol(s);
  }

  // ─── Public API ────────────────────────────────────────────────────────────

  /**
   * Anchor the simulation to a live price.
   * Updates fair value for OU reversion.
   * Hard-corrects the sim price when it has drifted > 0.5% from live
   * (prevents the engine wandering away between polls).
   */
  updatePrice(symbol, price) {
    const p = Math.max(Number(price) || 1, 1e-10);

    if (!this.states[symbol] || !this.states[symbol]._initialised) {
      this._initSymbol(symbol, p);
      return;
    }

    const st     = this.states[symbol];
    st.fairValue = p;

    const logDrift = Math.abs(Math.log(p / Math.max(st.price, 1e-10)));
    if (logDrift > 0.005) {
      // Hard-correct to live — stale drift causes compounding errors
      st.price        = roundTick(p, st.tickSize);
      st.logPrice     = Math.log(Math.max(st.price, 1e-10));
      st.momentumEMA  = 0;
      st.prevLogRet   = 0;
      st.garchVar     = st.baseTickVol * st.baseTickVol;
      // Cancel any in-progress jump — it was calibrated on a stale price
      st.jumpTicksLeft = 0;
      st.jumpDirection = 0;
      st.jumpInCascade = false;
    }

    // Auto-update tick size if price has crossed a magnitude boundary
    const newTickSize = autoTickSize(p);
    if (newTickSize !== st.tickSize) {
      st.tickSize = newTickSize;
      st.decimals = autoDecimals(p);
    }
  }

  /**
   * Advance one tick for a symbol.
   */
  tick(symbol, opts = { drift: true, book: true, trades: true }) {
    if (!this.states[symbol] || !this.states[symbol]._initialised) {
      this._initSymbol(symbol, 1.0);
    }

    const st      = this.states[symbol];
    const nowMs   = Date.now();
    const session = getCryptoSession(nowMs);
    this.sessions[symbol] = session;

    if (opts.drift  !== false) this._evolvePrice(symbol, st, session);
    this._computeQuotes(symbol, st, session);
    if (opts.book   !== false) this._buildOrderBook(symbol, st, session);
    if (opts.trades !== false) this._generateTrades(symbol, st, session);

    this.assets[symbol] = {
      price:     fmtCrypto(st.price,     st.decimals),
      open:      fmtCrypto(st.open,      st.decimals),
      high:      fmtCrypto(st.high,      st.decimals),
      low:       fmtCrypto(st.low,       st.decimals),
      close:     fmtCrypto(st.close,     st.decimals),
      volume:    parseFloat(st.volume.toFixed(4)),
      volumeUsd: Math.round(st.volumeUsd),
    };

    return this._snapshot(symbol);
  }

  resetCandle(symbol) {
    const st = this.states[symbol];
    if (!st) return;
    st.open = st.price; st.high = st.price;
    st.low  = st.price; st.close= st.price;
    st.volume = 0; st.volumeUsd = 0;
  }

  // ─── Private: price evolution ──────────────────────────────────────────────

  _evolvePrice(symbol, st, session) {
    const rng = this._rng;

    // ── GARCH(1,1) ────────────────────────────────────────────────────────
    const longRunVar = st.baseTickVol * st.baseTickVol;
    const rawVar =
      st.garchOmega * longRunVar +
      st.garchAlpha * (st.prevLogRet * st.prevLogRet) +
      st.garchBeta  * st.garchVar;

    // Floor 25% of LR, ceiling 36× — crypto spikes hard but not infinitely
    st.garchVar = Math.min(Math.max(rawVar, 0.0625 * longRunVar), 36 * longRunVar);

    const tickVol = Math.sqrt(st.garchVar) * session.volMult;

    // ── Funding rate drift ────────────────────────────────────────────────
    const annualTicks = st.ticksPerDay * 365;
    const tickFunding = (st.fundingRate * 3 * 365) / annualTicks;
    const tickDrift   = tickFunding - 0.5 * st.garchVar;

    // ── OU mean reversion ─────────────────────────────────────────────────
    const logGap = Math.log(Math.max(st.fairValue, 1e-10)) - st.logPrice;
    const ouPull = Math.max(-0.03, Math.min(0.03, st.reversionSpeed * logGap));

    // ── Momentum ──────────────────────────────────────────────────────────
    st.momentumEMA =
      st.momentumDecay * st.momentumEMA +
      (1 - st.momentumDecay) * st.prevLogRet;
    const rawMomentum  = st.momentumWeight * st.momentumEMA;
    const momentumPull = Math.max(-st.momentumCap, Math.min(st.momentumCap, rawMomentum));

    // ── Diffusion shock (ν=3 fat tails) ───────────────────────────────────
    const shock = rng.fattailNormal(0, tickVol, 3, 7);

    // ── Jump diffusion ────────────────────────────────────────────────────
    //
    // Jumps are NOT applied as a random shock every tick.
    // Instead:
    //   1. Each tick, check if a NEW jump starts (jumpProbTick is rare).
    //   2. If one starts, set jumpTicksLeft = jumpDuration and pick direction.
    //   3. For the next jumpDuration ticks, apply jumpTickMag each tick.
    //   4. After the jump completes, maybe trigger a cascade (same direction,
    //      larger magnitude) that also unfolds over jumpDuration ticks.
    //
    // This means:
    //   • A new jump cannot start while one is already running (natural gate)
    //   • The per-tick contribution is jumpMagPct / jumpDuration — small and smooth
    //   • The total move is still jumpMagPct (e.g. 1% for BTC) over ~7 minutes
    //   • At 100 ticks/day jumpDuration=1 so behaviour matches the old instant model

    let jumpContrib = 0;

    if (st.jumpTicksLeft > 0) {
      // ── Ongoing jump (or cascade) — apply one tick's worth ────────────
      const mag    = st.jumpInCascade
        ? st.jumpTickMag * st.cascadeMagMult
        : st.jumpTickMag;
      jumpContrib = st.jumpDirection * mag * session.volMult;
      st.jumpTicksLeft--;

      if (st.jumpTicksLeft === 0 && !st.jumpInCascade) {
        // Primary jump finished — maybe trigger cascade
        if (rng.next() < st.cascadeProb) {
          st.jumpTicksLeft = st.jumpDuration;
          st.jumpInCascade = true;
          // Direction stays the same — cascade reinforces the move
        }
      } else if (st.jumpTicksLeft === 0 && st.jumpInCascade) {
        st.jumpInCascade = false;
        st.jumpDirection = 0;
      }

    } else {
      // ── Check if a new jump starts this tick ──────────────────────────
      // Scale jumpProbTick slightly by session vol so jumps are more likely
      // during US hours (realistic — news and liquidations cluster then)
      const effectiveProbTick = st.jumpProbTick * Math.max(0.6, session.volMult);

      if (rng.next() < effectiveProbTick) {
        // Jump triggered — direction is slightly biased by momentum
        const bullBias   = 0.5 + Math.max(-0.3, Math.min(0.3, st.momentumEMA * 20));
        st.jumpDirection  = rng.next() < bullBias ? 1 : -1;
        st.jumpTicksLeft  = st.jumpDuration;
        st.jumpInCascade  = false;

        // Apply first tick's contribution immediately
        jumpContrib = st.jumpDirection * st.jumpTickMag * session.volMult;
        st.jumpTicksLeft--;

        // If jumpDuration was 1 (100 ticks/day mode), check cascade now
        if (st.jumpTicksLeft === 0) {
          if (rng.next() < st.cascadeProb) {
            st.jumpTicksLeft = st.jumpDuration;
            st.jumpInCascade = true;
          }
        }
      }
    }

    // ── Total log-return ──────────────────────────────────────────────────
    const logRet =
      tickDrift    +
      ouPull       +
      momentumPull +
      shock        +
      jumpContrib;

    // ── Apply ─────────────────────────────────────────────────────────────
    st.logPrice  += logRet;
    st.price      = roundTick(Math.exp(st.logPrice), st.tickSize);
    st.price      = Math.max(st.price, st.tickSize);
    st.prevLogRet = logRet;

    // OHLC
    if (st.price > st.high) st.high = st.price;
    if (st.price < st.low)  st.low  = st.price;
    st.close = st.price;
  }

  // ─── Private: quotes ───────────────────────────────────────────────────────

  _computeQuotes(symbol, st, session) {
    const rng = this._rng;

    const volRatio   = Math.sqrt(st.garchVar) / st.baseTickVol;
    const spreadBps  = st.spreadBps *
                       Math.max(0.5, Math.min(5.0, volRatio)) *
                       session.spreadMult;
    const spreadFrac = spreadBps / 10_000;
    const halfSpread = st.price * spreadFrac / 2;

    const noise = rng.normal(0, st.tickSize * 0.2);
    const mid   = st.price + noise;

    const bid = roundTick(mid - halfSpread, st.tickSize);
    const ask = roundTick(mid + halfSpread, st.tickSize);

    st.bid = Math.max(bid, st.tickSize);
    st.ask = Math.max(ask, st.bid + st.tickSize);

    const spreadAbs = st.ask - st.bid;
    const midPrice  = (st.bid + st.ask) / 2;

    this.quotes[symbol] = {
      bid:       fmtCrypto(st.bid, st.decimals),
      ask:       fmtCrypto(st.ask, st.decimals),
      mid:       fmtCrypto(midPrice, st.decimals),
      spread:    parseFloat(spreadAbs.toFixed(st.decimals)),
      spreadBps: parseFloat((spreadAbs / midPrice * 10_000).toFixed(2)),
      spreadPct: parseFloat((spreadAbs / midPrice * 100).toFixed(4)),
      decimals:  st.decimals,
      tickSize:  st.tickSize,
    };
  }

  // ─── Private: order book ───────────────────────────────────────────────────

  _buildOrderBook(symbol, st, session) {
    const rng    = this._rng;
    const levels = this._opts.maxBookLevels;

    const baseQty      = this._typicalQty(st.price, session);
    const levelSpacing = Math.max(st.tickSize, st.price * (st.spreadBps / 10_000) * 0.5);

    const bids = [];
    const asks = [];

    for (let i = 0; i < levels; i++) {
      const bidPrice = roundTick(st.bid - i * levelSpacing, st.tickSize);
      const askPrice = roundTick(st.ask + i * levelSpacing, st.tickSize);

      const decay  = Math.exp(-i * 0.28);
      const bidQty = parseFloat(Math.max(st.tickSize, baseQty * decay * rng.logNormal(0, 0.45)).toFixed(4));
      const askQty = parseFloat(Math.max(st.tickSize, baseQty * decay * rng.logNormal(0, 0.45)).toFixed(4));

      if (bidPrice > 0) {
        bids.push({
          price:    fmtCrypto(bidPrice, st.decimals),
          qty:      bidQty,
          usd:      Math.round(bidQty * bidPrice),
          orders:   rng.int(1, 6),
          exchange: rng.pick(EXCHANGES),
        });
      }
      asks.push({
        price:    fmtCrypto(askPrice, st.decimals),
        qty:      askQty,
        usd:      Math.round(askQty * askPrice),
        orders:   rng.int(1, 6),
        exchange: rng.pick(EXCHANGES),
      });
    }

    this.orderBooks[symbol] = { bids, asks };
  }

  _typicalQty(price, session) {
    const rng = this._rng;
    const s   = session.liquidityScore / 10;
    if (price > 50_000) return parseFloat((rng.uniform(0.1,  2.0)  * s).toFixed(4));
    if (price > 1_000)  return parseFloat((rng.uniform(0.5,  5.0)  * s).toFixed(4));
    if (price > 100)    return parseFloat((rng.uniform(5,    50)   * s).toFixed(2));
    if (price > 10)     return parseFloat((rng.uniform(50,   500)  * s).toFixed(2));
    if (price > 1)      return parseFloat((rng.uniform(500,  5000) * s).toFixed(2));
    return parseFloat((rng.uniform(10_000, 100_000) * s).toFixed(0));
  }

  // ─── Private: trade tape ───────────────────────────────────────────────────

  _generateTrades(symbol, st, session) {
    const rng       = this._rng;
    const maxTrades = this._opts.maxTrades;

    const lambda  = 3 + (session.liquidityScore / 10) * 5;
    const nTrades = rng.poisson(lambda);

    if (!this.recentTrades[symbol]) this.recentTrades[symbol] = [];
    const tape = this.recentTrades[symbol];

    for (let i = 0; i < nTrades; i++) {
      const bullBias  = 0.5 + Math.max(-0.3, Math.min(0.3, st.momentumEMA * 25));
      const isBuy     = rng.next() < bullBias;
      const execPrice = isBuy ? st.ask : st.bid;

      // Liquidation flag — slightly more likely if a jump is in progress
      const liqProb       = st.jumpTicksLeft > 0 ? 0.08 : 0.03;
      const isLiquidation = rng.next() < liqProb;
      const baseQtyMult   = this._typicalQty(st.price, session);

      const qty = isLiquidation
        ? parseFloat((baseQtyMult * rng.uniform(5, 20)).toFixed(4))
        : parseFloat((baseQtyMult * rng.logNormal(0, 0.8) * 0.5).toFixed(4));

      const usdValue = Math.round(qty * execPrice);

      tape.unshift({
        price:         fmtCrypto(Math.max(execPrice, st.tickSize), st.decimals),
        qty,
        usd:           usdValue,
        side:          isBuy ? "buy" : "sell",
        isLiquidation,
        isMaker:       rng.next() < 0.40,
        exchange:      rng.pick(EXCHANGES),
        ts:            Date.now() - rng.int(0, 4_900),
      });

      st.volume    += qty;
      st.volumeUsd += usdValue;
    }

    if (tape.length > maxTrades) tape.length = maxTrades;
  }

  // ─── Private: init / snapshot ─────────────────────────────────────────────

  _registerSymbol(symbol) {
    this.states[symbol]       = { _initialised: false };
    this.assets[symbol]       = { price: 0, open: 0, high: 0, low: 0, close: 0, volume: 0, volumeUsd: 0 };
    this.quotes[symbol]       = { bid: 0, ask: 0, mid: 0, spread: 0, spreadBps: 0, spreadPct: 0, decimals: 2, tickSize: 0.01 };
    this.orderBooks[symbol]   = { bids: [], asks: [] };
    this.recentTrades[symbol] = [];
    this.sessions[symbol]     = getCryptoSession(Date.now());
  }

  _initSymbol(symbol, price) {
    const p  = Math.max(Number(price) || 1, 1e-10);
    const st = createCoinState(symbol, p, { ticksPerDay: this._opts.ticksPerDay });
    st._initialised           = true;
    this.states[symbol]       = st;
    this.assets[symbol]       = { price: p, open: p, high: p, low: p, close: p, volume: 0, volumeUsd: 0 };
    this.quotes[symbol]       = { bid: 0, ask: 0, mid: p, spread: 0, spreadBps: 0, spreadPct: 0, decimals: st.decimals, tickSize: st.tickSize };
    this.orderBooks[symbol]   = { bids: [], asks: [] };
    this.recentTrades[symbol] = [];
    this.sessions[symbol]     = getCryptoSession(Date.now());
  }

  _snapshot(symbol) {
    return {
      symbol,
      session:   this.sessions[symbol],
      asset:     this.assets[symbol],
      quote:     this.quotes[symbol],
      orderBook: this.orderBooks[symbol],
      trades:    this.recentTrades[symbol],
    };
  }
}

module.exports = CryptoEngine;