Triangular arbitrage on Binance Spot: measuring why the taker loses
The engine from the first post emitted opportunities: cycles of three pairs whose quoted prices, after fees and lot rounding, returned more of the start asset than they consumed. On a full-market run it found a few hundred per hour, most of them on thin TRY, USDC and THB pairs. The question that decides whether any of this is a business is not how many signals there are. It is how many of them could have been executed by the time an order reached the exchange, and at what price. I built a simulator to answer that, and then, when the answer was zero, a recorder to find out why.
A simulator that only ever flatters
The hot path gained one thing: a 32-deep ring of recent quotes per symbol, so the state of any leg at any instant in the last few hundred milliseconds can be reconstructed. The simulator runs on the service thread. For every opportunity with trigger time t0, and for each modelled order latency L in a set of buckets (1, 5, 20 and 50 ms by default), it assumes the three legs are sent one after another and arrive at the exchange at t0 + L, t0 + 2L, t0 + 3L. A leg fills at the quoted price if, at that instant, the quote is still at least as good and the displayed quantity still covers the size. Otherwise the leg executes at whatever the best price is at that instant, with no quantity check. Leg one gone means no trade. Each bucket keeps its own balances, in-flight trade and counters, so a single run reads out fill rate against latency.
Two choices make this an upper bound by construction, and I want them on the record before the numbers. A fill assumes an immediate-or-cancel at the quoted price would have been first in the queue. And a fallback executes at the top of book regardless of size, so a failed leg costs the price difference and nothing more. Real execution is worse on both counts. If this simulator says no, the market says no.
From home: a 159 ms view
The first run was from my desk, 275 ms from the exchange, with the SBE feed and a synced clock. Fifteen minutes, every pair, 2.06 million events, five connections, no drops. Sixty-seven opportunities on seventeen cycles. Before simulating anything, the staleness histogram said what kind of signals these were: 63 % had a leg whose quote was more than a second old when the opportunity fired, and only four had all three legs younger than 100 ms.
| latency | trades | filled all three | first failing leg | P&L |
|---|---|---|---|---|
| 1 ms | 43 | 10 (23 %) | leg 2 fallback on the thin pair | −16.46 USD |
| 5 ms | 36 | 4 (11 %) | same | −15.48 USD |
| 20 ms | 32 | 2 (6 %) | same | −15.59 USD |
| 50 ms | 32 | 1 (3 %) | same | −16.58 USD |
Every fill came from a signal whose oldest leg was at least two seconds stale: a quiet book that simply had not moved. The four fresh signals, all legs under 100 ms old, filled none of their three legs and lost the most. The thin middle leg is where the quote disappears; leg one was missed only four or five times per bucket. So the obvious fix, a maximum leg age, would have removed the only trades that made money and kept the ones that lost it. That is not a strategy problem you can filter your way out of. It is a signal that fires because a thin quote is about to change, and the only way to know whether a faster view helps was to get one.
From Tokyo: a 1 ms view
The same engine on an instance in the exchange's zone, where a quote reaches the process about a millisecond after it is stamped, on both the JSON and the SBE feed, with the simulator in its sequential mode and then in a parallel mode I added for the occasion:
| run | signals | trades per bucket | filled all three legs | P&L per bucket |
|---|---|---|---|---|
| JSON, sequential legs, 1–50 ms | 169 (17 cycles) | 32 | 0 at every latency | −27 to −30 USD |
| SBE, sequential legs, 1–50 ms | 33 | 23 | 0 at every latency | −18 to −24 USD |
| SBE, parallel legs with inventory in every asset, 1 ms | 106 (21 cycles) | 96 | 75, of which 66 are XRP/THB cycles of about zero size | −6.87 USD |
| SBE, parallel legs, 2 and 5 ms | 92 and 90 | 64 and 61, the same dust | −15.4 and −20.2 USD |
Zero. Not fewer fills than from home, none, at one millisecond and at fifty. The parallel mode, which assumes you already hold inventory in every asset and fire all three legs at once, produced "fills" that were almost entirely a dust cycle through XRP/THB where the displayed size rounds to nothing, and still lost money on the ones that were not. A faster view made the simulated taker worse, because it now saw the fresh signals, and the fresh signals are the ones that fail.
The anatomy of the 169 JSON signals explains the shape. They are not 169 events; they are 32 bursts. 110 of the 152 gaps between consecutive signals are under 0.2 ms. A burst lasts 0.97 ms at the median, 1.8 ms at p90, 2.2 ms at most. Inside a burst the thin asset's two pairs re-quote every 100–200 µs and the net edge decays as they do: 72 bps down to 9, 40 bps down to 2. The leg that fails is always the thin pair, and when the simulator reaches it the price is 21–78 bps worse than quoted, 39 at the median, within 2 ms of the signal. With my start assets the thin pair is never the first leg; the trigger was leg two in 93 signals and leg three in 64, so the simulator buys the liquid leg, turns to the thin one, and finds it gone.
So the opportunity is a transient about a millisecond long, created by something happening on the thin pair, and my view of it arrives, at best, as it ends. The simulator could not say what that something was. The recorder could.
Who takes the quotes
I recorded two hours of every trade and every best-quote change on 67 symbols: the 65 that appeared in the day's signal cycles plus two liquid references. For the first 72 minutes I re-ran the engine's own evaluation over the recording, 86 cycles, 7.5 bps a leg, and for every burst of signals looked at each leg's quote and what happened to it within the next 10 ms.
Eighty-one percent of the legs were taken. In 219 of the 226 bursts at least one leg was taken; in 129, all three. On the thin legs the displayed size was fully consumed: LAZIO/TRY 57 taken, 9 pulled, 1 persisted; XAI/TRY 54, 5, 0; OSMO/USDC 13, 9, 1. The hub legs, USDT/TRY and USDC/USDT, print about a millisecond later: the hedge. As a control, an arbitrary quote change on these pairs is followed by a trade at the new best within a millisecond 2–10 % of the time; conditioned on my signal, 81 %. That is not noise, and it is not makers cancelling, which I had assumed until I looked: cancellations explain a tenth of the outcomes, takers four fifths.
On the engine's own clock, where no clock-sync assumption is needed because both ends are exchange timestamps, the first thin-leg take lands 0.74 / 1.00 / 1.43 ms after the match that created the signal, at p10 / p50 / p90. Per symbol it is tighter still: OGN/TRY 0.99 / 1.12 / 1.23, S/USDC 1.00 / 1.05 / 1.09, SXT/TRY 1.01 / 1.05 / 1.19. That is a pipeline, not a reaction. Over the full two hours the picture holds: outside one ETH–XRP–THB cycle of dust quotes nobody ever touches, the thin-pair cycles were taken in about 95 % of bursts, all three legs in about 55 %, at a median of 0.81 ms after the stamp.
Here is one, on the engine clock, with the two measured times of the SKL/TRY prints and my own feed's median receipt drawn in:
By the time my process has the quote, the three levels are gone. By the time my order could arrive, the hedge is printing.
What creates the opportunity
Ninety percent of the bursts, 201 of 226, are triggered by a trade on a thin leg, not by a quote being placed. All the prints of a trigger share one transactTime: a single order, swept through 1 / 3 / 8 levels at p10 / p50 / p90. In 181 of the 201 the aggressor's side is opposite to mine: they bought, the signal says sell into the new best bid; they sold, the signal says buy the new best ask. And in 150 of the 201 the new best quote on my side sits at or beyond the sweep's extreme price. That is the sweeper's own order: a marketable limit that ate three to eight levels and left its unfilled remainder resting at the far end, well away from fair value. One sell sweep on DODO/USDT went from 1966 to 1960 and left 5.4 million units resting at 1960, 38 bps below the previous mid. A buy sweep on OGN/TRY from 1909 to 1913 left 788 thousand resting at 1913.
So the "triangle" is not three legs of arbitrage. It is a two-leg trade against a sloppy limit order's remainder: hit the remainder on the thin pair, offset on the USDT pair. The USDT/TRY leg is not executed by the fast actors at all. Its "takes" in the table are the constant background flow on that pair, 1.8 ms after the signal with a fill share of zero, unlike the thin legs at 0.7 ms with a fill share of one. The fast actors carry TRY exposure and hedge it on their own schedule.
They are also a crowd, not one firm. Within 300 µs of the first take at the remainder price there are two more at the median and four to seven at p90, with sizes that repeat, the same actor resending, and sizes that differ, different actors. Only 49 of 188 remainders were consumed by a single trade. The first take is usually partial, 31 % of the displayed remainder at the median, sized to what the USDT leg can absorb. And the two thin legs of a cycle are taken 0.11 ms apart at the median, 0.03 at p10: the same actor fires both legs in parallel, not in an acknowledgement-driven chain, which would be a millisecond apart.
Where their data comes from
This is the part I cannot measure, so I will say exactly what I can.
The crowd's orders match at the match plus 1.0 ms, with p10 at 0.76. An order from the exchange's zone takes about 0.4–0.5 ms one way, half the 1.0 ms round trip I measured on the WebSocket order API. So they are holding the information at roughly the match plus 0.5–0.6 ms, which is the public stamp plus 0.2–0.3 ms. My public feed's best-ever delivery over a million updates, the 0.1th percentile, is the stamp plus 0.32 ms. Their routine is my once-in-a-thousand.
Three explanations were on the table. First, that the fast actors are parties to the trigger trade, the swept makers or the sweeper, and learn from their own execution reports. The quote-only bursts refute that as the main channel: in 19 of the 25 bursts triggered by a placed quote, no trade had happened on any thin leg of the cycle in the previous 6 ms, nobody had a fill, nobody had private information except the placer, who cannot be the taker, and the takes landed just as fast, 0.56 / 0.69 / 1.12 ms after the stamp against 0.46 / 0.65 / 0.91 for the trade-triggered ones. Second, that it is the same public feed from a better position in the fan-out. The connection races in the previous post refute that: four connections on four backends receive the same update within 0.1 ms of each other, sixteen connections gain a quarter of a millisecond and saturate, and the 0.6 ms that remains is common to every public connection. Third, that they receive market data on a path with less queueing than the public publisher. Nothing in my data contradicts that, and the arithmetic requires something like it. Binance's futures documentation lists a separate market-data endpoint for market makers; the spot VIP portal lists "Low Latency Access" as a benefit of its liquidity programmes without saying what it is. I did not ask, and I am not asserting more than the numbers do: whoever acts at the match plus one millisecond is not reading the feed I read.
One caveat on clocks. The stamp-to-receipt numbers depend on the exchange's publisher clock against mine. The take-after-match number does not; both ends are exchange timestamps. Given the measured 0.32 ms floor and a physical minimum of about 0.15 ms in the zone, the exchange's clock can be at most 0.2 ms behind mine, so the crowd's edge is at least 0.4 ms in the worst case and about 0.6 ms nominally.
What it is worth, roughly
For scale only, with every caveat attached. Over the two hours, 573 thousand USD of the 1.19 million displayed at signalled prices were taken, at a gross dislocation of 32 bps at the median. That is about 3,800 USD of gross edge in two evening hours on 67 symbols, which extrapolates to the order of 45 thousand USD a day before fees, shared by the two to five actors the sizes suggest. Two Istanbul-evening hours extrapolated to a day overstates it; 67 symbols out of 1,375 understates it; hedging at the top of book without walking the book overstates it. It is a niche for a few specialised desks, not a tier-one market.
Verdict
Taker triangular arbitrage on thin Binance Spot pairs, from any public market-data channel, is closed. The signal is real, about a millisecond long, created by a sloppy limit order's remainder, and it is taken at the match plus one millisecond by a crowd that holds the information 0.6 ms before the public feed delivers it. Sequential legs, parallel legs with inventory, from home, from the exchange's zone, on JSON, on SBE, with clones, on FIX: the simulator filled nothing and the recording showed who did.
What the recording did not close is the other side of the same trade. If a resting quote on these pairs gets hit by this flow, a maker without queue priority is the sloppy limit order. Whether a maker with a better price and a faster hedge could make that flow pay is the next post, and the answer there is also measured.