Trade Flow Imbalance Detector

This tutorial shows how to build a real-time Trade Flow Imbalance Detector using CoinAPI’s Market Data WebSocket trade stream.

The application will:

  • connect to the WebSocket feed
  • subscribe to live trade messages
  • track trades in a rolling window
  • calculate buy and sell pressure using taker_side
  • apply filters so the signal is more reliable
  • compare pressure against recent price movement
  • print a live signal in the terminal

By the end, the script will help users answer questions like:

  • Are buyers or sellers dominating right now?
  • Is the pressure strong enough to matter?
  • Is price moving in the same direction as the flow?
  • Is the signal persistent or only temporary?

Each trade includes:

  • price
  • size
  • taker_side

The script uses those fields to calculate:

  • Buy Volume = sum of sizes where taker_side = BUY
  • Sell Volume = sum of sizes where taker_side = SELL
  • Net Imbalance = Buy Volume - Sell Volume
  • Imbalance Ratio = (Buy Volume - Sell Volume) / (Buy Volume + Sell Volume)

A positive ratio means buyer-initiated trades dominate.

A negative ratio means seller-initiated trades dominate.

To make the output more useful, this tutorial adds:

  • a minimum trade count
  • a signal persistence rule
  • price confirmation

This tutorial uses one active symbol to keep the output readable:

  • BINANCE_SPOT_AAVE_USDT

You can replace it later with another symbol.

You need:

  • Node.js installed
  • a CoinAPI API key
  • terminal access

This tutorial uses the Market Data WebSocket endpoint:

1wss://ws.coinapi.io/v1/

Open your terminal and run:

1mkdir trade-flow-imbalance-demo
2cd trade-flow-imbalance-demo

Create a new file named package.json and paste this into it:

1{
2  "name":"trade-flow-imbalance-demo",
3  "version":"1.0.0",
4  "description":"Real-time trade flow imbalance detector using CoinAPI WebSocket",
5  "main":"index.js",
6  "type":"commonjs",
7  "dependencies": {
8    "dotenv":"^16.4.5",
9    "ws":"^8.18.0"
10  }
11}

Save the file.

Run:

1npm install

This installs:

  • ws for the WebSocket client
  • dotenv for loading your API key from .env

Create a new file named .env and paste this into it:

1COINAPI_KEY=YOUR_API_KEY_HERE

Replace YOUR_API_KEY_HERE with your actual API key, then save the file.

Create a new file named index.js and paste the full script below.

1require("dotenv").config();
2const WebSocket = require("ws");
3
4const API_KEY = process.env.COINAPI_KEY;
5const WS_URL = "wss://ws.coinapi.io/v1/";
6
7if (!API_KEY) {
8  console.error("Missing COINAPI_KEY in .env file");
9  process.exit(1);
10}
11
12// -------------------------------------
13// CONFIGURATION
14// -------------------------------------
15
16// Start with one symbol to keep the output easy to read.
17const SUBSCRIBED_SYMBOLS = [
18  "BINANCE_SPOT_AAVE_USDT"
19];
20
21// Rolling trade window.
22const WINDOW_MS = 30_000;
23
24// Require at least this many trades in the current window
25// before showing a directional signal.
26const MIN_TRADES_IN_WINDOW = 20;
27
28// Require the same directional condition to hold
29// for this many consecutive render cycles before
30// escalating to a stronger signal.
31const REQUIRED_PERSISTENCE_CYCLES = 3;
32
33// Re-render terminal every second even if no new trades arrive.
34// This keeps the rolling window current.
35const RENDER_INTERVAL_MS = 1000;
36
37// -------------------------------------
38// IN-MEMORY STATE
39// -------------------------------------
40
41const trades = [];
42const signalHistory = [];
43
44// -------------------------------------
45// HELPERS
46// -------------------------------------
47
48function parseTradeTime(msg) {
49  const parsed = Date.parse(msg.time_coinapi || msg.time_exchange);
50  return Number.isNaN(parsed) ? null : parsed;
51}
52
53function addTrade(msg) {
54  const timeMs = parseTradeTime(msg);
55  if (timeMs === null) return;
56
57  trades.push({
58    symbol_id: msg.symbol_id,
59    time_ms: timeMs,
60    price: Number(msg.price),
61    size: Number(msg.size),
62    taker_side: msg.taker_side,
63    sequence: msg.sequence
64  });
65}
66
67function pruneOldTrades(nowMs) {
68  const cutoff = nowMs - WINDOW_MS;
69
70  while (trades.length > 0 && trades[0].time_ms < cutoff) {
71    trades.shift();
72  }
73}
74
75function calculateMetrics() {
76  let buyVolume = 0;
77  let sellVolume = 0;
78  let buyCount = 0;
79  let sellCount = 0;
80  let firstPrice = null;
81  let lastPrice = null;
82  let buyNotional = 0;
83  let sellNotional = 0;
84
85  for (const trade of trades) {
86    if (firstPrice === null) {
87      firstPrice = trade.price;
88    }
89    lastPrice = trade.price;
90
91    const notional = trade.price * trade.size;
92
93    if (trade.taker_side === "BUY") {
94      buyVolume += trade.size;
95      buyCount += 1;
96      buyNotional += notional;
97    } else if (trade.taker_side === "SELL") {
98      sellVolume += trade.size;
99      sellCount += 1;
100      sellNotional += notional;
101    }
102  }
103
104  const totalVolume = buyVolume + sellVolume;
105  const totalTrades = buyCount + sellCount;
106  const netImbalance = buyVolume - sellVolume;
107  const imbalanceRatio = totalVolume > 0 ? netImbalance / totalVolume : 0;
108
109  const priceChange = firstPrice !== null && lastPrice !== null
110    ? lastPrice - firstPrice
111    : 0;
112
113  const priceChangePct = firstPrice !== null && firstPrice !== 0
114    ? (priceChange / firstPrice) * 100
115    : 0;
116
117  return {
118    buyVolume,
119    sellVolume,
120    buyCount,
121    sellCount,
122    totalTrades,
123    totalVolume,
124    netImbalance,
125    imbalanceRatio,
126    firstPrice,
127    lastPrice,
128    priceChange,
129    priceChangePct,
130    buyNotional,
131    sellNotional
132  };
133}
134
135function getRawPressureDirection(metrics) {
136  if (metrics.totalTrades < MIN_TRADES_IN_WINDOW) {
137    return "INSUFFICIENT_DATA";
138  }
139
140  if (metrics.imbalanceRatio >= 0.25) return "STRONG_BUY";
141  if (metrics.imbalanceRatio >= 0.10) return "BUY";
142  if (metrics.imbalanceRatio <= -0.25) return "STRONG_SELL";
143  if (metrics.imbalanceRatio <= -0.10) return "SELL";
144  return "BALANCED";
145}
146
147function updateSignalHistory(rawDirection) {
148  signalHistory.push(rawDirection);
149
150  if (signalHistory.length > REQUIRED_PERSISTENCE_CYCLES) {
151    signalHistory.shift();
152  }
153}
154
155function hasPersistentDirection(direction) {
156  if (signalHistory.length < REQUIRED_PERSISTENCE_CYCLES) {
157    return false;
158  }
159
160  return signalHistory.every((value) => value === direction);
161}
162
163function getFlowComment(metrics, rawDirection) {
164  const priceUp = metrics.priceChangePct > 0.02;
165  const priceDown = metrics.priceChangePct < -0.02;
166
167  if (rawDirection === "INSUFFICIENT_DATA") {
168    return "Not enough trades in the current window yet.";
169  }
170
171  if (rawDirection === "STRONG_BUY" || rawDirection === "BUY") {
172    if (priceUp) return "Buy pressure is aligned with rising price.";
173    if (priceDown) return "Buy pressure is present, but price is falling. Possible absorption or lag.";
174    return "Buy pressure is present, but price is mostly flat.";
175  }
176
177  if (rawDirection === "STRONG_SELL" || rawDirection === "SELL") {
178    if (priceDown) return "Sell pressure is aligned with falling price.";
179    if (priceUp) return "Sell pressure is present, but price is rising. Possible absorption or lag.";
180    return "Sell pressure is present, but price is mostly flat.";
181  }
182
183  return "Trade flow is relatively balanced.";
184}
185
186function getFinalSignal(metrics) {
187  const rawDirection = getRawPressureDirection(metrics);
188  updateSignalHistory(rawDirection);
189
190  if (rawDirection === "INSUFFICIENT_DATA") {
191    return {
192      rawDirection,
193      label: "Insufficient Data",
194      comment: getFlowComment(metrics, rawDirection)
195    };
196  }
197
198  if (rawDirection === "BALANCED") {
199    return {
200      rawDirection,
201      label: "Balanced Flow",
202      comment: getFlowComment(metrics, rawDirection)
203    };
204  }
205
206  // Require persistence before keeping the stronger classifications.
207  if (!hasPersistentDirection(rawDirection)) {
208    if (rawDirection === "STRONG_BUY" || rawDirection === "BUY") {
209      return {
210        rawDirection,
211        label: "Buy Pressure (Unconfirmed)",
212        comment: "Buy pressure is present, but it has not persisted long enough yet."
213      };
214    }
215
216    if (rawDirection === "STRONG_SELL" || rawDirection === "SELL") {
217      return {
218        rawDirection,
219        label: "Sell Pressure (Unconfirmed)",
220        comment: "Sell pressure is present, but it has not persisted long enough yet."
221      };
222    }
223  }
224
225  if (rawDirection === "STRONG_BUY") {
226    return {
227      rawDirection,
228      label: "Strong Buy Pressure",
229      comment: getFlowComment(metrics, rawDirection)
230    };
231  }
232
233  if (rawDirection === "BUY") {
234    return {
235      rawDirection,
236      label: "Buy Pressure",
237      comment: getFlowComment(metrics, rawDirection)
238    };
239  }
240
241  if (rawDirection === "STRONG_SELL") {
242    return {
243      rawDirection,
244      label: "Strong Sell Pressure",
245      comment: getFlowComment(metrics, rawDirection)
246    };
247  }
248
249  if (rawDirection === "SELL") {
250    return {
251      rawDirection,
252      label: "Sell Pressure",
253      comment: getFlowComment(metrics, rawDirection)
254    };
255  }
256
257  return {
258    rawDirection,
259    label: "Balanced Flow",
260    comment: getFlowComment(metrics, rawDirection)
261  };
262}
263
264function render() {
265  const nowMs = Date.now();
266  pruneOldTrades(nowMs);
267
268  const metrics = calculateMetrics();
269  const signal = getFinalSignal(metrics);
270
271  process.stdout.write("\x1Bc");
272  console.log("=== Trade Flow Imbalance Detector ===");
273  console.log("Subscribed symbols:");
274  for (const symbol of SUBSCRIBED_SYMBOLS) {
275    console.log(`- ${symbol}`);
276  }
277
278  console.log(`\nRolling window: last ${WINDOW_MS / 1000} seconds`);
279  console.log(`Minimum trades required: ${MIN_TRADES_IN_WINDOW}`);
280  console.log(`Trades in window: ${metrics.totalTrades}`);
281  console.log(`Window start price: ${metrics.firstPrice ?? "-"}`);
282  console.log(`Window last price:  ${metrics.lastPrice ?? "-"}`);
283
284  console.log("\n=== VOLUME ===");
285  console.log(`Buy Volume:   ${metrics.buyVolume.toFixed(8)}`);
286  console.log(`Sell Volume:  ${metrics.sellVolume.toFixed(8)}`);
287  console.log(`Total Volume: ${metrics.totalVolume.toFixed(8)}`);
288
289  console.log("\n=== TRADE COUNT ===");
290  console.log(`Buy Trades:   ${metrics.buyCount}`);
291  console.log(`Sell Trades:  ${metrics.sellCount}`);
292
293  console.log("\n=== PRICE CONTEXT ===");
294  console.log(`Price Change:     ${metrics.priceChange.toFixed(8)}`);
295  console.log(`Price Change %:   ${metrics.priceChangePct.toFixed(4)}%`);
296
297  console.log("\n=== IMBALANCE ===");
298  console.log(`Net Imbalance:    ${metrics.netImbalance.toFixed(8)}`);
299  console.log(`Imbalance Ratio:  ${metrics.imbalanceRatio.toFixed(4)}`);
300  console.log(`Signal:           ${signal.label}`);
301  console.log(`Interpretation:   ${signal.comment}`);
302
303  console.log("\nPress CTRL + C to stop.");
304}
305
306const ws = new WebSocket(WS_URL);
307
308ws.on("open", () => {
309  console.log("Connected to CoinAPI WebSocket");
310
311  const helloMessage = {
312    type: "hello",
313    apikey: API_KEY,
314    heartbeat: false,
315    subscribe_data_type: ["trade"],
316    subscribe_filter_symbol_id: SUBSCRIBED_SYMBOLS
317  };
318
319  ws.send(JSON.stringify(helloMessage));
320  console.log("Subscription sent");
321  console.log("Waiting for live trade messages...");
322});
323
324ws.on("message", (raw) => {
325  try {
326    const msg = JSON.parse(raw.toString());
327
328    if (msg.type !== "trade") return;
329
330    addTrade(msg); // only store data
331  } catch (error) {
332    console.error("Failed to parse message:", error.message);
333  }
334});
335
336ws.on("error", (error) => {
337  console.error("WebSocket error:", error.message);
338});
339
340ws.on("close", () => {
341  console.log("WebSocket connection closed");
342});
343
344// Render on a timer so the rolling window remains accurate
345// even during quieter periods.
346setInterval(render, RENDER_INTERVAL_MS);

Save the file.

A trade message from CoinAPI includes fields like:

1{
2  "type": "trade",
3  "symbol_id": "BITSTAMP_SPOT_BTC_USD",
4  "sequence": 2323346,
5  "time_exchange": "2013-09-28T22:40:50.0000000Z",
6  "time_coinapi": "2013-09-28T22:40:52.3763342Z",
7  "uuid": "770C7A3B-7258-4441-8182-83740F3E2457",
8  "price": 770.000000000,
9  "size": 0.050000000,
10  "taker_side": "BUY"
11}

CoinAPI’s docs and examples show this same trade event structure, including taker_side, price, size, symbol_id, timestamps, and sequence.

For this tutorial:

  • size is the amount traded
  • taker_side decides whether the trade counts as buy flow or sell flow
  • time_coinapi is used for the rolling window
  • price is shown as the latest price in the terminal

In your project folder, run:

1node index.js

You should see:

1Connected to CoinAPI WebSocket
2Subscription sent
3Waiting for live trade messages...

After trades begin streaming, the terminal will update continuously.

Once running, the script:

  1. connects to the WebSocket endpoint
  2. subscribes to trade data for your selected symbol
  3. receives trades as they happen
  4. stores them temporarily in memory
  5. removes trades outside the rolling window
  6. calculates:
    • buy volume
    • sell volume
    • trade counts
    • price change within the window
    • imbalance ratio
  7. evaluates the signal using:
    • minimum trade threshold
    • persistence check
    • price context
  8. prints a live summary every second

Because this uses live data, your output will vary. A sample might look like:

1=== Trade Flow Imbalance Detector ===
2Subscribed symbols:
3- BINANCE_SPOT_AAVE_USDT
4
5Rolling window: last 30 seconds
6Minimum trades required: 20
7Trades in window: 58
8Window start price: 96.52
9Window last price:  96.34
10
11=== VOLUME ===
12Buy Volume:   132.88000000
13Sell Volume:  241.19000000
14Total Volume: 374.07000000
15
16=== TRADE COUNT ===
17Buy Trades:   21
18Sell Trades:  37
19
20=== PRICE CONTEXT ===
21Price Change:     -0.18000000
22Price Change %:   -0.1864%
23
24=== IMBALANCE ===
25Net Imbalance:    -108.31000000
26Imbalance Ratio:  -0.2896
27Signal:           Strong Sell Pressure
28Interpretation:   Sell pressure is aligned with falling price.
29
30Press CTRL + C to stop.

This output shows:

  • more sell trades than buy trades
  • higher sell volume
  • negative imbalance ratio
  • price moving downward

Together, these indicate consistent sell-side pressure.

The script only considers trades within a recent time window:

1const WINDOW_MS = 30_000;

This means:

  • only trades from the last 30 seconds are used
  • older trades are removed continuously

This keeps the signal focused on recent activity.

  • short window → faster but noisier
  • longer window → smoother but slower

The script requires a minimum number of trades:

1const MIN_TRADES_IN_WINDOW = 20;
  • avoids signals based on very few trades
  • prevents misleading results on low activity

If the number of trades is below this threshold, the signal will show:

1Signal: Insufficient Data

The script checks whether the same directional signal appears repeatedly before confirming it.

1const REQUIRED_PERSISTENCE_CYCLES = 3;
  • avoids reacting to brief spikes
  • ensures the signal is sustained

Without this, a single burst of trades could incorrectly appear as a strong signal.

The script compares:

  • first price in the window
  • last price in the window

This allows it to determine whether:

  • pressure aligns with price movement
  • or if there is divergence
  • sell pressure + price dropping → strong confirmation
  • sell pressure + price rising → possible absorption
  • buy pressure + flat price → weak signal

This adds important context to the imbalance.

To use a different asset, edit this section:

1const SUBSCRIBED_SYMBOLS = [
2  "BINANCE_SPOT_AAVE_USDT"
3];

Example:

1const SUBSCRIBED_SYMBOLS = [
2  "BINANCE_SPOT_BTC_USDT"
3];

You can tune the detector depending on your needs.

1const WINDOW_MS = 10_000;
1const WINDOW_MS = 60_000;
1const MIN_TRADES_IN_WINDOW = 10;
1const MIN_TRADES_IN_WINDOW = 30;
1const REQUIRED_PERSISTENCE_CYCLES = 5;
  • try a more active symbol
  • check symbol format
  • lower minimum trade threshold
  • or increase window size
  • increase window size
  • increase persistence cycles

Your folder should look like:

trade-flow-imbalance-demo/
├── .env
├── index.js
├── package-lock.json
└── package.json

You now have a real-time Trade Flow Imbalance Detector that goes beyond basic volume tracking.

You were able to:

  • stream live trade data using WebSocket
  • classify trades into buy and sell pressure
  • calculate imbalance in a rolling time window
  • filter out weak signals using minimum trade thresholds
  • confirm signals using persistence
  • interpret pressure using price movement

This creates a more reliable and practical signal that can be used for:

  • short-term market monitoring
  • momentum confirmation
  • identifying potential turning points

From here, you can extend this by:

  • adding charts for visualization
  • tracking multiple symbols separately
  • triggering alerts when strong pressure persists
  • combining this with order book data for deeper analysis

You now have a solid foundation for building real-time trade flow analytics.