Trade Flow Imbalance Detector
Overview
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
trademessages - 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?
What this detector measures
Each trade includes:
pricesizetaker_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
What you will build
This tutorial uses one active symbol to keep the output readable:
BINANCE_SPOT_AAVE_USDT
You can replace it later with another symbol.
Prerequisites
You need:
- Node.js installed
- a CoinAPI API key
- terminal access
This tutorial uses the Market Data WebSocket endpoint:
1wss://ws.coinapi.io/v1/Step 1 — Create the project folder
Open your terminal and run:
1mkdir trade-flow-imbalance-demo
2cd trade-flow-imbalance-demoStep 2 — Create package.json
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.
Step 3 — Install dependencies
Run:
1npm installThis installs:
wsfor the WebSocket clientdotenvfor loading your API key from.env
Step 4 — Create .env
Create a new file named .env and paste this into it:
1COINAPI_KEY=YOUR_API_KEY_HEREReplace YOUR_API_KEY_HERE with your actual API key, then save the file.
Step 5 — Create index.js
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.
Step 6 — Understand the incoming trade message
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:
sizeis the amount tradedtaker_sidedecides whether the trade counts as buy flow or sell flowtime_coinapiis used for the rolling windowpriceis shown as the latest price in the terminal
Step 7 — Run the script
In your project folder, run:
1node index.jsYou should see:
1Connected to CoinAPI WebSocket
2Subscription sent
3Waiting for live trade messages...After trades begin streaming, the terminal will update continuously.
Step 8 — What the script does in real time
Once running, the script:
- connects to the WebSocket endpoint
- subscribes to
tradedata for your selected symbol - receives trades as they happen
- stores them temporarily in memory
- removes trades outside the rolling window
- calculates:
- buy volume
- sell volume
- trade counts
- price change within the window
- imbalance ratio
- evaluates the signal using:
- minimum trade threshold
- persistence check
- price context
- prints a live summary every second
Step 9 — Sample terminal output
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.
Step 10 — How the rolling window works
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.
Why this matters
- short window → faster but noisier
- longer window → smoother but slower
Step 11 — Why minimum trade count is used
The script requires a minimum number of trades:
1const MIN_TRADES_IN_WINDOW = 20;Purpose
- 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
Step 12 — Why persistence is used
The script checks whether the same directional signal appears repeatedly before confirming it.
1const REQUIRED_PERSISTENCE_CYCLES = 3;Purpose
- avoids reacting to brief spikes
- ensures the signal is sustained
Without this, a single burst of trades could incorrectly appear as a strong signal.
Step 13 — Why price context is included
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
Examples
- 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.
Step 14 — Change the symbol
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];Step 15 — Adjust sensitivity
You can tune the detector depending on your needs.
Faster, more reactive
1const WINDOW_MS = 10_000;More stable
1const WINDOW_MS = 60_000;Lower activity markets
1const MIN_TRADES_IN_WINDOW = 10;High-confidence signals
1const MIN_TRADES_IN_WINDOW = 30;Stricter persistence
1const REQUIRED_PERSISTENCE_CYCLES = 5;Step 16 — Troubleshooting
No trades appearing
- try a more active symbol
- check symbol format
Signal stuck at “Insufficient Data”
- lower minimum trade threshold
- or increase window size
Output updates too quickly
- increase window size
- increase persistence cycles
Step 17 — Project structure
Your folder should look like:
trade-flow-imbalance-demo/
├── .env
├── index.js
├── package-lock.json
└── package.json
Congratulations!
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.