Strategy · 7 min read

Detecting choppy markets with volume imbalance

How to use buy/sell volume imbalance as a chop filter, with the exact threshold logic from poly5m-v4 and poly15m-v6.

What chop costs you

A choppy market is one where price oscillates without direction. In a directional strategy, entering during chop means paying taker fees on a trade that has roughly coin-flip odds. Over 20 such trades at 1.5% fee per side, you have spent 30% of position size with no edge.

The goal of a chop filter is not to win every trade -- it is to sit out the trades where your signal has no predictive value.

Volume imbalance defined

Volume imbalance measures whether buyers or sellers are more aggressive on an exchange order book within a recent window. The canonical Binance calculation uses the aggTrade stream:

imbalance = (buyVolume - sellVolume) / (buyVolume + sellVolume)

The result is in [-1, +1]. +0.7 means 85% of the volume was buy-initiated. -0.7 means 85% was sell-initiated. Near zero means balanced -- neither side is dominating.

Collecting aggTrade data via WebSocket

const { WebSocket } = require('ws')

let buyVol = 0
let sellVol = 0

function startImbalanceTracker(symbol = 'BTCUSDT', windowMs = 60_000) {
  const ws = new WebSocket(`wss://stream.binance.com:9443/ws/${symbol.toLowerCase()}@aggTrade`)

  ws.on('message', (raw) => {
    const trade = JSON.parse(raw)
    const qty = parseFloat(trade.q)
    if (trade.m) {
      // maker was the buyer → this is a sell-initiated trade
      sellVol += qty
    } else {
      buyVol += qty
    }
  })

  // Reset window periodically
  setInterval(() => {
    buyVol = 0
    sellVol = 0
  }, windowMs)

  return {
    getImbalance() {
      const total = buyVol + sellVol
      if (total === 0) return 0
      return (buyVol - sellVol) / total
    },
  }
}

Note: trade.m being true means the trade matched against a resting buy (maker), making the aggressive side the seller.

The skip threshold

Our bots skip a trade when |imbalance| < threshold. The threshold is set so that directional signals below it are likely noise. In poly5m-v4 the default is 0.15: if neither buyers nor sellers are clearly dominant, skip.

const IMBALANCE_THRESHOLD = 0.15

function shouldSkipChop(imbalance) {
  return Math.abs(imbalance) < IMBALANCE_THRESHOLD
}

Higher thresholds (0.25-0.35) make the bot more selective but reduce trade frequency. During backtesting on BTC 5-minute data, thresholds in the 0.15-0.20 range gave the best risk-adjusted returns.

Combining imbalance with price momentum

Volume imbalance alone can give false signals on large single trades. Combining it with a price momentum condition reduces noise:

function hasDirectionalSignal(
  priceChangePct,
  imbalance,
  priceThreshold = 0.0005,
  imbalanceThreshold = 0.15,
) {
  const strongBuy = priceChangePct > priceThreshold && imbalance > imbalanceThreshold
  const strongSell = priceChangePct < -priceThreshold && imbalance < -imbalanceThreshold
  return { strongBuy, strongSell, isChop: !strongBuy && !strongSell }
}

This requires both price movement AND volume confirmation. If only one is present, the filter treats it as chop and skips.

ATR as a secondary chop indicator

Average True Range (ATR) measures recent volatility. Very low ATR suggests the asset is flat and unlikely to provide the required move before expiry. Poly5m-v4 uses a 14-period ATR on 1-minute Binance candles as a secondary gate:

function atr(highs, lows, closes, period = 14) {
  const trs = highs.map((h, i) => {
    if (i === 0) return h - lows[i]
    return Math.max(h - lows[i], Math.abs(h - closes[i - 1]), Math.abs(lows[i] - closes[i - 1]))
  })
  // Simple EMA-style ATR
  let atrVal = trs.slice(0, period).reduce((a, b) => a + b, 0) / period
  for (let i = period; i < trs.length; i++) {
    atrVal = (atrVal * (period - 1) + trs[i]) / period
  }
  return atrVal
}

Skip if currentATR < MIN_ATR_USD, where MIN_ATR_USD is set to the minimum move needed to cover fees and hit the target profit margin.

Logging skips

Always log chop skips. If you are skipping 90% of candles, either the threshold is too high or the market is genuinely directionless and not worth trading at all. Review the skip ratio weekly.

if (shouldSkipChop(imbalance)) {
  console.log(`[CHOP] imbalance=${imbalance.toFixed(3)}, skipping`)
  return
}

Summary

  • Volume imbalance = (buyVol - sellVol) / totalVol, calculated from Binance aggTrade stream.
  • Skip when |imbalance| < 0.15 (tune based on your backtest).
  • Combine with price momentum: require both for a directional signal.
  • Use ATR as a secondary gate to skip flat market periods entirely.
  • Log all chop skips and review the ratio periodically.

Related bots