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)) 

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 

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 

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 

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 

👉 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.