Defend: protect your strategy from toxic flow
Working recipes for using flow findings to keep your orders out of manipulated markets and away from toxic counterparties.
Five recipes for the defensive side. Each is a complete, copy-pasteable example with context.
Protection comes in two flavors:
- Market-level. “This market has active spoofing; don’t trade it right now.” Wired through
FlowAnomalyCheckin the risk pipeline. - Actor-level. “This counterparty has an HFT profile with Hawkes branching 0.85; reduce size or don’t take their offer.” Wired inside your
Strategy.evaluate().
Both run off the same data. The flow store populated by the engine running in the background.
Recipe 1: reject orders in markets with active manipulation findings
The simplest protection. FlowAnomalyCheck plugs into RiskConfig.extra_checks and rejects any order for a market that has a recent finding matching your criteria.
import horizon as hz
from horizon.flow import SQLiteFlowStore, FlowAnomalyCheck
from horizon.flow.events import AnomalyCategory
flow_store = SQLiteFlowStore("flow.db")
# Block for 60 seconds after any medium+ spoofing, layering, or
# wash-trade finding with confidence >= 0.7.
flow_check = FlowAnomalyCheck(
store=flow_store,
categories={
AnomalyCategory.Spoofing,
AnomalyCategory.Layering,
AnomalyCategory.WashTrade,
AnomalyCategory.MomentumIgnition,
},
lookback_s=60.0,
min_severity="medium",
min_confidence=0.7,
)
risk = hz.risk.RiskConfig(
# ... your existing risk config ...
extra_checks=[flow_check],
)
hz.run(mode="live", risk=risk, ...)
What it does. On every OrderAction the risk pipeline invokes flow_check.check(action, state). The check queries the flow store for findings on action.market_id in the last 60 seconds; if any match the category + severity + confidence filters, the order is rejected with a reason like "flow anomaly active: spoofing (conf=0.87, bait buy size=5000 canceled 400ms...)".
Tuning.
- Tighten
lookback_s=10for latency-sensitive strategies; a stale finding shouldn’t permanently block trading. - Loosen to
lookback_s=300for cautious capital. min_confidence=0.9drops most false positives;min_confidence=0.5catches more but also rejects on marginal signals.
What the rejection looks like downstream. The risk engine emits the usual AuditCategory.RiskDecision event with the extra-check reason attached. The audit trail shows: order submitted, risk rejected, reason = “flow anomaly active: …“. Exactly what a compliance auditor expects.
When it doesn’t fire. If the flow engine isn’t running (no findings being written), this check passes everything through. If the store is unreachable, it passes through too. A store outage must not block all trading.
Recipe 2: avoid trading against a specific known-bot counterparty
Sometimes the relevant question isn’t “is this market manipulated”. It’s “is MY counterparty a fast HFT that will pick me off the moment the mid moves.” Query the actor profile before sizing.
import horizon as hz
from horizon.flow.actors.taxonomy import TraderCategory
class FlowAwareStrategy(hz.Strategy):
"""Reduce size when the likely counterparty is HFT."""
asset_classes = [...]
features = {...}
def evaluate(self, f, universe):
signals = []
for market in universe:
# Baseline signal
if f.z[market.id] > 2.0:
edge_bps = 40
size_mult = 1.0
# Check: who's on the other side of this book?
# In a real book we'd query the best-bid / best-ask wallet;
# here we assume the top actors on each side are tracked.
counterparties = self._likely_counterparty_wallets(market.id)
for wallet in counterparties[:3]:
profile = hz.flow.actor_profile(wallet, venue="polymarket")
if not profile:
continue
probs = profile.taxonomy_probs
# If >50% prob the counterparty is HFT, cut size in half
if probs.get(TraderCategory.HFT.value, 0.0) > 0.5:
size_mult *= 0.5
break
signals.append(hz.Signal.increase(
market, edge_bps=edge_bps * size_mult,
horizon="1h",
))
return signals
def _likely_counterparty_wallets(self, market_id):
"""Venue-specific: read the top of the L3 book."""
# Polymarket / Hyperliquid expose wallets per resting order.
# Your venue adapter / ingestion source exposes this.
return []
When to use. Prediction markets where you see the L3 book. Any venue with anonymous tape (equities) requires actor attribution you don’t have; fall back to market-level rules (recipe 1).
Why size reduction rather than rejection. HFT on the other side isn’t manipulation. It’s a legitimate market maker who’s faster than you. Reducing size preserves your ability to participate while limiting adverse-selection damage on the trade you take.
Recipe 3: freeze strategies during coordinated-wallet activity
If the clustering engine has identified a wallet group with coordinated behavior on a market, that’s a stronger signal than a single-actor profile. Halt new entries for that market until the cluster disperses.
import horizon as hz
from datetime import datetime, timedelta, timezone
def is_cluster_active(market_id: str, *, lookback_minutes: int = 5) -> bool:
"""True if the market has recent cluster-assigned findings."""
since = datetime.now(timezone.utc) - timedelta(minutes=lookback_minutes)
findings = hz.flow.anomalies(
market_id=market_id,
since=since,
)
# Any finding with a cluster_id → coordinated activity
return any(f.cluster_id for f in findings)
class ClusterAwareStrategy(hz.Strategy):
def evaluate(self, f, universe):
signals = []
for market in universe:
if is_cluster_active(market.id):
continue # skip this market this tick
# ... normal signal generation ...
return signals
Why this matters. A single spoofing event is bad; multiple coordinated wallets running the same pattern is a different and more dangerous regime. The cluster assignment is the tell.
Pair with recipe 1. The risk-layer check (recipe 1) is defense-in-depth against individual findings; this is strategy-layer avoidance of the broader coordinated-activity context.
Recipe 4: reduce size when Hawkes branching is high market-wide
Not every detection fires an AnomalyFinding. Pure “toxic flow” signals. VPIN, OFI, Hawkes branching. Can be read directly from the toxicity estimators without waiting for a detector to cross its threshold.
import horizon as hz
from horizon.flow.config import HawkesConfig
from horizon.flow.toxicity import HawkesFingerprint
class HawkesAwareStrategy(hz.Strategy):
def __init__(self):
super().__init__()
# One estimator per market
self._hawkes: dict[str, HawkesFingerprint] = dict()
def on_tick(self, ctx):
"""Called on every feed tick by the pipeline. Here we feed the
estimator from the market's recent trades."""
for market_id, trades in ctx.recent_trades.items():
h = self._hawkes.setdefault(
market_id,
HawkesFingerprint(HawkesConfig(window_s=300.0, kernel_decay_s=1.0)),
)
for price, size, side, ts in trades[-5:]: # feed just the tail
h.observe(ts)
def evaluate(self, f, universe):
signals = []
for market in universe:
h = self._hawkes.get(market.id)
if h is None:
continue
est = h.estimate()
if est is None:
size_mult = 1.0
elif est.branching_ratio > 0.7:
# High self-excitation. Cascading bot activity. Halve size.
size_mult = 0.5
elif est.branching_ratio > 0.5:
# Moderate. 75% size.
size_mult = 0.75
else:
size_mult = 1.0
if f.z[market.id] > 2.0:
signals.append(hz.Signal.increase(
market, edge_bps=40 * size_mult, horizon="1h",
))
return signals
What the Hawkes number means in context.
< 0.2: Poisson-ish. Humans / fundamental flow. Full size.0.2–0.5: Mixed. 75% size. Cautious.0.5–0.7: Substantial bot activity. 50% size.> 0.7: Near-critical. Either sit out or take with very small size; we’re close to cascading-liquidation territory.
This is not manipulation detection. High branching ratio can be perfectly legal algorithmic activity. The reason to scale down isn’t that something is wrong. It’s that your fills will be adverse-selected by faster participants and your P&L will suffer.
Recipe 5: blacklist a specific wallet
Sometimes you’ve done the investigation (see the investigation recipes) and concluded a specific wallet is hostile. Hard-block it.
import horizon as hz
# Wallets you've decided to avoid as counterparties
BLACKLIST = {
"0xABC...",
"0xDEF...",
}
class BlacklistStrategy(hz.Strategy):
def evaluate(self, f, universe):
signals = []
for market in universe:
# Venue-specific: get the best-offer wallets from L3 book
best_asks = self._best_ask_wallets(market.id)
if any(w in BLACKLIST for w in best_asks[:3]):
continue # don't cross. Let someone else deal with them
# Also check cluster membership. Their whole group is out
for w in best_asks[:3]:
cluster = hz.flow.cluster_of(w)
if cluster and any(a in BLACKLIST for a in cluster.actor_ids):
break
else:
if f.z[market.id] > 2.0:
signals.append(hz.Signal.increase(market, edge_bps=40, horizon="1h"))
return signals
def _best_ask_wallets(self, market_id):
# Venue-specific; see the polymarket / hyperliquid adapters.
return []
Combine with clustering. The cluster_of() call catches the case where the blacklisted entity is operating a new wallet you haven’t yet added. If the clustering engine has linked the new wallet to a blacklisted one, the whole cluster is out.
Maintenance. Review quarterly. Record the reason (with citation to a specific AnomalyFinding.finding_id) next to each blacklist entry. The audit trail + the findings + the blacklist is the story you tell compliance.
The defensive posture in one picture
order leaves your strategy
│
▼
┌─────────────────────┐
│ RiskConfig pipeline│
│ │
│ 1. per-order │
│ 2. stops │
│ 3. drawdown │
│ 4. circuit breakers │
│ 5. margin │
│ 6. scenarios │
│ 7. kill switch │
│ + FlowAnomalyCheck │ ◄── Recipe 1
└──────────┬──────────┘
│ pass
▼
venue submit
parallel: running inside evaluate() before the order is even emitted
┌─────────────────────┐
│ ActorProfile lookup │ ◄── Recipe 2
│ cluster_of lookup │ ◄── Recipe 3, 5
│ Hawkes per market │ ◄── Recipe 4
│ blacklist check │ ◄── Recipe 5
└─────────────────────┘
Two lines of defense. The strategy-layer check keeps bad orders from being generated; the risk-layer check keeps anything that slips through from reaching the venue. Both consume the same flow store.
What this does NOT protect against
- Fresh patterns. If a new manipulation technique emerges that our 7 detectors don’t match, they won’t fire. Extend with custom detectors.
- Off-venue coordination. Manipulators coordinating on Discord / Telegram leave no trace on-chain until they act. The module sees the action; the meta-action is invisible.
- You being wrong. A FlowAnomalyCheck rejection is a signal, not a verdict. If your strategy is confident enough to override, add an escape hatch. But record it in the audit log so the decision is traceable.
Related
- Risk integration reference. Full
FlowAnomalyCheckAPI. - Actor profiling. What a profile contains and how to read it.
- Alpha recipes. The other side of the coin: trading WITH flow signals, not against them.