DEV Community

Boris Fesenko
Boris Fesenko

Posted on

Measuring broker latency: building a tick-divergence detector for latency arbitrage

Line chart on a dark navy background showing a gold
Most write-ups about latency arbitrage stop at the idea: a fast reference price moves, your broker's quote lags for a moment, you trade the stale price. True — but useless on its own, because whether there's any edge at all depends entirely on your specific broker, and that is a measurable property, not a vibe.

So let's build the thing that measures it: a small tick-divergence detector that compares two price feeds and records every moment they disagree — the size of the gap, its direction, and how long it lasts. It's ~60 lines of Python, it runs, and it's the same core idea behind any serious broker-latency tool.

The one number that matters

A latency-arbitrage system is only interesting if you can answer three questions about a broker:

  1. How large are the gaps between the broker's quote and the true market? (magnitude)
  2. How long do they persist? (duration — do you have time to act?)
  3. When do they happen? (session/timing)

Everything else — the VPS, the execution stack, the strategy code — is downstream of those numbers. If the gaps are tiny and vanish in single-digit milliseconds, no infrastructure will manufacture an edge. If they're wide and last 100+ ms during liquid hours, you have something. You cannot know which world you're in without measuring.

Modelling a "divergence"

Two feeds:

  • a reference feed — a fast, aggregated view of the market's true price, with minimal delay;
  • a broker feed — the quote you can actually trade, which lags because of aggregation delay, network distance, or deliberate throttling.

At any instant, define the gap as reference_mid - broker_mid. When the reference jumps and the broker hasn't caught up, that gap exceeds some threshold and a divergence window opens. It stays open until the broker catches up and the gap collapses back under the threshold. For each window we record:

  • max_gap — the largest absolute gap seen (your theoretical edge)
  • directionbuy the broker when it's stale-low (ref > broker), sell when stale-high
  • duration — how long the window stayed open

Here are the data structures:

from dataclasses import dataclass

PIP = 0.0001  # 5-digit FX symbol, e.g. EUR/USD

@dataclass
class Divergence:
    start: float          # arrival time (s) the gap opened
    end: float            # arrival time (s) the gap closed
    max_gap: float        # largest |ref - broker| during the window (price units)
    direction: str        # 'buy' broker (ref above) or 'sell' broker (ref below)

    @property
    def duration_ms(self):
        return (self.end - self.start) * 1000

    @property
    def gap_pips(self):
        return self.max_gap / PIP
Enter fullscreen mode Exit fullscreen mode

The detector

The detector is a tiny state machine. It remembers the latest reference mid, and on every incoming tick it re-evaluates the gap. Above threshold → a window is open (and we keep the running max). Back below threshold → close the window and emit an event.

class DivergenceDetector:
    """Feed it interleaved reference/broker ticks in arrival-time order."""

    def __init__(self, threshold_pips=0.8):
        self.threshold = threshold_pips * PIP
        self.ref_mid = None
        self._open = None          # currently open divergence
        self.events = []

    def on_reference(self, t, mid):
        self.ref_mid = mid
        self._reevaluate(t)

    def on_broker(self, t, mid):
        self.broker_mid = mid
        self._reevaluate(t, broker=mid)

    def _reevaluate(self, t, broker=None):
        if self.ref_mid is None or not hasattr(self, "broker_mid"):
            return
        b = broker if broker is not None else self.broker_mid
        gap = self.ref_mid - b                 # >0 => broker stale-low => buy broker
        if abs(gap) >= self.threshold:
            direction = "buy" if gap > 0 else "sell"
            if self._open is None:
                self._open = {"start": t, "max": abs(gap), "dir": direction}
            else:
                self._open["max"] = max(self._open["max"], abs(gap))
        elif self._open is not None:
            o = self._open
            self.events.append(Divergence(o["start"], t, o["max"], o["dir"]))
            self._open = None
Enter fullscreen mode Exit fullscreen mode

Note the important detail: ticks are processed in arrival-time order, not in the order the market produced them. The whole point is that the broker's tick for a given price arrives later than the reference's. That ordering is the phenomenon.

A runnable simulation

To exercise it without a live feed, simulate one true price (a random walk) observed by two feeds with different delays — a ~5 ms reference and a ~150 ms broker. Merge both streams by arrival time and push them through the detector.

import random, statistics

def simulate(n=20000, ref_delay=0.005, broker_delay=0.150, seed=42):
    rnd = random.Random(seed)
    t, price = 0.0, 1.1000
    arrivals = []
    for _ in range(n):
        t += rnd.expovariate(1 / 0.05)             # ~20 ticks/sec avg
        price += rnd.gauss(0, 0.6) * PIP           # small random step
        arrivals.append((t + ref_delay,    "ref",    price))
        arrivals.append((t + broker_delay, "broker", price))
    arrivals.sort(key=lambda a: a[0])              # merge by ARRIVAL time

    det = DivergenceDetector(threshold_pips=0.8)
    for at, kind, mid in arrivals:
        (det.on_reference if kind == "ref" else det.on_broker)(at, mid)
    return det.events

ev = simulate()
gaps = [e.gap_pips for e in ev]
durs = [e.duration_ms for e in ev]
buys = sum(e.direction == "buy" for e in ev)
print(f"divergences detected : {len(ev)}")
print(f"median gap (pips)    : {statistics.median(gaps):.2f}")
print(f"median duration (ms) : {statistics.median(durs):.0f}")
print(f"direction split      : {buys} buy / {len(ev) - buys} sell")
Enter fullscreen mode Exit fullscreen mode

Output on my machine:

divergences detected : 5122
median gap (pips)    : 1.27
median duration (ms) : 54
direction split      : 2593 buy / 2529 sell
Enter fullscreen mode Exit fullscreen mode

Play with broker_delay. Drop it to 0.02 (a fast broker) and the divergences mostly disappear — which is exactly the point: the edge is a function of the broker's lag, and nothing else. Widen it to 0.3 and both the gap size and duration grow. You've just built a knob that models "how exploitable is this broker."

The roughly even buy/sell split is a good sanity check too: a pure lag against a symmetric random walk shouldn't be directionally biased. A real broker feed that comes back skewed is telling you something about how it's quoting.

Get the code

The full, tested version — with a CLI, per-symbol pip sizes, an examples/ delay sweep and unit tests — is open-source (MIT) on GitHub:

github.com/bjftradinggroup-inc/forex-latency-divergence-detector

git clone https://github.com/bjftradinggroup-inc/forex-latency-divergence-detector
cd forex-latency-divergence-detector
python latency_detector.py --broker-delay 0.15
Enter fullscreen mode Exit fullscreen mode

If it's useful, a ⭐ helps other people find it.

What the toy model conveniently ignores

This is where measurement stops being cute and starts being engineering. A production detector has to deal with:

  • Clock discipline. You're comparing timestamps from two sources. If their clocks drift, your "latency" is fiction. Real setups stamp events against a monotonic clock and, at the serious end, discipline the host clock with PTP. Never trust wall-clock datetime.now() for this.
  • Last look. A measured gap is a theoretical opportunity, not a fill. Many liquidity providers get a brief final window to reject an order after you send it — so the gaps you measure and the trades you actually get filled on are two different distributions. Measuring the first without modelling the second is how people overestimate an edge.
  • Per-symbol reality. PIP isn't a constant; gold, JPY crosses and majors all differ. Thresholds, spreads and tick rates are per-symbol. Gold (XAU/USD) in particular tends to show far larger gaps than EUR/USD because it's more volatile and more loosely quoted.
  • Spread and costs. A 1.3-pip gap on a 0.6-pip spread is not a 1.3-pip edge. The detector measures the raw divergence; the tradeable part is what's left after spread, commission and slippage.
  • Throughput. Real feeds are tens of thousands of messages per second. The hot path has to be allocation-light; the version above is for clarity, not for a colocated box.

The meta-point: a measured opportunity is not realized profit. The detector tells you whether a broker is even worth pursuing. It does not promise the money is capturable. That distinction is the difference between people who treat a backtest as a track record and people who don't.

From a detector to a decision

Scale this up — run it live against many brokers, across many symbols, for months — and the event stream becomes a dataset you can actually make decisions with: which brokers lag enough to matter, on which instruments, at which hours. That's the difference between choosing a broker on marketing and choosing one on evidence, and it's the single highest-value step before spending a cent on infrastructure.

If you'd rather not build and host the whole pipeline, this is exactly what a free forex arbitrage scanner does — it records real broker latency with open methodology so you can inspect the gap distributions directly. For the strategy context around it, see the deep dives on algorithmic & automated forex trading and latency arbitrage.

The takeaway for engineers: in this corner of trading, the interesting problem isn't "what's the strategy" — it's "how do I measure the market honestly enough to know whether a strategy can exist at all." Start there.


Educational content, not financial advice. Trading involves significant risk; measured theoretical opportunities do not guarantee tradable or profitable results.

Top comments (0)