DEV Community

Cover image for Building a Pre-Trade Microstructure Risk Gate in Python: Hysteresis, EWMA Spikes and Taker-Sweep Detection
Andrew Rodn for FollowSM

Posted on Edited on

Building a Pre-Trade Microstructure Risk Gate in Python: Hysteresis, EWMA Spikes and Taker-Sweep Detection

A step-by-step guide to protecting your trading bot from toxic order flow and adverse selection without building complex streaming infrastructure.

The research parts of this series ended in the same place: the signals that matter are about timing, and the most useful one was the bluntest. In Part 5's two-week capture, a single large Binance taker sweep warned of Polymarket repricing better than VPIN or book imbalance did, and pausing after sweeps was the only trigger that clearly beat randomly timed pauses (on BTC).

This part is engineering, not research. It builds the thing most trading bots actually need: a pre-trade gate. Before every order, ask "is the book around this symbol in a state I shouldn't trade into?", get a yes or no with reasons, and fail closed when you can't tell. No streaming infrastructure, no event loop, about 120 lines of Python on followsm-sdk.

TL;DR

  • Reuse your HTTPS connection. A fresh connection per check cost 160 ms at the median (measured, keyed). A kept-alive requests.Session cost 59 ms. For a per-order check, that is the biggest single win.
  • Cache for one second. Snapshots were 0.59 s old at the median when they reached us (p99 1.65 s). Re-fetching a symbol more often than once a second buys nothing; a 1 s cache turns a burst of orders into one request.
  • Four triggers, two kinds. A recent taker sweep and a sudden jump in the ±1% book are events. VPIN percentile and a book imbalance in the tail of its own history are regimes. In our live test, VPIN kept BTC and SOL closed for a full minute. Decide which you want before you wire them in.
  • Hysteresis and fail-closed are not optional. A gate that re-opens at the trip level flaps; a gate that allows on a timeout trades blind.
  • Know what REST can't do. On BTC, Polymarket's crypto books reprice a median 227 ms after a sweep. A REST gate can stop you trading in the seconds after a sweep; it can't beat the first fill. That needs a stream.

The code below runs against the live API, and the full module with its offline tests is in hft-toxicity-circuit-breaker/examples. Every number in this article was measured on 1 November 2026 from a laptop in Europe; yours will differ, and the article shows how to measure them.


1. What the gate decides

gate = RiskGate(session_fetcher(api_key=os.environ.get("FOLLOWSM_API_KEY")))

decision = gate.check("BTCUSDT")
if decision.allow:
    place_order(...)
else:
    log.info("skipped BTCUSDT: %s", "; ".join(decision.reasons))
Enter fullscreen mode Exit fullscreen mode

check() returns allow plus human-readable reasons. It closes on any of four triggers, all read from one ConfluenceSnapshot:

trigger field kind what Part 5 found
taker sweep in the last few seconds sweep_active, last_sweep_side, last_sweep_notional_usd event Polymarket repriced a median 44–227 ms after a ≥ $100k sweep; pausing after sweeps beat random pauses on BTC, not on ETH or SOL
VPIN in its top decile vpin_percentile ≥ 0.90 regime fired rarely and was often followed by a move, but missed most moves
±1% book unusually lopsided for this symbol ob_imbalance_percentile ≤ 0.01 or ≥ 0.99 regime not tested in Part 5 (which used the fixed ratio); uses each pair's own history, so structurally lopsided books don't trip it all day
sudden ±1% book shift depth_bands["1.0%"].imbalance_ratio vs its EWMA event a fixed ratio > 2 flagged volatility in both directions, not a direction

Once closed, it re-opens only after a cooldown with no triggers, and once VPIN's percentile is back below a lower re-arm level. Any failure to get a fresh snapshot keeps it closed.

2. Fetching: one connection, one second

The SDK's get_confluence_snapshot() is fine for scripts, but it opens a new HTTPS connection on every call. We measured both ways against the live API (Developer key, 40 calls each):

request p50 p90
FollowSMClient.get_confluence_snapshot(), new connection each call 160 ms 164 ms
requests.Session, connection reused 59 ms 63 ms
GET /confluence/snapshots, every pair in one call 137 ms 143 ms

Most of the first row is the TCP and TLS handshake. So the gate's fetcher keeps a session:

import requests
from followsm_sdk import ConfluenceSnapshot, RateLimitExceededException

API = "https://follow-sm.com/api/v1"


def session_fetcher(api_key=None, base_url=API, timeout=2.0):
    """One snapshot per call over a kept-alive connection (no new TLS handshake per order)."""
    session = requests.Session()
    if api_key:
        session.headers["X-FollowSM-Key"] = api_key

    def fetch(symbol: str) -> ConfluenceSnapshot:
        r = session.get(f"{base_url}/developer/confluence/snapshot", params={"symbol": symbol}, timeout=timeout)
        if r.status_code == 429:
            raise RateLimitExceededException(r.text, int(r.headers.get("X-RateLimit-Reset", 0)))
        if r.status_code != 200:  # 202 = symbol not tracked yet, subscribing; 404 = no fresh data
            raise requests.HTTPError(f"{r.status_code}: {r.text[:120]}", response=r)
        return ConfluenceSnapshot.model_validate(r.json())

    return fetch
Enter fullscreen mode Exit fullscreen mode

Two details matter:

  • Treat 202 as "no". Asking for a symbol the backend isn't tracking yet subscribes it and returns 202. Your first order on a new symbol should not go through on an empty answer.
  • Short timeout. Two seconds, not the SDK's default ten. A gate that blocks your order path for ten seconds is worse than one that says no.

The snapshots themselves are recomputed several times a second; over 1,783 polls they were 0.59 s old at the median when they arrived (p99 1.65 s). That sets the useful refresh rate: the gate caches each symbol for one second, so ten orders in a burst cost one request.

3. The gate

import time
from dataclasses import dataclass, field
from typing import Callable, Optional

import requests
from followsm_sdk import ConfluenceSnapshot, RateLimitExceededException, RiskConfig, is_toxic_book


@dataclass
class GateConfig:
    vpin_pct_trip: float = 0.90
    vpin_pct_rearm: float = 0.80  # hysteresis: re-open only below this
    spike: float = 0.20  # max |1% bid share - its EWMA|
    ewma_alpha: float = 0.10
    cooldown_s: float = 5.0  # stay closed this long after the last trigger
    max_age_s: float = 3.0  # an older snapshot fails closed
    cache_s: float = 1.0  # orders within this window share one request
    book: RiskConfig = field(default_factory=RiskConfig)  # ob_imbalance_percentile tails


@dataclass
class Decision:
    allow: bool
    reasons: list[str]
    snapshot_age_s: Optional[float] = None


@dataclass
class _State:
    snapshot: Optional[ConfluenceSnapshot] = None
    fetched_at: float = 0.0
    triggers: list[str] = field(default_factory=list)
    ewma: Optional[float] = None
    closed: bool = False
    last_trigger_at: float = 0.0


class RiskGate:
    def __init__(self, fetch, config=None, clock: Callable[[], float] = time.time):
        self.fetch, self.config, self.clock = fetch, config or GateConfig(), clock
        self._states: dict[str, _State] = {}

    def check(self, symbol: str) -> Decision:
        c, now = self.config, self.clock()
        st = self._states.setdefault(symbol, _State())
        if st.snapshot is None or now - st.fetched_at >= c.cache_s:
            try:
                snapshot = self.fetch(symbol)
            except (RateLimitExceededException, requests.RequestException) as exc:
                return Decision(False, [f"no snapshot: {exc}"])  # fail closed
            st.snapshot, st.fetched_at = snapshot, now
            st.triggers = self._triggers(st, snapshot)
        age = now - st.snapshot.timestamp_ms / 1000
        if age > c.max_age_s:
            return Decision(False, [f"stale snapshot ({age:.1f} s old)"], age)

        if st.triggers:
            st.closed, st.last_trigger_at = True, now
            return Decision(False, st.triggers, age)
        if st.closed:
            pctl = st.snapshot.binance_microstructure.vpin_percentile
            if now - st.last_trigger_at < c.cooldown_s:
                return Decision(False, [f"cooling down ({now - st.last_trigger_at:.1f} s since the last trigger)"], age)
            if pctl is not None and pctl >= c.vpin_pct_rearm:
                return Decision(False, [f"vpin_percentile {pctl:.2f} still above re-arm {c.vpin_pct_rearm:.2f}"], age)
            st.closed = False
        return Decision(True, [], age)

    def _triggers(self, st: _State, snapshot: ConfluenceSnapshot) -> list[str]:
        c, m = self.config, snapshot.binance_microstructure
        out = []
        if m.sweep_active:
            side = "buy" if (m.last_sweep_side or 0) > 0 else "sell"
            out.append(f"{side} sweep ${m.last_sweep_notional_usd or 0:,.0f}")
        if m.vpin_percentile is not None and m.vpin_percentile >= c.vpin_pct_trip:
            out.append(f"vpin_percentile {m.vpin_percentile:.2f}")
        if is_toxic_book(m, c.book):
            out.append(f"lopsided 1% book (imbalance percentile {m.ob_imbalance_percentile})")
        band = m.depth_bands.get("1.0%")
        if band is not None:
            x = band.imbalance_ratio
            if st.ewma is not None and abs(x - st.ewma) > c.spike:
                out.append(f"1% depth spike: bid share {x:.2f} vs EWMA {st.ewma:.2f}")
            st.ewma = x if st.ewma is None else c.ewma_alpha * x + (1 - c.ewma_alpha) * st.ewma
        return out
Enter fullscreen mode Exit fullscreen mode

Why each piece is there

Fail closed, three ways. No snapshot (timeout, network, 429, 202, 404), or a snapshot older than max_age_s, means no. Every one of these is a moment when you know least about the book.

Hysteresis. VPIN's percentile trips the gate at 0.90 but must fall below 0.80 to re-open it. Without the gap, a reading wandering around 0.90 opens and closes the gate on every check, and your bot alternates between trading and not trading at random.

Cooldown. Event triggers clear quickly: sweep_active is only true for a few seconds. The cooldown keeps the gate closed for cooldown_s after the last trigger, so a burst of sweeps closes it once, not ten times.

EWMA spike, not a level. Books are persistently lopsided on many pairs. The spike check compares the ±1% bid share with its own exponentially weighted average, so it reacts to a change. The EWMA is only updated on fresh snapshots (once a second at most), so ewma_alpha = 0.10 is roughly a ten-second memory at full polling rate, and longer if you check a symbol less often. Tune it to how often you actually trade that symbol.

is_toxic_book from the SDK. It uses ob_imbalance_percentile when the symbol has enough history and falls back to the raw ob_toxicity_1pct > 2.0 while it warms up, so you don't re-implement that rule.

Tests

The gate takes an injectable fetch and clock, so it is testable without a network. The six offline tests in examples/test_risk_gate.py cover: a calm book allows and caches; a sweep closes, then cooldown and hysteresis re-open; VPIN, book and spike triggers; failing closed on 429 and network errors; and a stale snapshot. The core one, with a fake feed and a fake clock:

def test_sweep_closes_then_cooldown_and_hysteresis_reopen():
    g, feed, clock = gate()
    feed.next = dict(sweep=True, vpin_pct=0.85)
    assert g.check("BTCUSDT").reasons == ["sell sweep $184,250"]
    feed.next = dict(vpin_pct=0.85)
    clock.now += 2
    assert "cooling down" in g.check("BTCUSDT").reasons[0]
    clock.now += 4
    assert "still above re-arm" in g.check("BTCUSDT").reasons[0]  # 0.85: between re-arm 0.80 and trip 0.90
    feed.next = dict(vpin_pct=0.70)
    clock.now += 1
    assert g.check("BTCUSDT").allow
Enter fullscreen mode Exit fullscreen mode

4. What it did on a live minute

We ran the gate for 60 seconds against the live API, checking BTC, ETH and SOL every 0.4 s (390 checks):

  • Latency: a cached check costs nothing; a fresh one cost 59 ms at p90.
  • BTC and SOL were closed for the whole minute, every check, by vpin_percentile (0.90–0.92 on BTC, 0.99 on SOL). ETH stayed open.

That is the regime trigger working as designed, and it is also the most important decision you have to make. A VPIN regime can last minutes to hours. If your strategy can't afford to stand aside that long, drop VPIN from the gate and use it to size or widen instead: halve the order, or quote wider, while vpin_percentile ≥ 0.90, and keep the gate for events.

How often will the event trigger close it? Over a separate 30-minute window that morning, polling every pair once a second:

sweeps seen per hour share of polls with sweep_active
BTCUSDT ≥ 114 14.2%
ETHUSDT ≥ 10 1.1%
SOLUSDT ≥ 4 0.6%
UNIUSDT ≥ 4 0.4%

Thirty minutes on a Sunday morning is a sample, not a rate; over Part 5's two weeks the BTC sweep trigger was active 6.2% of the time. The point is the spread: on BTC a sweep gate binds often, on most alts it barely binds at all.

5. Which plan, honestly

The gate makes one request per symbol per second at most, and only when you are about to trade. That decides the plan:

Free Developer ($199/mo) Enterprise ($499/mo)
limit 30 requests / min per IP 300 requests / min per key 1,000 requests / min per key
fits a scanner: GET /confluence/snapshots returns every pair in one call, so polling all of them every few seconds stays inside 30/min a bot that checks before each order: with the 1 s cache that is up to 60 requests a minute for each symbol you trade continuously, so even one busy symbol can outgrow 30/min sub-second reaction via /ws/v1/toxicity and /ws/v1/confluence
doesn't fit per-order checks at any real order rate reacting inside the first few hundred ms of a sweep
  • If you only scan, use the free tier and /confluence/snapshots. Don't pay for a limit you won't use.
  • If you gate every order, the request count grows with your order rate and symbol count. That is what the Developer limit is for.
  • If you make markets on BTC/ETH Polymarket books, a REST gate is too late for the first fill: Part 5 measured Polymarket repricing a median 227 ms after a BTC sweep, while a REST check adds a 59 ms round trip on top of a snapshot that is already ~0.6 s old. That needs the streams, and the hft-toxicity-circuit-breaker built on them.

One practical note: keyed responses carry X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset (epoch seconds), and a 429 adds Retry-After. If you run close to 300 a minute, read X-RateLimit-Remaining and slow down before you hit zero.


Caveats

  • This is a gate, not an edge. Nothing here shows the gate makes a strategy profitable. Part 5's evidence is narrower: pausing after sweeps beat random pauses on BTC over two weeks, and did not on ETH or SOL. The book and VPIN triggers describe regimes and volatility, not direction.
  • The measurements are one location on one morning. Round trips depend on where you run; snapshot age and sweep counts depend on the market. Re-measure with your own setup: the session-versus-new-connection comparison takes a minute.
  • REST sees the world late. Snapshot age plus round trip is about two-thirds of a second at the median. The gate protects the seconds after an event, not the event.

Get started

pip install "followsm-sdk>=1.7.0" requests
Enter fullscreen mode Exit fullscreen mode

👉 Get a Developer key at follow-sm.com/pricing


References: Easley, López de Prado & O'Hara (2012), "Flow Toxicity and Liquidity in a High-Frequency World", Review of Financial Studies.

Not financial advice. Trading crypto and prediction markets carries substantial risk of loss.

Top comments (0)