Lesson 3.3.1 · 60 min
FX microstructure: OTC, ECN, last look and sessions
In currencies there is no exchange and no single book: each liquidity provider quotes on its own and whoever receives the prices builds their own book. This lesson rebuilds that book with five fictitious providers in three sessions, measures the last look of each one order by order and computes what it really costs to trade with them, beyond the bid-ask spread they show.
- Module
- 3.3 · Advanced forex
- Duration
- 60 min
- Prerequisites
- 1.1.3 · Order book: bid, ask, spread and depth, 1.2.4 · Forex: pairs, pips, lots, rollover and CFDs, 2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing, 2.4.8 · Fill modeling: limit orders, queue, partial fills, gaps and stops
- Author
- Jesús Cuesta
- Updated
Level 3 lessons
3.1 · Futures in depth
3.2 · Futures spreads
- 3.2.1 Calendar spreads, butterflies and condors
- 3.2.2 Intercommodity spreads: crack, spark and crush
- 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
- 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
- 3.2.5 Spread data and backtesting: listed, synthetic and legged
- 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
3.3 · Advanced forex
3.4 · Option theory and pricing
- 3.4.1 Payoffs, put-call parity and arbitrage bounds
- 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
- 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
- 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
- 3.4.5 Smile, skew and term structure; volatility surface with SVI
- 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
- 3.4.7 Advanced models: Heston, SABR and local volatility
3.5 · Option structures in Python
- 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
- 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
- 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
- 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
- 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
3.6 · Systematic volatility and options strategies
3.7 · Crypto derivatives and specific strategies
- 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
- 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
- 3.7.3 Crypto options, on-chain data and DeFi
- 3.7.4 ccxt for trading on multiple exchanges
- 3.7.5 MiCA: the EU framework for crypto-assets and their service providers (CASPs)
A market without a central book
Lesson 1.2.4 presented the currency market as an OTC market that follows the sun from Sydney to New York. Here what matters is its fine mechanics: who sets the prices, how they reach the client and what happens between the moment the client clicks “buy” and the trade is done. On an exchange (lesson 1.1.3) there is a central order book with public rules: an order that crosses the best price is executed, period. In spot currencies that single book does not exist. There are many liquidity providers (LPs) that publish prices to their clients through different channels, and a large part of those prices is not firm: the provider reserves a last look before accepting.
Mermaid source
flowchart TB B[Bank LPs] --> ID[Interbank] B --> MD[Multi-dealer and ECN] N[Non-bank LPs] --> MD B --> SD[Single-dealer platform] MD --> AG[Aggregator] SD --> AG AG --> C[Fund or CTA] AG --> R[Retail broker] R --> M[Retail client]
- Liquidity providers. Large banks and, increasingly, non-bank electronic market-making firms. They quote buy and sell prices continuously and earn the bid-ask spread; their risk is adverse selection (lesson 2.3.6): being filled just before the price moves.
- Interbank market. Banks trade with each other, often on platforms with a central book of firm orders. There there is something similar to an exchange, but only for participants with credit agreements among themselves.
- Multi-dealer platforms and ECNs. They receive prices from several providers and distribute them to clients. In an ECN (electronic communication network) each provider publishes its price stream; the client sees all of them and chooses.
- Single-dealer platform. Each large bank has its own, with its prices for its clients.
- Aggregator. The client's (or their broker's) software that combines the streams of several providers, builds a book and decides whom to send each order to. The book a fund sees is seen by nobody else: it depends on whom it has agreements with.
- Retail brokers. They take prices from one or several providers, add their markup and offer them to their clients; some internalize the flow (they are the counterparty) and others pass it to the market.
Having no central book has a practical consequence for whoever runs backtests: there is no official price. A data vendor's EURUSD is that of its sources, and two sources do not agree to the tenth of a pip (lesson 1.3.8).
How much and with whom
The reference for measuring the market is the triennial survey that central banks carry out every three years, coordinated by the Bank for International Settlements (BIS). In April 2025, 52 countries took part. The Banco de España publishes the Spanish part1Note 1 · Banco de España, Triennial survey on the OTC foreign exchange and interest rate derivatives markets in Spain (2025) and press release of September 30, 2025, accessed on October 22, 2026.:
| Spanish FX market, April 2025 | Average daily volume |
|---|---|
| Total | 67.9 billion USD (39.2 in April 2022) |
| FX swaps | 44.8 billion (66%) |
| Spot | 11.8 billion (17%) |
| With other reporting dealers (banks) | 53% |
| With other financial institutions | 41% |
| With non-financial clients | 6% |
| With foreign counterparties | 94% |
Two figures in the table help to understand the rest of the lesson. Spot, what a currency trading system trades, is a minority share of the volume: most of it is FX swaps, the funding and hedging tool of lesson 3.3.3. And almost half of the volume is no longer between banks: it is traded by other financial institutions, the category that includes funds and non-bank market makers.
Five providers, three sessions
The data in this lesson are for QEVUSD, the pair of a fictitious evro that mimics EURUSD, on March 12, 2025: one
hour at each opening, Tokyo (00:00–01:00 UTC, labeled asia), London (07:00–08:00) and New York (13:00–14:00). There
is a reference price that nobody sees and five providers that quote it, each with its own latency, its bid-ask spread
and its last look policy. It is synthetic data with the parameters planted in fx3_lp_verdad.csv: the lesson measures
them as if it did not know them and compares them at the end.
import numpy as np
import pandas as pd
cot = pd.read_parquet("fx3_ecn_cotizaciones.parquet")
print(len(cot), "quotes from", cot["lp"].nunique(), "providers")
cot.head(4)193244 quotes from 5 providers
ts sesion lp bid ask tam_bid tam_ask
0 2025-03-12 00:00:00.175000+00:00 asia LP-Borea 1.473744 1.473916 1000000 1000000
1 2025-03-12 00:00:00.278000+00:00 asia LP-Atlas 1.473716 1.473944 3000000 1000000
2 2025-03-12 00:00:00.311000+00:00 asia LP-Delta 1.473798 1.473862 3000000 1000000
3 2025-03-12 00:00:00.360000+00:00 asia LP-Delta 1.473798 1.473862 5000000 2000000Each row is a price update from a provider: its buy price (bid), its sell price (ask) and the firm quantity on
each side, between 1 and 5 million evros. The timestamps have milliseconds, which in currencies is a coarse
resolution: a fast provider updates several times per second. By provider and session:
PIP = 1e-4
cot["spread"] = (cot["ask"] - cot["bid"]) / PIP
spread = cot.pivot_table(index="lp", columns="sesion",
values="spread", aggfunc="median")
ritmo = cot.groupby(["lp", "sesion"]).size().unstack() / 3600
pd.concat({"spread_pips": spread, "cotiz_por_s": ritmo},
axis=1).round(2) spread_pips cotiz_por_s
sesion asia londres nueva_york asia londres nueva_york
lp
LP-Atlas 2.02 1.26 1.26 1.09 2.94 3.29
LP-Borea 1.66 1.04 1.04 1.40 4.00 4.39
LP-Cirro 0.92 0.58 0.58 2.09 6.01 6.57
LP-Delta 0.74 0.46 0.46 1.73 4.99 5.43
LP-Eris 1.28 0.80 0.80 1.37 3.99 4.40The two most traditional banks, LP-Atlas and LP-Borea, quote bid-ask spreads of 1.26 and 1.04 pips in London; the
two non-banks, LP-Cirro and LP-Delta, 0.58 and 0.46. In the Tokyo hour all spreads are 60% wider and the update
rate falls to less than half: the evro trades, but its large centers are closed (lesson 1.2.4 measured the same
pattern hour by hour in the level 1 EURUSD). London and New York look alike.
The figure puts the Tokyo and London spreads of each provider next to that of the aggregated book, which is built in
the next section. The starting point of the whole lesson is already visible: the provider with the narrowest spread is
LP-Delta. If the price it shows were the price at which it executes, the decision would be made.
The aggregated book: the best price of all
An aggregator does what an exchange does, but with other people's prices: for each instant it keeps the latest quote
from each provider and takes the highest bid and the lowest ask. The best bid and the best ask can come from different
providers. The function below does it by session with pivot_table and ffill (each one's latest valid quote) and
stores how long each state of the book lasts, because an average per event would give more weight to the moments with
more updates.
def libro_agregado(g):
b = g.pivot_table(index="ts", columns="lp", values="bid",
aggfunc="last").ffill().dropna()
a = g.pivot_table(index="ts", columns="lp", values="ask",
aggfunc="last").ffill().dropna()
libro = pd.DataFrame({"mejor_bid": b.max(axis=1),
"mejor_ask": a.min(axis=1),
"lp_bid": b.idxmax(axis=1)})
libro["spread"] = (libro["mejor_ask"]
- libro["mejor_bid"]) / PIP
t = libro.index.to_series()
libro["dur"] = t.diff().shift(-1).dt.total_seconds()
return libro.dropna()
libros = {s: libro_agregado(g) for s, g in cot.groupby("sesion")}
def resumen(l):
w = l["dur"] / l["dur"].sum() # weight by duration
return pd.Series({
"spread_medio": (w * l["spread"]).sum(),
"cruzado_pct": 100 * w[l["spread"] < 0].sum(),
})
pd.DataFrame({s: resumen(l) for s, l in libros.items()}).T.round(2) spread_medio cruzado_pct
asia 0.70 0.00
londres 0.19 20.01
nueva_york 0.27 11.51In London, the average spread of the aggregated book is 0.19 pips, less than half that of the best single provider (0.46). That is the advantage of aggregating: the best bid and the best ask rarely come from the same provider at the same instant. But the second column says something uncomfortable: during 20% of the London hour the book is crossed, with the best bid above the best ask, and 11.5% of the New York one. In Tokyo, never.
A crossed book looks like free money: buy at one provider's ask and sell at another's bid. It is not. Each provider publishes the reference price with its own delay (5 ms the fastest, 60 ms the slowest, according to the ground-truth file), and when the price moves fast, as at the London open, the slow one's quote lags behind and crosses the fast one's. Whoever tries to exploit it sends an order against the stale price, which is exactly the one that provider's last look is designed to reject. On an exchange, a crossed book cannot exist: orders that cross are executed. In an ECN with non-firm prices, it can.
l = libros["londres"]
cuota = l.groupby("lp_bid")["dur"].sum() / l["dur"].sum()
(100 * cuota).round(1).sort_values(ascending=False)lp_bid
LP-Delta 54.5
LP-Cirro 24.7
LP-Eris 10.7
LP-Borea 5.7
LP-Atlas 4.4
Name: dur, dtype: float64LP-Delta has the best bid 54.5% of the time and LP-Cirro 24.7%: between the two, almost 80%. An aggregator that
sends each order to the best price will give Delta more than half of the flow. That is why what Delta does with the
orders it receives matters so much.
Last look: the provider's last glance
The FX Global Code, the code of good practice for the currency market maintained by the central banks' Global Foreign Exchange Committee (GFXC), defines last look in its Principle 17 as a practice used in electronic trading in which the participant receiving a trade request has a final opportunity to accept or reject it against its quoted price2Note 2 · GFXC, FX Global Code, last updated December 2024, Principle 17; PDF of the Code.. The client sends an order against a published price; the provider holds it for a few milliseconds, checks and decides.
Mermaid source
stateDiagram-v2 [*] --> Quoting Quoting --> Request: client order Request --> Hold: window Hold --> Filled: passes Hold --> Rejected: fails Rejected --> Quoting: ask again Filled --> [*]
The Code does not prohibit the practice, but it limits it:
- If used, it should be a risk control mechanism to verify validity (that the trade details are correct and there is enough credit) and price (that the requested price is still consistent with the one the client could get at that moment).
- The provider should disclose, at a minimum, whether price changes in either direction influence its decision, how long it takes to decide and what it uses last look for.
- It cannot be used to obtain information without the intention of accepting, and the provider cannot trade during the window (neither adjust prices nor hedge) on the information in the request.
The August 2021 report of the GFXC working group adds two clarifications useful for measuring3Note 3 · GFXC, Execution Principles Working Group Report on Last Look, August 2021, sections 2 and 5.. The first is the distinction between a symmetric check (the same tolerance around the current price in both directions: it rejects if the price has moved more than the tolerance, in favor of or against the provider) and an asymmetric one (it accepts when the price has moved in the provider's favor and rejects when it has moved against it). The second: adding any delay beyond what is needed to perform the checks is considered contrary to Principle 17. The same report points to high rejection rates with long response times and asymmetric elements, and different response times between acceptances and rejections, as possible signs of misuse.
Measuring last look order by order
fx3_ecn_ordenes.parquet holds 2,266 IOC orders (immediate or cancel) from a client to the five providers, from 1 to
5 million evros. Each order carries the requested price, the send and response times, the status and the reference
price at send and at response (mid_envio, mid_respuesta), something a real client does not have: they would have
to estimate it with the mid of the aggregated book. The first thing is the sign: for a buy, the price rising during
the hold is good for the client and bad for the provider; for a sell, the other way around.
ords = pd.read_parquet("fx3_ecn_ordenes.parquet")
signo = np.where(ords["lado"] == "compra", 1, -1)
ords["retencion_ms"] = (ords["ts_respuesta"] - ords["ts_envio"]
).dt.total_seconds() * 1000
# move during the hold: + = in the client's favor
ords["mov"] = (signo * (ords["mid_respuesta"] - ords["mid_envio"])
/ PIP).round(1)
ords["rechazada"] = ords["estado"] == "rechazada"
por_lp = ords.groupby("lp").agg(
ordenes=("rechazada", "size"),
rechazo_pct=("rechazada", "mean"),
retencion_ms=("retencion_ms", "median"))
por_lp["rechazo_pct"] *= 100
por_lp.round(1) ordenes rechazo_pct retencion_ms
lp
LP-Atlas 290 1.7 4.0
LP-Borea 328 14.6 43.0
LP-Cirro 496 12.9 59.0
LP-Delta 820 27.7 152.0
LP-Eris 332 9.9 33.0LP-Delta rejects 27.7% of orders and takes a median of 152 ms to respond; LP-Atlas, 1.7% in 4 ms. LP-Delta is
also the one that receives the most orders (820), because it is the one that most often has the best price. The
measured hold time is the planted one plus network latency, from 1 to 3 ms: 4, 43, 59, 152 and 33 ms versus 2, 40, 60,
150 and 30 planted.
The rejection rate alone does not distinguish between a fair check and one that is not. What distinguishes them is when each one rejects: with the price still, when it has moved in the client's favor or when it has moved in the provider's favor.
ords["contexto"] = np.select(
[ords["mov"] > 0.05, ords["mov"] < -0.05],
["contra_lp", "a_favor_lp"], "quieto")
tasa = ords.pivot_table(index="lp", columns="contexto",
values="rechazada", aggfunc="mean")
(100 * tasa).round(1)contexto a_favor_lp contra_lp quieto
lp
LP-Atlas 0.0 NaN 1.7
LP-Borea 59.6 44.2 0.4
LP-Cirro 1.1 74.1 0.9
LP-Delta 0.4 93.4 0.3
LP-Eris 0.0 73.8 0.8The table separates the five policies without needing to know them:
LP-Atlas: no last look. It rejects 1.7% with the price still, never because of the move (and in 4 ms the price almost never moves: it has no orders in thecontra_lpcolumn).LP-Borea: symmetric. It rejects 44.2% when the price moves against it and 59.6% when it moves in its favor. It also rejects trades that would have turned out expensive for the client.LP-Cirro,LP-DeltaandLP-Eris: asymmetric. They reject 74%, 93% and 74% when the price goes against them, and almost nothing (1.1%, 0.4% and 0%) when it goes in their favor.
With the exact move, the threshold and the rejection probability are recovered:
ords["mov_tramo"] = ords["mov"].clip(-0.3, 0.3)
por_mov = ords.pivot_table(index="mov_tramo", columns="lp",
values="rechazada", aggfunc="mean")
(100 * por_mov.drop(columns="LP-Atlas")).round(0)lp LP-Borea LP-Cirro LP-Delta LP-Eris
mov_tramo
-0.3 69.0 0.0 1.0 0.0
-0.2 40.0 6.0 0.0 0.0
-0.1 62.0 0.0 0.0 0.0
0.0 0.0 1.0 0.0 1.0
0.1 44.0 50.0 94.0 71.0
0.2 38.0 96.0 93.0 70.0
0.3 55.0 82.0 93.0 80.0The reference price moves in steps of 0.1 pips, so the move only takes those values (the extremes group everything
beyond ±0.3). LP-Delta, with a threshold of 0, rejects 93–94% of any move against it, close to the planted 95%.
LP-Eris (threshold 0.05) rejects 70–80%, close to 70%. LP-Cirro has its threshold at 0.10 pips: with a move of
exactly 0.1 it rejects half (it is on the boundary, and the comparison of a move equal to the threshold falls on one
side or the other by rounding), and from 0.2 on, 82–96%, close to the planted 90%. LP-Borea rejects between 38% and
69% on both sides: the planted 60%, with the noise of 10 to 24 orders per cell. The figure sums it up:
Response times: accepted versus rejected
The GFXC report points to different response times between acceptances and rejections as a possible sign of misuse. In the data they can be compared:
ords.pivot_table(index="lp", columns="estado",
values="retencion_ms", aggfunc="median")estado ejecutada rechazada
lp
LP-Atlas 4.0 4.0
LP-Borea 41.0 47.0
LP-Cirro 58.5 79.5
LP-Delta 145.0 173.0
LP-Eris 32.0 38.0Rejected orders take longer at the four providers with last look: 173 ms versus 145 at LP-Delta, 79.5 versus 58.5 at
LP-Cirro. In this data there is no extra hold planted for rejecting: the difference arises on its own, because a
longer hold leaves more time for the price to move and the order to fail the check. It is a good reminder that the
report's signs are signs: a difference in times calls for an explanation from the provider, it does not prove abuse.
What would be a clear signal is a hold longer than the one the provider declares.
What it really costs: bid-ask spread plus rejections
To compare providers you need a cost measure that includes rejections. A filled order costs the half-spread: the distance between the requested price and the mid at the moment of sending. A rejected order costs nothing on the spot, but it leaves the client without the position and with the price moved: if the price rose while the provider was looking at a buy, the client has to ask for a price again from the new level. That lost move is the cost of the rejection. If the rejection comes with the price moved against the client, the cost is negative: the provider has saved them a bad trade.
with for buys and for sells and the move of the mid during the hold in the client's favor, in pips. The measure assumes that, after a rejection, the client pays the same half-spread again; it ignores the lost time and the risk that the price keeps moving while they ask for another price, so it is a lower bound of the real cost.
ords["semi_spread"] = signo * (ords["precio_solicitado"]
- ords["mid_envio"]) / PIP
ords["coste_rechazo"] = ords["rechazada"] * ords["mov"]
coste = ords.groupby("lp")[["semi_spread", "coste_rechazo"]].mean()
coste["efectivo"] = coste.sum(axis=1)
coste["usd_por_millon"] = 100 * coste["efectivo"]
coste.round(3) semi_spread coste_rechazo efectivo usd_por_millon
lp
LP-Atlas 0.571 0.000 0.571 57.052
LP-Borea 0.502 -0.004 0.499 49.866
LP-Cirro 0.250 0.030 0.280 28.020
LP-Delta 0.199 0.079 0.278 27.807
LP-Eris 0.370 0.024 0.394 39.361This is the header figure. LP-Delta's last look costs the client 0.079 pips per order, 40% of its half-spread of
0.199. Even so, its effective cost (0.278 pips, about 28 USD per million) is half that of LP-Atlas (0.571), which
rejects nothing but quotes three times wider. LP-Cirro and LP-Delta end up tied, and LP-Eris pays 0.024 in
rejections on a half-spread of 0.370. LP-Borea's symmetric check comes out free on average (−0.004): it rejects both
in favor of and against the client.
The conclusion is not “last look does not matter”: it is that you have to measure it. With these parameters and this client, the narrow spread makes up for the rejections. With a different client, it does not (exercise 2).
Markout: the same math from the fills
A real client does not know the reference price, but they can measure how much the market price moved after each fill: the markout. If the provider filters orders asymmetrically, the fills that remain are those that did not move or moved in the provider's favor, and the average markout of the fills is negative for the client.
ejec = ords[~ords["rechazada"]]
markout = ejec.groupby("lp")["mov"].agg(["mean", "size"])
markout["mean"] = markout["mean"].round(3)
markout mean size
lp
LP-Atlas -0.002 285
LP-Borea 0.003 280
LP-Cirro -0.028 432
LP-Delta -0.097 593
LP-Eris -0.017 299LP-Delta has an average markout of −0.097 pips on its 593 fills: the price moved against the client during the
hold, because the ones that would have moved in their favor were rejected. LP-Atlas has −0.002, as befits a provider
that does not filter. LP-Borea, symmetric, has +0.003 because it filters on both sides. If the price has no trend,
the sum of the moves of all orders is zero, so the negative markout of the fills and the positive cost of the
rejections are the same money seen from two sides. In a real transaction cost analysis (TCA), markout is measured at
several horizons (100 ms, 1 s, 5 s) with the mid of the aggregated book, by provider and by session.
Mermaid source
flowchart TB O[Own orders] --> R[Rejection by move] O --> T[Times] O --> K[Markout] R --> C[Effective cost] K --> C T --> D[Questions to the LP] C --> E[Flow allocation]
What this changes in a trading system
- The backtest price is not the execution price. A currency backtest with the mid and a fixed spread assumes firm orders. With last look, part of the orders are not filled, and those that are not filled are precisely the ones that were going well. For a low-frequency strategy the effect is small; for a minutes-scale one, it can be the whole edge (lessons 2.4.7 and 2.4.8).
- The quoted spread does not rank providers. The effective cost does. And it depends on each client's flow: it has to be measured with your own orders, not someone else's.
- The aggregated book is misleading in fast moments. A crossed book or a 0.05-pip spread at the London open are, in part, stale prices that are not going to be filled.
- The session matters. In the Tokyo hour the effective cost of all providers almost doubles (exercise 1). A system that trades at any hour should know at what hour it executes.
- Ask for the documents. The Code asks providers to publish their last look policy (symmetric or asymmetric, minimum and maximum window). The GFXC report proposes a standardized disclosure sheet. An institutional client can, and should, ask for it.
In lesson 3.3.3 real orders are sent to three brokers through their APIs; there, rejections show up as response codes that the program has to handle.
Exercises
- Compute the effective cost by provider and session. How much more expensive is it to trade in the Tokyo hour than in the London one? Does the ranking of providers change?
Solution
ords["efectivo"] = ords["semi_spread"] + ords["coste_rechazo"]
ords.pivot_table(index="lp", columns="sesion",
values="efectivo", aggfunc="mean").round(3)sesion asia londres nueva_york
lp
LP-Atlas 0.992 0.525 0.505
LP-Borea 0.804 0.454 0.451
LP-Cirro 0.445 0.249 0.255
LP-Delta 0.379 0.259 0.256
LP-Eris 0.636 0.356 0.393In Tokyo the effective cost is almost double that of London for everyone: 0.992 versus 0.525 pips at LP-Atlas,
0.379 versus 0.259 at LP-Delta. The ranking holds: the non-banks are the cheapest in all three sessions. With only
312 orders in Tokyo, the small differences (LP-Cirro versus LP-Delta) are not reliable.
- Imagine a client whose orders anticipate the price: they only trade when the price is about to move in their favor
in the following milliseconds. Simulate it by keeping the orders with
mov > 0and compute their rejection rate and their effective cost by provider. Which provider would they choose?
Solution
rapido = ords[ords["mov"] > 0]
t = rapido.groupby("lp").agg(
ordenes=("rechazada", "size"),
rechazo_pct=("rechazada", "mean"),
efectivo=("efectivo", "mean"))
t["rechazo_pct"] *= 100
t.round(2) ordenes rechazo_pct efectivo
lp
LP-Borea 43 44.19 0.53
LP-Cirro 81 74.07 0.45
LP-Delta 241 93.36 0.46
LP-Eris 42 73.81 0.53For that client, the asymmetric providers reject almost everything: LP-Delta 93.4% and LP-Cirro 74.1%. Their
effective cost rises from 0.28 to 0.46 pips at LP-Delta and from 0.28 to 0.45 at LP-Cirro. LP-Atlas has no
orders in the sample (with a 4 ms hold the price does not get to move), but since it does not reject, it would cost
its half-spread, 0.57 pips. By this measure LP-Delta is still slightly cheaper, and yet this client would not choose
it: it fills only 7% of their orders, and the measure only counts the move during the hold, not the one the client
expects to capture afterwards. If their edge is larger than a few tenths of a pip, what they need is to get filled,
and they gladly pay the spread of a firm price; that is why firm-price providers quote wider. It is the adverse
selection of lesson 2.3.6 seen from the other side: last look is the way the provider protects itself from this
client.
- A provider tells you that its price check is symmetric, with a tolerance of 0.05 pips and a window of up to 50 ms. With the orders you have sent it, which three measures from this lesson would you use to verify it?
Solution
First, the rejection table by context: a symmetric check rejects in a similar proportion when the price moves in
favor of and against the provider (like LP-Borea), and almost nothing with the price still. Second, the
distribution of the hold time: the median and the high percentiles should be within the declared 50 ms, and the
difference between accepted and rejected orders should be explained by the duration (long holds are rejected more)
and not by a delay added to rejections. Third, the markout of the fills: with a symmetric check it should be close to
zero; a clearly negative one would indicate that, in practice, it filters asymmetrically.
Footnotes
-
Banco de España, Triennial survey on the OTC foreign exchange and interest rate derivatives markets in Spain (2025) and press release of September 30, 2025, accessed on October 22, 2026. ↩
-
GFXC, FX Global Code, last updated December 2024, Principle 17; PDF of the Code. ↩
-
GFXC, Execution Principles Working Group Report on Last Look, August 2021, sections 2 and 5. ↩
Each answer is folded under its question
Self-assessment
According to Principle 17 of the FX Global Code, what should last look be used for?
- AFor the provider to decide whether the trade suits it in view of the client's intention
- BAs a risk control mechanism to verify the validity of the trade and the price
- CTo hedge in the market before accepting the order
- DTo hold the order as long as needed until the price improves
Show answer
Answer B. As a risk control mechanism to verify the validity of the trade and the price
The Code says that, if used, last look should be a risk control mechanism to verify validity and/or price. It prohibits using it to obtain information without the intention of accepting and trading during the window on the information in the request; the 2021 GFXC report adds that any delay beyond what is needed for the checks is contrary to Principle 17.
In the data, LP-Borea rejects 44% of orders when the price moves against it and 60% when it moves in its favor. What kind of price check does it apply?
- AAsymmetric
- BNone: they are background rejections
- CSymmetric: it rejects if the price has moved more than the tolerance in either direction
- DA price improvement
Show answer
Answer C. Symmetric: it rejects if the price has moved more than the tolerance in either direction
A symmetric check applies the same tolerance around the current price in both directions. It also rejects when the move hurts the client, who is thus spared a fill that would have cost them. Its average cost per rejection is almost zero (−0.004 pips in the data).
LP-Delta quotes the narrowest bid-ask spread (0.46 pips in London) but rejects 27.7% of orders. How does it compare with LP-Atlas, with no last look and 1.26 pips?
- ADelta is still cheaper: its half-spread of 0.20 pips plus 0.08 of rejection cost gives 0.28, versus 0.57 for Atlas
- BDelta is more expensive, because it rejects a lot
- CAtlas is cheaper, because it never rejects
- DThey cannot be compared without knowing the order size
Show answer
Answer A. Delta is still cheaper: its half-spread of 0.20 pips plus 0.08 of rejection cost gives 0.28, versus 0.57 for Atlas
The effective cost adds the half-spread paid and, on rejected orders, the move in the client's favor that is lost by having to ask for a price again. In the data, Delta's last look costs 0.08 pips per order, 40% of its half-spread, but it is not enough to make Atlas cheaper. With a flow that anticipates the price (exercise 2) the math changes.
London's aggregated book is crossed (best bid above best ask) 20% of the time. What does it indicate?
- AA risk-free arbitrage opportunity
- BAn error in the data
- CThat the providers collude
- DThat each provider's quotes arrive with different latencies: some reflect the price of 5 ms ago and others that of 60 ms ago, and in a fast market they cross
Show answer
Answer D. That each provider's quotes arrive with different latencies: some reflect the price of 5 ms ago and others that of 60 ms ago, and in a fast market they cross
Each LP publishes its price with its own delay. When the reference price moves, the slowest one lags behind and its quote can cross that of a faster one. Whoever tries to cross the two runs into the last look of the lagging provider: a price that is not firm is not an arbitrage.
The fills of a provider with asymmetric last look have a negative average markout for the client. Why?
- ABecause it charges hidden fees
- BBecause the fills are done at a worse price than requested
- CBy chance
- DBecause the orders in which the price moved in the client's favor were rejected: only those that did not move or moved in the provider's favor are filled
Show answer
Answer D. Because the orders in which the price moved in the client's favor were rejected: only those that did not move or moved in the provider's favor are filled
In the data the fills are always at the requested price. The bias comes from what gets filled: the asymmetric filter removes the orders that would have won and leaves the rest. The markout of the fills and the cost of the rejections are two sides of the same money.
Key points
- Currencies trade OTC: each liquidity provider publishes its price separately and the book the client sees is built by the client or their aggregator. In Spain, according to the Banco de España, 94% of the volume in April 2025 was with foreign counterparties.
- The aggregated book is narrower than any single provider (0.19 pips on average in London versus 0.46 for the best one), but it is crossed 20% of the time because of the different latencies: not every visible price is firm.
- Last look is a last opportunity for the provider to accept or reject. The FX Global Code allows it as a validity and price check, requires disclosing whether it is applied symmetrically or asymmetrically and prohibits using it to obtain information.
- The data recover the planted parameters: median hold times of 4, 43, 59, 152 and 33 ms (2, 40, 60, 150 and 30 plus 1–3 ms of network), rejection of 93% at LP-Delta when the price goes against it and 0.4% when it goes in its favor.
- The correct comparison between providers is the effective cost: half-spread plus the cost of rejections. LP-Delta, with the narrowest bid-ask spread, costs 0.28 pips per order; LP-Atlas, with no last look, 0.57.
- The result depends on the flow: a client whose orders anticipate the price sees much more frequent and expensive rejections. Transaction cost analysis (TCA) by provider, with markouts and rejections, is the only way to know.