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.Sessioncost 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
- The kill switch on streams: hft-toxicity-circuit-breaker (the same triggers, evaluated per tick).
- A REST polling bot for Polymarket: polymarket-arbitrage-starter-kit (halts on sweeps).
- The research behind the triggers: tick-microstructure-research.
-
SDKs:
pip install followsm-sdk·npm install @followsm/sdk
👉 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)