← Back
β˜†
// densityV2.js β€” Statistical Walls + Bid/Ask Imbalance + Persistence
// Variant C: Hybrid Screener
//
// Algorithm:
// 1. Fetch order book (from stateManager WS data)
// 2. Adaptive bucketing: bucket_size = price Γ— 0.05%
// 3. Statistical wall detection: volume > median + 2Γ—stddev
// 4. Cluster adjacent flagged buckets into zones
// 5. Score: (size / median) Γ— proximity Γ— persistence
// 6. Bid/Ask Imbalance in Β±2% window
// 7. Output: nearest support + nearest resistance per symbol

/**
 * Adaptive bucket size based on price
 * BTC ($67K) β†’ ~$33 buckets
 * ETH ($3.5K) β†’ ~$1.75 buckets
 * Small alt ($0.05) β†’ ~$0.000025 buckets
 */
function getBucketSize(price) {
  return price * 0.0005 // 0.05%
}

/**
 * Bucket raw order book levels into price zones
 * @param {Array} levels - [{price, notional, firstSeen, lastUpdate}]
 * @param {number} bucketSize - absolute price bucket size
 * @returns {Array} [{anchorPrice, totalNotional, levelCount, minPrice, maxPrice, oldestSeen}]
 */
function bucketLevels(levels, bucketSize) {
  if (!levels || levels.length === 0) return []

  const bucketMap = new Map() // bucketIndex -> aggregated data

  for (const level of levels) {
    const bucketIdx = Math.floor(level.price / bucketSize)
    const anchorPrice = (bucketIdx + 0.5) * bucketSize // center of bucket

    if (!bucketMap.has(bucketIdx)) {
      bucketMap.set(bucketIdx, {
        anchorPrice,
        totalNotional: 0,
        levelCount: 0,
        minPrice: level.price,
        maxPrice: level.price,
        oldestSeen: level.firstSeen || Date.now(),
        newestUpdate: level.lastUpdate || Date.now()
      })
    }

    const bucket = bucketMap.get(bucketIdx)
    bucket.totalNotional += level.notional
    bucket.levelCount++
    bucket.minPrice = Math.min(bucket.minPrice, level.price)
    bucket.maxPrice = Math.max(bucket.maxPrice, level.price)
    bucket.oldestSeen = Math.min(bucket.oldestSeen, level.firstSeen || Date.now())
    bucket.newestUpdate = Math.max(bucket.newestUpdate, level.lastUpdate || Date.now())
  }

  return Array.from(bucketMap.values())
}

/**
 * Detect statistical outlier walls from bucketed data
 * Wall = bucket where notional > median + NΓ—stddev
 * @param {Array} buckets - from bucketLevels()
 * @param {number} nSigma - number of standard deviations (default 2)
 * @returns {Object} {walls: [], median, stddev, threshold}
 */
function detectWalls(buckets, nSigma = 2.0) {
  if (!buckets || buckets.length < 3) return { walls: [], median: 0, stddev: 0, threshold: 0 }

  const notionals = buckets.map(b => b.totalNotional).sort((a, b) => a - b)

  // Median
  const mid = Math.floor(notionals.length / 2)
  const median = notionals.length % 2 === 0
    ? (notionals[mid - 1] + notionals[mid]) / 2
    : notionals[mid]

  // Standard deviation
  const mean = notionals.reduce((a, b) => a + b, 0) / notionals.length
  const variance = notionals.reduce((sum, v) => sum + (v - mean) ** 2, 0) / notionals.length
  const stddev = Math.sqrt(variance)

  // Threshold: median + NΓ—Οƒ (using median not mean β€” more robust against outliers)
  // Also require at least 3Γ— median as a floor
  const threshold = median + nSigma * stddev
  const effectiveThreshold = Math.max(threshold, median * 3)

  const walls = buckets
    .filter(b => b.totalNotional >= effectiveThreshold)
    .map(b => ({
      ...b,
      sizeVsMedian: median > 0 ? Math.min(b.totalNotional / median, 99.9) : 0
    }))
    .sort((a, b) => b.totalNotional - a.totalNotional)

  return { walls, median, stddev, threshold: effectiveThreshold }
}

/**
 * Cluster adjacent wall buckets into zones
 * If wall buckets are next to each other, merge them into a single zone
 * @param {Array} walls - detected wall buckets
 * @param {number} bucketSize - price bucket size
 * @param {number} maxGap - max gap in bucket units to merge (default 2 = merge if gap ≀ 2 buckets)
 */
function clusterWalls(walls, bucketSize, maxGap = 2) {
  if (!walls || walls.length === 0) return []

  const sorted = [...walls].sort((a, b) => a.anchorPrice - b.anchorPrice)
  const clusters = []
  let current = { ...sorted[0], priceStart: sorted[0].minPrice, priceEnd: sorted[0].maxPrice }

  for (let i = 1; i < sorted.length; i++) {
    const wall = sorted[i]
    const gapBuckets = Math.abs(wall.anchorPrice - current.anchorPrice) / bucketSize

    if (gapBuckets <= maxGap + 1) {
      // Merge into current cluster
      current.totalNotional += wall.totalNotional
      current.levelCount += wall.levelCount
      current.priceEnd = wall.maxPrice
      current.maxPrice = Math.max(current.maxPrice, wall.maxPrice)
      current.minPrice = Math.min(current.minPrice, wall.minPrice)
      current.oldestSeen = Math.min(current.oldestSeen, wall.oldestSeen)
      current.newestUpdate = Math.max(current.newestUpdate, wall.newestUpdate)
      // Recalculate anchor as notional-weighted center
      current.anchorPrice = (current.anchorPrice * (current.totalNotional - wall.totalNotional) + wall.anchorPrice * wall.totalNotional) / current.totalNotional
      current.sizeVsMedian = Math.max(current.sizeVsMedian || 0, wall.sizeVsMedian || 0)
    } else {
      clusters.push(current)
      current = { ...wall, priceStart: wall.minPrice, priceEnd: wall.maxPrice }
    }
  }
  clusters.push(current)

  return clusters
}

/**
 * Calculate Bid/Ask Imbalance in a price window
 * imbalance = (totalBids - totalAsks) / (totalBids + totalAsks)
 * > +0.3 = strong buy pressure (bullish)
 * < -0.3 = strong sell pressure (bearish)
 * @param {Array} bidLevels - raw bid levels from stateManager
 * @param {Array} askLevels - raw ask levels from stateManager
 * @returns {number} imbalance -1 to +1
 */
function calcImbalance(bidLevels, askLevels) {
  const totalBids = bidLevels.reduce((sum, l) => sum + l.notional, 0)
  const totalAsks = askLevels.reduce((sum, l) => sum + l.notional, 0)
  const total = totalBids + totalAsks
  if (total === 0) return 0
  return (totalBids - totalAsks) / total
}

/**
 * Persistence tracking β€” stored in a Map outside, passed in
 * Returns enriched wall with status: 'new' | 'confirmed' (>3min) | 'strong' (>10min)
 *
 * Key strategy: bucket index = floor(price / bucketSize) β€” stable across scans
 * Also does fuzzy match: if exact bucket not found, check Β±1 neighbor buckets
 * (wall may shift by 1 bucket between scans due to order changes)
 */
function enrichWithPersistence(wall, persistenceMap, symbol, side, markPrice) {
  const now = Date.now()
  const bucketSize = getBucketSize(markPrice || wall.anchorPrice)
  const bucketIdx = Math.floor(wall.anchorPrice / bucketSize)
  const prefix = `${symbol}:${side}:`

  // Try exact key first, then Β±1 neighbor, then Β±2
  let record = null
  let matchKey = null
  for (const offset of [0, -1, 1, -2, 2]) {
    const tryKey = `${prefix}${bucketIdx + offset}`
    const existing = persistenceMap.get(tryKey)
    if (existing && (now - existing.lastSeen) < 120000) {
      record = existing
      matchKey = tryKey
      break
    }
  }

  const exactKey = `${prefix}${bucketIdx}`

  if (!record) {
    // Brand new wall
    record = { firstSeen: now, lastSeen: now, peakNotional: wall.totalNotional }
    persistenceMap.set(exactKey, record)
  } else {
    // Existing wall β€” update and migrate key if needed
    record.lastSeen = now
    record.peakNotional = Math.max(record.peakNotional, wall.totalNotional)
    if (matchKey && matchKey !== exactKey) {
      // Wall shifted bucket β€” migrate to new key, keep firstSeen
      persistenceMap.delete(matchKey)
      persistenceMap.set(exactKey, record)
    }
  }

  const ageMs = now - record.firstSeen
  const ageMins = ageMs / 60000

  let status = 'new'
  if (ageMins >= 10) status = 'strong'
  else if (ageMins >= 3) status = 'confirmed'

  return {
    ...wall,
    ageMins: Math.round(ageMins * 10) / 10,
    status,
    peakNotional: record.peakNotional
  }
}

/**
 * Clean up old persistence entries (not seen in 2 minutes)
 */
function cleanupPersistence(persistenceMap) {
  const now = Date.now()
  const STALE_MS = 300000 // 5 min β€” wall can disappear briefly and come back
  const MAX_ENTRIES = 10000 // hard cap to prevent unbounded growth

  for (const [key, record] of persistenceMap.entries()) {
    if (now - record.lastSeen > STALE_MS) {
      persistenceMap.delete(key)
    }
  }

  // Hard cap: if still too large after stale cleanup, drop oldest entries
  if (persistenceMap.size > MAX_ENTRIES) {
    const sorted = [...persistenceMap.entries()].sort((a, b) => a[1].lastSeen - b[1].lastSeen)
    const toRemove = sorted.slice(0, persistenceMap.size - MAX_ENTRIES)
    for (const [key] of toRemove) persistenceMap.delete(key)
  }
}

/**
 * Score a wall zone for ranking
 * score = sizeVsMedian Γ— proximityWeight Γ— persistenceMultiplier
 */
function scoreWall(wall, markPrice) {
  const distancePct = Math.abs(wall.anchorPrice - markPrice) / markPrice * 100

  // Size factor (capped at 50 to avoid extreme outliers dominating)
  const sizeFactor = Math.min(wall.sizeVsMedian || 1, 50)

  // Proximity weight: closer = better (1.0 at 0%, 0.2 at 5%)
  const proximityWeight = Math.max(0.2, 1.0 - distancePct * 0.16)

  // Persistence multiplier
  let persistMult = 1.0
  if (wall.status === 'strong') persistMult = 1.5
  else if (wall.status === 'confirmed') persistMult = 1.2

  const score = sizeFactor * proximityWeight * persistMult
  return Math.round(score * 10) / 10
}

/**
 * Main analysis function: process one symbol
 * @param {Object} params
 * @param {string} params.symbol
 * @param {number} params.markPrice
 * @param {Array} params.bidLevels - raw [{price, notional, firstSeen, lastUpdate}]
 * @param {Array} params.askLevels - raw [{price, notional, firstSeen, lastUpdate}]
 * @param {Map} params.persistenceMap - shared persistence store
 * @param {number} params.windowPct - % window around price (default 2)
 * @param {number} params.nSigma - statistical threshold (default 2)
 * @returns {Object} {symbol, support, resistance, imbalance, wallCount, bidWalls, askWalls}
 */
function analyzeSymbol({ symbol, markPrice, bidLevels, askLevels, persistenceMap, windowPct = 2, nSigma = 2.0 }) {
  const bucketSize = getBucketSize(markPrice)

  // Filter levels to window
  const filterWindow = (levels) => levels.filter(l => {
    const dist = Math.abs(l.price - markPrice) / markPrice * 100
    return dist <= windowPct
  })

  const bidsInWindow = filterWindow(bidLevels)
  const asksInWindow = filterWindow(askLevels)

  // Bucket levels
  const bidBuckets = bucketLevels(bidsInWindow, bucketSize)
  const askBuckets = bucketLevels(asksInWindow, bucketSize)
  const allBuckets = [...bidBuckets, ...askBuckets]

  // Detect walls using combined statistics (one threshold for both sides)
  const { walls: rawWalls, median, stddev, threshold } = detectWalls(allBuckets, nSigma)

  // Separate by side (bid = below price, ask = above price)
  const bidWallBuckets = rawWalls.filter(w => w.anchorPrice < markPrice)
  const askWallBuckets = rawWalls.filter(w => w.anchorPrice >= markPrice)

  // Cluster adjacent walls
  const bidClusters = clusterWalls(bidWallBuckets, bucketSize)
  const askClusters = clusterWalls(askWallBuckets, bucketSize)

  // Enrich with persistence
  const enrichedBidWalls = bidClusters.map(w => enrichWithPersistence(w, persistenceMap, symbol, 'bid', markPrice))
  const enrichedAskWalls = askClusters.map(w => enrichWithPersistence(w, persistenceMap, symbol, 'ask', markPrice))

  // Score all walls
  const scoredBidWalls = enrichedBidWalls.map(w => ({
    ...w,
    side: 'bid',
    distancePct: Math.round(Math.abs(w.anchorPrice - markPrice) / markPrice * 10000) / 100,
    score: scoreWall(w, markPrice)
  })).sort((a, b) => b.score - a.score)

  const scoredAskWalls = enrichedAskWalls.map(w => ({
    ...w,
    side: 'ask',
    distancePct: Math.round(Math.abs(w.anchorPrice - markPrice) / markPrice * 10000) / 100,
    score: scoreWall(w, markPrice)
  })).sort((a, b) => b.score - a.score)

  // Imbalance
  const imbalance = calcImbalance(bidsInWindow, asksInWindow)

  // Nearest support = best bid wall (closest to price first, then biggest)
  const support = scoredBidWalls.length > 0 ? scoredBidWalls[0] : null
  // Nearest resistance = best ask wall
  const resistance = scoredAskWalls.length > 0 ? scoredAskWalls[0] : null

  return {
    symbol,
    markPrice,
    support: support ? formatWall(support, markPrice) : null,
    resistance: resistance ? formatWall(resistance, markPrice) : null,
    imbalance: Math.round(imbalance * 1000) / 1000,
    imbalanceLabel: imbalance > 0.3 ? 'BULLISH' : imbalance < -0.3 ? 'BEARISH' : 'NEUTRAL',
    wallCount: scoredBidWalls.length + scoredAskWalls.length,
    bidWalls: scoredBidWalls.slice(0, 5).map(w => formatWall(w, markPrice)),
    askWalls: scoredAskWalls.slice(0, 5).map(w => formatWall(w, markPrice)),
    stats: { median: Math.round(median), stddev: Math.round(stddev), threshold: Math.round(threshold), bucketSize: Math.round(bucketSize * 10000) / 10000 }
  }
}

/**
 * Format a wall for API response
 */
function formatWall(wall, markPrice) {
  return {
    price: Math.round(wall.anchorPrice * 10000) / 10000,
    notional: Math.round(wall.totalNotional),
    distancePct: wall.distancePct,
    sizeVsMedian: Math.round((wall.sizeVsMedian || 0) * 10) / 10,
    levelCount: wall.levelCount,
    ageMins: wall.ageMins || 0,
    status: wall.status || 'new',
    score: wall.score,
    side: wall.side
  }
}

module.exports = {
  analyzeSymbol,
  bucketLevels,
  detectWalls,
  clusterWalls,
  calcImbalance,
  enrichWithPersistence,
  cleanupPersistence,
  scoreWall,
  getBucketSize
}

πŸ“œ Git History

4492574fix+test: comprehensive code audit β€” 11 bugfixes + 148 new tests8 weeks ago
9ee69c8feat: density v2 β€” statistical wall detection + bid/ask imbalance + persistence3 months ago
Show last diff
Loading...