← Back
β˜†
const { createLogger } = require('./logger')
const log = createLogger('liq-sweep')

/**
 * Liquidity Sweep + Pin Bar Signal Detector
 *
 * Step 1: Swing level detection + round number levels
 * - findSwingLevels()  β€” fractal-based swing high/low from klines
 * - findRoundNumbers() β€” psychological round-number levels near price
 */

// ======================== SWING HIGH/LOW DETECTION ========================

/**
 * Find swing highs and lows using fractal method.
 * A swing high: candle.high > N candles on each side.
 * A swing low:  candle.low  < N candles on each side.
 *
 * @param {Array} candles β€” [{time, open, high, low, close, volume}, ...] ASC order
 * @param {number} leftBars  β€” candles to the left for confirmation (default 3)
 * @param {number} rightBars β€” candles to the right for confirmation (default 3)
 * @returns {Array} [{ price, type: 'swing_high'|'swing_low', time, strength, touches }]
 */
function findSwingLevels(candles, leftBars = 3, rightBars = 3) {
  if (!Array.isArray(candles) || candles.length < leftBars + rightBars + 1) return []

  const raws = [] // raw swing points before clustering

  for (let i = leftBars; i < candles.length - rightBars; i++) {
    const c = candles[i]

    // --- Swing High ---
    let isSwingHigh = true
    for (let l = 1; l <= leftBars; l++) {
      if (candles[i - l].high >= c.high) { isSwingHigh = false; break }
    }
    if (isSwingHigh) {
      for (let r = 1; r <= rightBars; r++) {
        if (candles[i + r].high >= c.high) { isSwingHigh = false; break }
      }
    }
    if (isSwingHigh) {
      raws.push({ price: c.high, type: 'swing_high', time: c.time, volume: c.volume })
    }

    // --- Swing Low ---
    let isSwingLow = true
    for (let l = 1; l <= leftBars; l++) {
      if (candles[i - l].low <= c.low) { isSwingLow = false; break }
    }
    if (isSwingLow) {
      for (let r = 1; r <= rightBars; r++) {
        if (candles[i + r].low <= c.low) { isSwingLow = false; break }
      }
    }
    if (isSwingLow) {
      raws.push({ price: c.low, type: 'swing_low', time: c.time, volume: c.volume })
    }
  }

  // --- Cluster nearby levels (within 0.15% of each other) ---
  // Keeps the strongest (most touches) and freshest level per cluster
  const CLUSTER_PCT = 0.0015
  const clustered = clusterLevels(raws, CLUSTER_PCT)

  return clustered
}

/**
 * Cluster raw swing points that are within pctThreshold of each other.
 * Returns deduplicated levels with touch count and strength score.
 */
function clusterLevels(raws, pctThreshold) {
  if (raws.length === 0) return []

  // Sort by price
  const sorted = [...raws].sort((a, b) => a.price - b.price)
  const clusters = []
  let current = [sorted[0]]

  for (let i = 1; i < sorted.length; i++) {
    const prev = current[current.length - 1]
    const pctDiff = Math.abs(sorted[i].price - prev.price) / prev.price

    if (pctDiff <= pctThreshold && sorted[i].type === prev.type) {
      current.push(sorted[i])
    } else {
      clusters.push(current)
      current = [sorted[i]]
    }
  }
  clusters.push(current)

  // Reduce each cluster to a single level
  return clusters.map(group => {
    const touches = group.length
    // Use the price with the most recent touch (freshest)
    const freshest = group.reduce((a, b) => a.time > b.time ? a : b)
    // Strength: more touches = stronger, capped at 10
    const strength = Math.min(10, touches * 2 + 1)

    return {
      price: freshest.price,
      type: freshest.type,
      time: freshest.time,
      touches,
      strength,
      source: 'swing',
    }
  })
}


// ======================== ORDER BOOK WALL LEVELS ========================

/**
 * Extract significant wall levels from the live order book.
 * Uses stateManager for raw levels and densityV2 for statistical detection.
 *
 * @param {Object} params
 * @param {string} params.symbol
 * @param {number} params.markPrice
 * @param {Object} params.stateManager β€” server/state.js singleton
 * @param {Object} params.densityV2   β€” server/densityV2.js module
 * @param {Map}    params.persistenceMap β€” densityV2 persistence store (from index.js)
 * @param {number} params.windowPct β€” price window % (default 3)
 * @returns {Array} [{ price, type: 'bid_wall'|'ask_wall', strength, notional, score, status, source }]
 */
function getWallLevels({ symbol, markPrice, stateManager, densityV2, persistenceMap, windowPct = 3 }) {
  if (!stateManager || !markPrice) return []

  // Check if we have WS data for this symbol
  if (!stateManager.books || !stateManager.books.has(symbol)) return []

  const book = stateManager.books.get(symbol)

  // Gather raw levels from the order book within the window
  const minPrice = markPrice * (1 - windowPct / 100)
  const maxPrice = markPrice * (1 + windowPct / 100)

  const bidLevels = []
  for (const [price, data] of book.bids.entries()) {
    if (price >= minPrice && price <= maxPrice) {
      bidLevels.push({ price, notional: data.notional, firstSeen: data.firstSeen, lastUpdate: data.lastUpdate })
    }
  }

  const askLevels = []
  for (const [price, data] of book.asks.entries()) {
    if (price >= minPrice && price <= maxPrice) {
      askLevels.push({ price, notional: data.notional, firstSeen: data.firstSeen, lastUpdate: data.lastUpdate })
    }
  }

  // If densityV2 and persistenceMap are available, use statistical wall detection
  if (densityV2 && persistenceMap) {
    try {
      const analysis = densityV2.analyzeSymbol({
        symbol, markPrice, bidLevels, askLevels, persistenceMap, windowPct, nSigma: 2.0
      })

      const walls = []

      // Convert bidWalls (support) to liquidity levels
      if (Array.isArray(analysis.bidWalls)) {
        for (const w of analysis.bidWalls) {
          walls.push({
            price: w.price,
            type: 'bid_wall',
            strength: Math.min(10, Math.round(w.score || 1)),
            notional: w.notional,
            score: w.score,
            status: w.status,
            distancePct: w.distancePct,
            source: 'wall',
            time: null,
            touches: 0,
          })
        }
      }

      // Convert askWalls (resistance) to liquidity levels
      if (Array.isArray(analysis.askWalls)) {
        for (const w of analysis.askWalls) {
          walls.push({
            price: w.price,
            type: 'ask_wall',
            strength: Math.min(10, Math.round(w.score || 1)),
            notional: w.notional,
            score: w.score,
            status: w.status,
            distancePct: w.distancePct,
            source: 'wall',
            time: null,
            touches: 0,
          })
        }
      }

      return walls
    } catch (e) {
      // Fallback: skip densityV2, return empty
      log.error({ symbol, err: e.message }, 'densityV2 error')
    }
  }

  // Fallback without densityV2: return top raw levels by notional
  // (simple threshold: top 5 per side, min $50K notional)
  const MIN_NOTIONAL = 50_000
  const result = []

  const topBids = bidLevels.filter(l => l.notional >= MIN_NOTIONAL)
    .sort((a, b) => b.notional - a.notional).slice(0, 5)
  for (const l of topBids) {
    result.push({
      price: l.price, type: 'bid_wall', strength: 3,
      notional: l.notional, score: null, status: null,
      distancePct: Math.abs(l.price - markPrice) / markPrice * 100,
      source: 'wall', time: null, touches: 0,
    })
  }

  const topAsks = askLevels.filter(l => l.notional >= MIN_NOTIONAL)
    .sort((a, b) => b.notional - a.notional).slice(0, 5)
  for (const l of topAsks) {
    result.push({
      price: l.price, type: 'ask_wall', strength: 3,
      notional: l.notional, score: null, status: null,
      distancePct: Math.abs(l.price - markPrice) / markPrice * 100,
      source: 'wall', time: null, touches: 0,
    })
  }

  return result
}


// ======================== ROUND NUMBER LEVELS ========================

/**
 * Generate psychological round-number levels near the current price.
 * Adapts step size to the price magnitude.
 *
 * @param {number} price β€” current mark price
 * @param {number} windowPct β€” how far above/below to look (default 2%)
 * @returns {Array} [{ price, type: 'round_number', strength, source }]
 */
function findRoundNumbers(price, windowPct = 2) {
  if (!price || price <= 0) return []

  // Determine step based on price magnitude
  // BTC ~60000 β†’ step 1000, ETH ~3000 β†’ step 100, altcoin ~1.5 β†’ step 0.1
  const step = getRoundStep(price)
  const halfStep = step / 2 // sub-levels (e.g. 500 for BTC)

  const windowAbs = price * (windowPct / 100)
  const lo = price - windowAbs
  const hi = price + windowAbs

  const levels = []

  // Start from the nearest round number below lo
  const start = Math.floor(lo / halfStep) * halfStep

  for (let p = start; p <= hi; p += halfStep) {
    if (p <= 0) continue
    if (p < lo) continue

    // Full round (e.g. 60000) is stronger than half (e.g. 60500)
    const isFull = Math.abs(p % step) < step * 0.001
    const strength = isFull ? 4 : 2

    levels.push({
      price: p,
      type: 'round_number',
      strength,
      source: 'round',
      time: null,
      touches: 0,
    })
  }

  return levels
}

/**
 * Determine the appropriate round-number step for a given price.
 */
function getRoundStep(price) {
  if (price >= 10000) return 1000      // BTC: 60000, 61000, ...
  if (price >= 1000)  return 100       // ETH: 3000, 3100, ...
  if (price >= 100)   return 10        // SOL: 150, 160, ...
  if (price >= 10)    return 1         // LINK: 14, 15, ...
  if (price >= 1)     return 0.1       // DOGE: 0.3, 0.4, ...
  if (price >= 0.01)  return 0.01      // SHIB-like: 0.01, 0.02, ...
  return 0.001
}


// ======================== PIN BAR DETECTION ========================

/**
 * Detect whether a candle is a pin bar (rejection candle).
 *
 * Bullish pin bar (LONG signal):
 *   - Long lower wick (β‰₯ wickMinRatio of total range)
 *   - Small body (≀ bodyMaxRatio of total range)
 *   - Close in the upper portion of the range
 *
 * Bearish pin bar (SHORT signal):
 *   - Long upper wick (β‰₯ wickMinRatio of total range)
 *   - Small body (≀ bodyMaxRatio of total range)
 *   - Close in the lower portion of the range
 *
 * Also filters out tiny-range noise candles by requiring the range
 * to exceed a minimum relative to the average range of previous candles.
 *
 * @param {Object} candle β€” { open, high, low, close, volume, time }
 * @param {Array}  prevCandles β€” preceding candles for avg range calc (5-20 recommended)
 * @param {Object} opts
 * @param {number} opts.wickMinRatio   β€” min wick / range (default 0.60)
 * @param {number} opts.bodyMaxRatio   β€” max body / range (default 0.33)
 * @param {number} opts.minRangeMult   β€” candle range must be β‰₯ this Γ— avgRange (default 0.8)
 * @returns {null | { direction: 'LONG'|'SHORT', wickRatio, bodyRatio, range }}
 */
function detectPinBar(candle, prevCandles = [], opts = {}) {
  if (!candle) return null

  const {
    wickMinRatio = 0.60,
    bodyMaxRatio = 0.33,
    minRangeMult = 0.8,
  } = opts

  const { open, high, low, close } = candle
  const range = high - low
  if (range <= 0) return null

  // --- Filter out tiny candles (noise) ---
  if (prevCandles.length >= 3) {
    const avgRange = prevCandles.reduce((s, c) => s + (c.high - c.low), 0) / prevCandles.length
    if (avgRange > 0 && range < avgRange * minRangeMult) return null
  }

  const body = Math.abs(close - open)
  const bodyRatio = body / range

  // Body must be small
  if (bodyRatio > bodyMaxRatio) return null

  const upperWick = high - Math.max(open, close)
  const lowerWick = Math.min(open, close) - low

  const upperWickRatio = upperWick / range
  const lowerWickRatio = lowerWick / range

  // --- Bullish pin bar: long lower wick, close near the top ---
  if (lowerWickRatio >= wickMinRatio) {
    return {
      direction: 'LONG',
      wickRatio: parseFloat(lowerWickRatio.toFixed(3)),
      bodyRatio: parseFloat(bodyRatio.toFixed(3)),
      range,
    }
  }

  // --- Bearish pin bar: long upper wick, close near the bottom ---
  if (upperWickRatio >= wickMinRatio) {
    return {
      direction: 'SHORT',
      wickRatio: parseFloat(upperWickRatio.toFixed(3)),
      bodyRatio: parseFloat(bodyRatio.toFixed(3)),
      range,
    }
  }

  return null
}


// ======================== SWEEP CONFIRMATION ========================

/**
 * Check if a pin bar's wick actually swept (pierced) a liquidity level.
 *
 * For a bullish pin bar (LONG): the candle LOW must be BELOW a level,
 * and the CLOSE must be ABOVE that level (wick grabbed liquidity, price recovered).
 *
 * For a bearish pin bar (SHORT): the candle HIGH must be ABOVE a level,
 * and the CLOSE must be BELOW that level.
 *
 * If multiple levels were swept, returns the one with the highest strength.
 *
 * @param {Object} candle   β€” { open, high, low, close }
 * @param {Object} pinBar   β€” from detectPinBar(): { direction }
 * @param {Array}  levels   β€” [{ price, type, strength, source, ... }]
 * @param {Object} opts
 * @param {number} opts.maxPenetrationPct β€” max wick penetration past level in % of price (default 1.5)
 * @returns {null | { sweptLevel, levelType, levelSource, strength, sweepDepthPct, levelsSwept }}
 */
function confirmSweep(candle, pinBar, levels, opts = {}) {
  if (!candle || !pinBar || !Array.isArray(levels) || levels.length === 0) return null

  const { maxPenetrationPct = 1.5 } = opts

  const swept = []

  if (pinBar.direction === 'LONG') {
    // Wick went DOWN through a level, close recovered above it
    for (const lv of levels) {
      // Level should be below close (support zone) β€” sweep means low < level < close
      if (candle.low < lv.price && candle.close > lv.price) {
        const penetration = lv.price - candle.low
        const penetrationPct = (penetration / lv.price) * 100
        // Don't count absurdly deep sweeps β€” probably a dump, not a sweep
        if (penetrationPct <= maxPenetrationPct) {
          swept.push({
            sweptLevel: lv.price,
            levelType: lv.type,
            levelSource: lv.source,
            strength: lv.strength,
            sweepDepthPct: parseFloat(penetrationPct.toFixed(3)),
            notional: lv.notional || null,
          })
        }
      }
    }
  } else if (pinBar.direction === 'SHORT') {
    // Wick went UP through a level, close recovered below it
    for (const lv of levels) {
      // Level should be above close (resistance zone) β€” sweep means close < level < high
      if (candle.high > lv.price && candle.close < lv.price) {
        const penetration = candle.high - lv.price
        const penetrationPct = (penetration / lv.price) * 100
        if (penetrationPct <= maxPenetrationPct) {
          swept.push({
            sweptLevel: lv.price,
            levelType: lv.type,
            levelSource: lv.source,
            strength: lv.strength,
            sweepDepthPct: parseFloat(penetrationPct.toFixed(3)),
            notional: lv.notional || null,
          })
        }
      }
    }
  }

  if (swept.length === 0) return null

  // Pick the strongest swept level
  swept.sort((a, b) => b.strength - a.strength)
  const best = swept[0]
  best.levelsSwept = swept.length // how many levels were taken out at once

  return best
}


// ======================== CONFIDENCE SCORING ========================

/**
 * Calculate confidence score (30–95) for a confirmed sweep + pin bar signal.
 *
 * Components:
 *   Base          β€” 40 pts (a confirmed sweep+pinbar is already meaningful)
 *   Wick quality  β€” 0-15 pts (how clean the pin bar is)
 *   Level strengthβ€” 0-15 pts (swing touches, wall score, confluence)
 *   Volume spike  β€” 0-12 pts (candle volume vs average)
 *   OI drop       β€” 0-8 pts  (open interest decreased = liquidations)
 *   Wall absorbed β€” 0-5 pts  (density wall disappeared after sweep)
 *
 * @param {Object} params
 * @param {number} params.wickRatio      β€” from detectPinBar (0.6–1.0)
 * @param {number} params.levelStrength  β€” from the swept level (1–10)
 * @param {number} params.levelsSwept    β€” how many levels taken at once (1+)
 * @param {string} params.levelSource    β€” 'swing' | 'wall' | 'round'
 * @param {number} [params.volumeRatio]  β€” candle volume / SMA volume (null if unknown)
 * @param {number} [params.oiChangePct]  β€” OI change % on that candle (negative = drop)
 * @param {boolean}[params.wallAbsorbed] β€” density wall existed before and gone after
 * @returns {number} confidence 30–95
 */
function scoreConfidence({
  wickRatio = 0.6,
  levelStrength = 1,
  levelsSwept = 1,
  levelSource = 'swing',
  volumeRatio = null,
  oiChangePct = null,
  wallAbsorbed = false,
  trendContext = null, // 'counter' | 'with' | null
  fundingContext = null, // 'extreme' | null
}) {
  let score = 35 // base (lowered from 40 to make room for trend+funding)

  // --- Wick quality (0–15) ---
  // 0.60 β†’ 0, 0.70 β†’ 5, 0.80 β†’ 10, 0.90+ β†’ 15
  score += Math.min(15, Math.max(0, (wickRatio - 0.60) / 0.30 * 15))

  // --- Level strength (0–15) ---
  // strength 1β†’1.5, 5β†’7.5, 10β†’15 (wall confluence already adds +3 to strength)
  let lvlPts = Math.min(10, levelStrength * 1.5)
  // All signals are swing-based now (+3 base)
  lvlPts += 3
  // Confluence bonus: multiple levels swept at once
  if (levelsSwept >= 2) lvlPts += 2
  score += Math.min(15, lvlPts)

  // --- Volume spike (0–12) ---
  if (volumeRatio != null && volumeRatio > 1) {
    // 1.5x β†’ 3, 2x β†’ 6, 3x β†’ 9, 5x+ β†’ 12
    score += Math.min(12, Math.max(0, (volumeRatio - 1) * 3))
  }

  // --- OI drop (0–8) ---
  // Negative oiChangePct means OI decreased = stops hit / liquidations
  if (oiChangePct != null && oiChangePct < 0) {
    const drop = Math.abs(oiChangePct)
    // 0.5% β†’ 2, 1% β†’ 4, 2%+ β†’ 8
    score += Math.min(8, drop * 4)
  }

  // --- Trend context (0–10) ---
  // Counter-trend sweep = exhaustion/reversal, much stronger signal
  // With-trend sweep = less reliable (might just be a pullback)
  if (trendContext === 'counter') {
    score += 10
  } else if (trendContext === 'with') {
    score += 2
  }

  // --- Funding extreme (0–5) ---
  // Sweep against overcrowded side = smart money liquidating the crowd
  if (fundingContext === 'extreme') {
    score += 5
  }

  // --- Wall absorbed (0–5) ---
  if (wallAbsorbed) {
    score += 5
  }

  return Math.max(30, Math.min(95, Math.round(score)))
}


// ======================== MERGE & DEDUPLICATE LEVELS ========================

/**
 * Merge levels from all sources and deduplicate nearby ones (within 0.15%).
 * Keeps the level with higher strength from each cluster.
 */
function mergeLevels(allLevels) {
  if (allLevels.length === 0) return []
  const sorted = [...allLevels].sort((a, b) => a.price - b.price)
  const result = [sorted[0]]

  for (let i = 1; i < sorted.length; i++) {
    const prev = result[result.length - 1]
    const pctDiff = Math.abs(sorted[i].price - prev.price) / prev.price
    if (pctDiff < 0.0015) {
      // Keep the stronger one
      if (sorted[i].strength > prev.strength) {
        result[result.length - 1] = sorted[i]
      }
    } else {
      result.push(sorted[i])
    }
  }
  return result
}


// ======================== SCAN LOOP ========================

const LIQ_SWEEP_SCAN_DELAY_MS = 150
const MIN_VOL_24H = 30_000_000
const MIN_VOLUME_RATIO = 5 // sweep candle volume must be >= 5x average

// Funding rate thresholds for extreme context (%, must match signals.js FUNDING_EXTREME values)
const FUNDING_EXTREME_POS = 0.04  // longs overcrowded β†’ SHORT sweep stronger
const FUNDING_EXTREME_NEG = -0.04 // shorts overcrowded β†’ LONG sweep stronger

// In-memory 1h klines cache for sweep scanner (avoids 72 API calls per scan)
// Refreshed every 30min per symbol (1h candles don't change fast)
const _1hCache = new Map() // symbol β†’ { candles, ts }
const _1H_CACHE_TTL = 30 * 60_000
const _1H_CACHE_MAX_AGE = 60 * 60_000 // evict entries older than 60min
const _1H_CACHE_MAX_SIZE = 300 // hard cap on symbols in cache

// Periodic cleanup of stale 1h cache entries (every 10min)
const _cleanupInterval = setInterval(() => {
  const now = Date.now()
  let evicted = 0
  for (const [symbol, entry] of _1hCache) {
    if (now - entry.ts > _1H_CACHE_MAX_AGE) {
      _1hCache.delete(symbol)
      evicted++
    }
  }
  if (evicted > 0) log.debug({ evicted, remaining: _1hCache.size }, 'Cache cleanup')
}, 10 * 60_000)

/**
 * Main scan function β€” called on a timer from signals.js.
 * Examines the latest closed 5m candle per liquid symbol for sweep + pin bar.
 *
 * @param {Object} deps β€” injected dependencies (same DI pattern as signals.js)
 * @param {Function} deps.getProxyCached
 * @param {Function} deps.bgetWithRetry
 * @param {Object}   deps.klinesCache
 * @param {Object}   deps.stateManager
 * @param {Object}   deps.densityV2
 * @param {Map}      deps.persistenceMap
 * @param {Function} deps.emitSignal
 */
async function scanLiqSweep(deps) {
  const {
    getProxyCached, bgetWithRetry, klinesCache,
    stateManager, densityV2, persistenceMap, emitSignal,
    getMarketRegime, getFundingMap,
  } = deps

  try {
    // --- Get liquid symbols (proxyCache β†’ Bottleneck, maxConcurrent=50 supports weight=40) ---
    let ticker = getProxyCached('ticker24hr', 60_000)
    if (!Array.isArray(ticker) || ticker.length === 0) {
      try {
        ticker = await bgetWithRetry('/fapi/v1/ticker/24hr')
      } catch { return }
      if (!Array.isArray(ticker)) return
    }

    const liquid = ticker
      .filter(t => t.symbol.endsWith('USDT') && !t.symbol.includes('_'))
      .filter(t => parseFloat(t.quoteVolume) >= MIN_VOL_24H)
      .sort((a, b) => parseFloat(b.quoteVolume) - parseFloat(a.quoteVolume))

    // Get market regime once per scan (cached 5min)
    const regime = getMarketRegime ? await getMarketRegime() : { direction: null }

    // Funding rate map β€” reuse cached getFundingMap from signals.js (5min TTL)
    // Values are raw decimals (e.g. 0.0003), multiply by 100 for percentage when comparing
    const fundingRates = getFundingMap ? await getFundingMap() : {}

    let signalCount = 0
    let errCount = 0
    let pinBarCount = 0
    let sweepCount = 0
    let volGateSkipped = 0

    for (const t of liquid) {
      const symbol = t.symbol
      const markPrice = parseFloat(t.lastPrice)
      if (!markPrice) continue

      try {
        // --- 1. Get 5m candles (cache first, API fallback) ---
        let candles5m = klinesCache ? klinesCache.getCandles(symbol, '5m', 50) : []
        if (candles5m.length < 10) {
          // Fallback: fetch from Binance API (raw format)
          const raw = await bgetWithRetry(`/fapi/v1/klines?symbol=${symbol}&interval=5m&limit=50`)
          if (!Array.isArray(raw) || raw.length < 10) continue
          candles5m = raw.map(k => ({
            time: Math.floor(k[0] / 1000),
            open: parseFloat(k[1]), high: parseFloat(k[2]),
            low: parseFloat(k[3]), close: parseFloat(k[4]),
            volume: parseFloat(k[7]), // quoteAssetVolume in USDT
          }))
        }

        // The last candle may still be forming β€” examine the second-to-last (latest closed)
        if (candles5m.length < 3) continue
        const targetCandle = candles5m[candles5m.length - 2]
        const prevCandles = candles5m.slice(Math.max(0, candles5m.length - 12), candles5m.length - 2) // 10 prior candles

        // --- 2. Detect pin bar ---
        const pinBar = detectPinBar(targetCandle, prevCandles)
        if (!pinBar) continue
        pinBarCount++

        // --- 3. Gather liquidity levels ---
        // 3a. Swing levels from 1h candles (SQLite cache β†’ memory cache β†’ API)
        let candles1h = klinesCache ? klinesCache.getCandles(symbol, '1h', 200) : null
        if (!candles1h || candles1h.length < 20) {
          // Check in-memory 1h cache
          const cached1h = _1hCache.get(symbol)
          if (cached1h && Date.now() - cached1h.ts < _1H_CACHE_TTL) {
            candles1h = cached1h.candles
          } else {
            try {
              const raw1h = await bgetWithRetry(`/fapi/v1/klines?symbol=${symbol}&interval=1h&limit=200`)
              if (Array.isArray(raw1h) && raw1h.length >= 20) {
                candles1h = raw1h.map(k => ({
                  time: Math.floor(k[0] / 1000),
                  open: parseFloat(k[1]), high: parseFloat(k[2]),
                  low: parseFloat(k[3]), close: parseFloat(k[4]),
                  volume: parseFloat(k[7]),
                }))
                if (_1hCache.size >= _1H_CACHE_MAX_SIZE) {
                  // Evict oldest entry
                  const oldest = _1hCache.keys().next().value
                  _1hCache.delete(oldest)
                }
                _1hCache.set(symbol, { candles: candles1h, ts: Date.now() })
              } else { candles1h = null }
            } catch { candles1h = null }
          }
        }
        const swingLevels = candles1h ? findSwingLevels(candles1h, 5, 3) : []

        // 3b. Order book walls β€” used as confluence boost, not standalone trigger
        const wallLevels = getWallLevels({ symbol, markPrice, stateManager, densityV2, persistenceMap })

        // 3c. Boost swing levels that coincide with walls (Β±0.2%)
        for (const sw of swingLevels) {
          const hasWallConfluence = wallLevels.some(w => Math.abs(w.price - sw.price) / sw.price < 0.002)
          if (hasWallConfluence) {
            sw.strength = (sw.strength || 1) + 3 // wall confluence bonus
            sw.wallConfluence = true
          }
        }

        // 3d. Only use swing levels (round numbers removed, walls are boost only)
        const allLevels = mergeLevels(swingLevels)
        if (allLevels.length === 0) continue

        // --- 4. Confirm sweep ---
        const sweep = confirmSweep(targetCandle, pinBar, allLevels)
        if (!sweep) continue
        sweepCount++

        // --- 5. Volume ratio (candle vol vs SMA of prior candles) ---
        // Gate: real sweeps ALWAYS have volume (stops/liquidations trigger).
        // Skip anything below MIN_VOLUME_RATIO β€” it's just noise.
        let volumeRatio = null
        if (prevCandles.length >= 5) {
          const avgVol = prevCandles.reduce((s, c) => s + (c.volume || 0), 0) / prevCandles.length
          if (avgVol > 0 && targetCandle.volume) {
            volumeRatio = targetCandle.volume / avgVol
          }
        }
        if (volumeRatio == null || volumeRatio < MIN_VOLUME_RATIO) { volGateSkipped++; continue }

        // --- 5b. OI change β€” confirms liquidations/stop hunts ---
        // Only fetched for signals that passed all gates (saves API calls)
        let oiChangePct = null
        try {
          const oiHist = await bgetWithRetry(
            `/futures/data/openInterestHist?symbol=${symbol}&period=5min&limit=3`
          )
          if (Array.isArray(oiHist) && oiHist.length >= 2) {
            const oiPrev = parseFloat(oiHist[oiHist.length - 2].sumOpenInterest)
            const oiCurr = parseFloat(oiHist[oiHist.length - 1].sumOpenInterest)
            if (oiPrev > 0) {
              oiChangePct = ((oiCurr - oiPrev) / oiPrev) * 100
            }
          }
        } catch { /* OI data unavailable β€” score without it */ }

        // --- 5c. Trend context β€” counter-trend sweeps are stronger ---
        // LONG sweep in BEARISH market = counter-trend exhaustion β†’ strongest
        // SHORT sweep in BULLISH market = counter-trend exhaustion β†’ strongest
        let trendContext = null
        if (regime.direction) {
          const isCounter =
            (pinBar.direction === 'LONG' && regime.direction === 'BEARISH') ||
            (pinBar.direction === 'SHORT' && regime.direction === 'BULLISH')
          trendContext = isCounter ? 'counter' : 'with'
        }

        // --- 5d. Funding rate context ---
        // Extreme funding + sweep against the crowd = smart money
        // fundingRates values are raw decimals (0.0003 = 0.03%)
        const rawFunding = fundingRates[symbol]
        const fundingPct = rawFunding != null ? rawFunding * 100 : null
        let fundingContext = null
        if (fundingPct != null) {
          if (fundingPct > FUNDING_EXTREME_POS && pinBar.direction === 'SHORT') fundingContext = 'extreme'
          else if (fundingPct < FUNDING_EXTREME_NEG && pinBar.direction === 'LONG') fundingContext = 'extreme'
        }

        // --- 6. Score confidence ---
        const confidence = scoreConfidence({
          wickRatio: pinBar.wickRatio,
          levelStrength: sweep.strength,
          levelsSwept: sweep.levelsSwept,
          levelSource: sweep.levelSource,
          volumeRatio,
          oiChangePct,
          wallAbsorbed: false,
          trendContext,
          fundingContext,
        })

        // --- 7. Emit signal ---
        const candleTimeIso = new Date(targetCandle.time * 1000).toISOString()

        emitSignal({
          type: 'liq_sweep',
          symbol,
          price: markPrice,
          signalTime: candleTimeIso,
          direction: pinBar.direction,
          confidence,
          description: `🎯 ${pinBar.direction === 'LONG' ? 'Bullish' : 'Bearish'} sweep β€” wick took ${sweep.levelType.replace('_', ' ')} at ${sweep.sweptLevel}, recovered (${(pinBar.wickRatio * 100).toFixed(0)}% wick)`,
          metadata: {
            sweptLevel: sweep.sweptLevel,
            levelType: sweep.levelType,
            levelSource: sweep.levelSource,
            sweepDepthPct: sweep.sweepDepthPct,
            levelsSwept: sweep.levelsSwept,
            wickRatio: pinBar.wickRatio,
            bodyRatio: pinBar.bodyRatio,
            candleRange: parseFloat(pinBar.range.toFixed(4)),
            volumeRatio: volumeRatio ? parseFloat(volumeRatio.toFixed(1)) : null,
            oiChangePct: oiChangePct != null ? parseFloat(oiChangePct.toFixed(2)) : null,
            trendContext: trendContext || 'unknown',
            fundingContext: fundingContext || 'normal',
            fundingPct: fundingPct != null ? parseFloat(fundingPct.toFixed(4)) : null,
            marketRegime: regime.direction || 'UNKNOWN',
            wallNotional: sweep.notional,
            change24h: parseFloat(t.priceChangePercent),
            volume24h: Math.round(parseFloat(t.quoteVolume)),
          },
        })
        signalCount++

      } catch (e) {
        errCount++
        if (errCount <= 3) log.warn({ symbol, err: e.message }, 'Scan error')
      }

      await new Promise(r => setTimeout(r, LIQ_SWEEP_SCAN_DELAY_MS))
    }

    log.info({ symbols: liquid.length, pinBars: pinBarCount, sweeps: sweepCount, volGateSkipped, signals: signalCount, errors: errCount || undefined }, 'Scan done')
  } catch (err) {
    log.error({ err: err.message }, 'Scan error')
  }
}


// ======================== EXPORTS ========================

module.exports = {
  findSwingLevels,
  findRoundNumbers,
  getWallLevels,
  detectPinBar,
  confirmSweep,
  scoreConfidence,
  scanLiqSweep,
  stopCleanup: () => clearInterval(_cleanupInterval),
  // internal β€” exported for testing
  mergeLevels,
  clusterLevels,
  getRoundStep,
}

πŸ“œ Git History

85e4ebdfix: 16-bug audit β€” resync storm, memory leaks, API errors, data persistence7 weeks ago
562f6d2fix: 14-point signal system audit β€” critical bugs + UX fixes8 weeks ago
59cbf69fix: eliminate crash loop + 29x faster page load8 weeks ago
af31d32fix: ticker24hr weight=40 exceeds Bottleneck maxConcurrent=108 weeks ago
6d27024feat: structured pino logging + revert resync queue to simple handler8 weeks ago
4492574fix+test: comprehensive code audit β€” 11 bugfixes + 148 new tests8 weeks ago
52f9fb3fix: 10-bug audit β€” WS shutdown, cache leaks, infinite retry, silent errors9 weeks ago
cd37de3feat: liq sweep quality overhaul β€” volume gate, OI drop, trend & funding context9 weeks ago
4446d3afix: 12 silent failure audit + multi-select signal filter + sync fix9 weeks ago
cf1c4f9refactor: liq-sweep β€” remove round numbers, walls as confluence boost, swing 5/39 weeks ago
Show last diff
Loading...