Introducing

Build a Real-Time Dark Pool Scanner with Massive's Websocket API

May 6, 2026

TL;DR: A huge share of U.S. equity volume executes off-exchange, in dark pools and other venues that never show up on a lit exchange feed. Those prints still have to get reported to FINRA, which publishes them through its Trade Reporting Facilities (TRFs). In this post, we walk through what dark pool activity is, how it surfaces in Massive's real-time stock trades WebSocket channel, and a runnable Python scanner (under 150 lines) that streams every TRF-reported trade, filters for institutional-sized notional, and prints a clean tape of block activity you can watch during market hours.

In this post, we'll cover what dark pool trading is and why the data matters, how a dark pool print shows up inside Massive's stock trades stream, and how to build a scanner that filters the firehose down to the prints that actually signal institutional activity.

Who this is for: Quantitative traders, algorithmic trading teams, risk managers, and fintech builders who want real-time visibility into off-exchange block activity across U.S. equities.

Plan requirement: This demo requires a Massive Stocks Advanced plan.

New to WebSockets? If you haven't connected to Massive's WebSocket API before, start with the WebSocket quickstart. It walks through the connect, authenticate, and subscribe pattern, the Python SDK install, and the shape of a minimal client. The rest of this post assumes familiarity with those basics.

What Dark Pool Trading Is

A dark pool is a private trading venue where large orders can be matched without publishing a pre-trade quote. Institutional investors use them to move size without telegraphing their intent. If a pension fund needs to buy ten million shares of AAPL, doing it in the lit market would move the price against them before they finished filling. A dark pool lets them cross size with a counterparty and only report the trade after it executes.

Dark pools are one of several off-exchange venues. Others include single-dealer platforms, internalizers, and OTC market makers that handle retail-internalized flow. Regardless of which off-exchange venue actually clears the trade, it has to be reported to a FINRA Trade Reporting Facility (TRF) within seconds of execution. That reporting is what makes the data visible.

There are three TRFs:

TRF IDVenue Name
201FINRA/NYSE TRF
202FINRA/NASDAQ TRF Carteret
203FINRA/NASDAQ TRF Chicago

For a developer, the interesting part is that every TRF-reported trade flows through the same real-time stock trades feed as on-exchange prints. Filter the firehose for TRF-reported activity and you have a real-time tape of off-exchange volume.

How a Dark Pool Print Shows Up in the Trade Feed

Massive's stock trades WebSocket channel (

T.{ticker}
or
T.*
) publishes every U.S. equity trade as it prints. Each trade message carries fields that tell you where it executed. Two of them matter for dark pool filtering:

  • exchange
    : A numeric ID for the venue that reported the trade. An ID of
    4
    means the trade was reported to a FINRA TRF, indicating off-exchange execution.
  • trf_id
    : When the exchange is
    4
    , this field identifies which specific TRF (201, 202, or 203) handled the report.

A trade is a dark pool (or otherwise off-exchange) print if and only if

exchange == 4
and
trf_id
is present. Everything else (NYSE, Nasdaq, ARCA, IEX, and so on) is lit-exchange activity and gets filtered out.

From there, a notional threshold (price multiplied by size) is the cleanest way to narrow the feed down to institutional-sized prints. The default in this scanner is $100,000, which cuts out most of the small-lot retail fills and leaves you with the block-sized activity that carries real signal.

Cloning the Example Repo

The scanner is published in Massive's community GitHub repo. You'll need Python 3.10 or later, uv for dependency management, and a Massive API key on the Stocks Advanced plan.

git clone https://github.com/massive-com/community.git
cd community/examples/websocket/dark-pool-scanner
uv sync
cp .env.example .env
# Add your MASSIVE_API_KEY to .env

The

pyproject.toml
declares two dependencies: the Massive Python SDK (
massive
) and
python-dotenv
for loading environment variables from the
.env
file.

Walking Through the Code

The full scanner is a single

main.py
file. We'll build it up section by section.

Imports and configuration

#!/usr/bin/env python3
import argparse
import os
from datetime import datetime
from zoneinfo import ZoneInfo

from dotenv import load_dotenv
from massive import WebSocketClient
from massive.websocket.models import EquityTrade, Market

load_dotenv()

API_KEY = os.getenv("MASSIVE_API_KEY")
if not API_KEY:
    raise ValueError(
        "MASSIVE_API_KEY not found in environment variables. "
        "Please set it in your .env file."
    )

EASTERN = ZoneInfo("America/New_York")

A few notes:

  • load_dotenv()
    reads
    MASSIVE_API_KEY
    from your
    .env
    file so you never hard-code secrets into source code.
  • zoneinfo
    is built into Python 3.9+ and handles timezone conversion without any external dependency.
  • EquityTrade
    is the SDK model for stock trade messages. It maps the raw WebSocket fields (like
    x
    ,
    p
    ,
    s
    ,
    trfi
    ) to readable Python attributes (
    exchange
    ,
    price
    ,
    size
    ,
    trf_id
    ).

TRF venue mapping

Each TRF-reported trade carries an ID telling you which facility handled the report. Mapping those IDs to human-readable names makes the output easier to scan:

TRF_NAMES = {
    201: "FINRA/NYSE TRF",
    202: "FINRA/NASDAQ TRF Carteret",
    203: "FINRA/NASDAQ TRF Chicago",
}

Timestamp formatting

Trades arrive with millisecond epoch timestamps. Convert them to Eastern time so they align with U.S. equity market hours:

def fmt_time(ms: int) -> str:
    """Convert a millisecond epoch timestamp to an Eastern Time string."""
    return datetime.fromtimestamp(ms / 1000, tz=EASTERN).strftime(
        "%Y-%m-%d %H:%M:%S %Z"
    )

Pretty-print a trade

Notional is the total dollar value of the trade: price per share multiplied by the number of shares. The display function formats each field into a readable block:

def show_trade(trade: EquityTrade) -> None:
    """Pretty-print a single dark pool trade."""
    notional = trade.price * trade.size
    venue = TRF_NAMES.get(trade.trf_id, f"TRF {trade.trf_id}")
    conditions = getattr(trade, "conditions", None)

    pl = lambda label, value, w=12: f"{(label + ':'):<{w}} {value}"

    print(
        "\n".join(
            [
                "",
                pl("Symbol", trade.symbol),
                pl("Price", f"${trade.price:,.2f}"),
                pl("Size", f"{trade.size:,} shares"),
                pl("Notional", f"${notional:,.2f}"),
                pl("Venue", venue),
                pl("Timestamp", fmt_time(trade.timestamp)),
                pl("Conditions", conditions if conditions else "N/A"),
                "",
                "------------------------",
            ]
        ),
        flush=True,
    )

The

pl
lambda is a small formatting helper that left-aligns each label for a clean columnar layout.

Dark pool detection and the main loop

This is where the filtering logic sits. The scanner does three things:

  1. Subscribes to stock trades via the WebSocket (
    T.*
    for all tickers, or
    T.{SYMBOL}
    for specific ones).
  2. For each incoming trade, checks whether it was reported to a TRF (meaning it executed off-exchange).
  3. Computes notional value and only displays trades above the threshold.
def main():
    parser = argparse.ArgumentParser(
        description="Stream dark pool (TRF) trades filtered by notional value."
    )
    parser.add_argument(
        "--min-notional",
        type=float,
        default=100_000,
        help="Minimum notional value in USD (default: 100,000)",
    )
    parser.add_argument(
        "--ticker",
        nargs="+",
        help="One or more tickers to watch (default: all)",
    )
    args = parser.parse_args()

    if args.ticker:
        subscriptions = [f"T.{t.upper()}" for t in args.ticker]
    else:
        subscriptions = ["T.*"]

    client = WebSocketClient(
        api_key=API_KEY, market=Market.Stocks, subscriptions=subscriptions
    )

    tickers_label = (
        ", ".join(t.upper() for t in args.ticker) if args.ticker else "all tickers"
    )
    print(
        f"[info] connecting to stock trade stream ({tickers_label})"
        f" | filter: TRF trades >= ${args.min_notional:,.0f} notional",
        flush=True,
    )

    def handler(msgs):
        for trade in msgs:
            if not isinstance(trade, EquityTrade):
                continue
            if trade.exchange != 4 or trade.trf_id is None:
                continue
            if trade.price * trade.size < args.min_notional:
                continue
            show_trade(trade)

    try:
        client.run(handle_msg=handler)
    except KeyboardInterrupt:
        pass
    finally:
        client.close()

if __name__ == "__main__":
    main()

The dark pool detection is two lines inside the handler:

  • trade.exchange == 4
    : the trade was reported to a TRF, meaning it executed off-exchange.
  • trade.trf_id is not None
    : confirms which specific TRF processed the report.

Only trades that satisfy both conditions are off-exchange prints. Everything else (trades on NYSE, Nasdaq, ARCA, IEX, and other lit venues) gets filtered out. The notional threshold then narrows the feed to institutional-sized activity.

Running It

uv run main.py

By default, the scanner shows only TRF trades with a notional value of $100,000 or more. You can adjust the threshold or filter to specific tickers:

# Lower the notional threshold to $50,000
uv run main.py --min-notional 50000

# Watch only NVDA and TSLA dark pool prints
uv run main.py --ticker NVDA TSLA

# Combine both
uv run main.py --ticker AAPL --min-notional 200000

Press Ctrl+C to stop the stream.

Example Output

Symbol:      NVDA
Price:       $172.62
Size:        5,000 shares
Notional:    $863,100.00
Venue:       FINRA/NASDAQ TRF Carteret
Timestamp:   2026-01-06 10:31:22 EST
Conditions:  N/A

------------------------

Each print shows the ticker, price per share, block size, total dollar value, the TRF venue that reported it, and a timestamp in Eastern time.

What You Can Build From Here

The scanner as written is a starting point. A few directions that extend it naturally:

  • Write every displayed trade to a CSV or JSON log so you can analyze dark pool activity across sessions and compare day-over-day block volume.
  • Add alerts (sound, desktop notification, Slack, or webhook) when a trade crosses a higher notional threshold, so a single $5M+ print gets your attention even when the scanner is running in the background.
  • Track cumulative off-exchange volume per ticker to spot which names are seeing the heaviest TRF activity relative to their lit-tape volume.
  • Build a dashboard that visualizes dark pool flow alongside on-exchange volume for the same tickers. The contrast is often where the signal is.
  • Join TRF prints to real-time quote and aggregate data from Massive's
    Q
    and
    AM
    channels to study how off-exchange activity precedes or trails lit-exchange price moves.

Get Started

Disclaimer

This content is for educational purposes only. Nothing in this post constitutes investment advice or a recommendation to buy or sell any securities or other financial instruments. Massive is a market data provider, not a broker-dealer, exchange, or investment adviser. Market data accessed through Massive may originate from third-party exchanges and data providers or may be derived or calculated by Massive; in either case, it is subject to the applicable terms of your Massive subscription agreement. The data and code samples provided by Massive are offered on an "as-is" basis without any warranty of accuracy, completeness, or timeliness. You are solely responsible for your use of the data provided by Massive and for compliance with all applicable terms and conditions, laws, and data licensing requirements.

From the blog

See what's happening at Massive