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 FlowAnomalyCheck in 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.

python
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=10 for latency-sensitive strategies; a stale finding shouldn’t permanently block trading.
  • Loosen to lookback_s=300 for cautious capital.
  • min_confidence=0.9 drops most false positives; min_confidence=0.5 catches 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.

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

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

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

python
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

text
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