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.

Fig. — Effective cost of an order with each provider: half-spread paid plus cost of rejections (QEVUSD, three sessions on 12 March 2025)
Level 3 lessons
  1. 3.1 · Futures in depth

    1. 3.1.1 Main futures contracts
    2. 3.1.2 Futures curve: basis, cost of carry, contango and roll yield
    3. 3.1.3 Roll management, margins, SPAN and implied leverage
    4. 3.1.4 Diversified futures systems: Carver's approach
  2. 3.2 · Futures spreads

    1. 3.2.1 Calendar spreads, butterflies and condors
    2. 3.2.2 Intercommodity spreads: crack, spark and crush
    3. 3.2.3 Rate and index spreads: 2s10s, NOB, NQ/ES and Europe versus the U.S.
    4. 3.2.4 Hedge ratios: by notional, by DV01 and by volatility
    5. 3.2.5 Spread data and backtesting: listed, synthetic and legged
    6. 3.2.6 Seasonality of spreads: multi-year analysis, patterns and traps
  3. 3.3 · Advanced forex

    1. 3.3.1 FX microstructure: OTC, ECN, last look and sessions
    2. 3.3.2 Carry and crash risk, momentum and value in currencies; baskets
    3. 3.3.3 Currency risk in multi-currency portfolios; execution via OANDA, IBKR or MT5
  4. 3.4 · Option theory and pricing

    1. 3.4.1 Payoffs, put-call parity and arbitrage bounds
    2. 3.4.2 Binomial trees, Black-Scholes-Merton, Black-76 and Monte Carlo
    3. 3.4.3 Greeks: delta, gamma, vega, theta, rho, vanna, volga and charm
    4. 3.4.4 Implied volatility: Newton-Raphson, Brent, py_vollib and QuantLib
    5. 3.4.5 Smile, skew and term structure; volatility surface with SVI
    6. 3.4.6 Realized vs implied volatility; volatility risk premium; VIX and VSTOXX
    7. 3.4.7 Advanced models: Heston, SABR and local volatility
  5. 3.5 · Option structures in Python

    1. 3.5.1 Directional and vertical structures: call, put, covered call, collar and spreads
    2. 3.5.2 Volatility and range structures: straddles, strangles, condors and butterflies
    3. 3.5.3 Calendars, diagonals, ratios, backspreads, risk reversals and jade lizard
    4. 3.5.4 A custom tool: payoff, aggregate Greeks and scenarios
    5. 3.5.5 Management: adjustments, rolls, early closes, early assignment and pin risk
  6. 3.6 · Systematic volatility and options strategies

    1. 3.6.1 Systematic premium selling: put-write, strangles and their tail risk
    2. 3.6.2 Delta hedging, gamma scalping and dispersion
    3. 3.6.3 VIX and VSTOXX strategies: volatility futures and ETPs
    4. 3.6.4 Options backtesting: data, fills and event adjustments
  7. 3.7 · Crypto derivatives and specific strategies

    1. 3.7.1 Perpetuals: funding mechanics, mark price and liquidations
    2. 3.7.2 Funding arbitrage, basis with futures and cross-exchange arbitrage
    3. 3.7.3 Crypto options, on-chain data and DeFi
    4. 3.7.4 ccxt for trading on multiple exchanges
    5. 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.

Bank LPsInterbankMulti-dealer and ECNNon-bank LPsSingle-dealer platformAggregatorFund or CTARetail brokerRetail client
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 2025Average daily volume
Total67.9 billion USD (39.2 in April 2022)
FX swaps44.8 billion (66%)
Spot11.8 billion (17%)
With other reporting dealers (banks)53%
With other financial institutions41%
With non-financial clients6%
With foreign counterparties94%

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.

IN [01]python
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)
OUT [01]Quotes from five providers
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  2000000

Each 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:

IN [02]python
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)
OUT [02]Bid-ask spread and update rate of each provider by session
         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.40

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

Fig. — QEVUSD spread by provider in the Tokyo and London hours, and that of the five-provider aggregated book

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.

IN [03]python
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)
OUT [03]Aggregated book by session
            spread_medio  cruzado_pct
asia                0.70         0.00
londres             0.19        20.01
nueva_york          0.27        11.51

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

IN [04]python
l = libros["londres"]
cuota = l.groupby("lp_bid")["dur"].sum() / l["dur"].sum()
(100 * cuota).round(1).sort_values(ascending=False)
OUT [04]Who sets the best bid in London
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: float64

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

client orderwindowpassesfailsask againQuotingRequestHoldFilledRejected
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.

IN [05]python
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)
OUT [05]Orders, hold time and rejections by provider
          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.0

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

IN [06]python
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)
OUT [06]Rejection by the move during the hold
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.8

The 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 the contra_lp column).
  • 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-Delta and LP-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:

IN [07]python
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)
OUT [07]Rejection by size of the move
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.0

The 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:

Fig. — Rejection rate of each last-look provider by how much the price moved during the hold

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:

IN [08]python
ords.pivot_table(index="lp", columns="estado",
                 values="retencion_ms", aggfunc="median")
OUT [08]Median hold time of accepted and rejected orders
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.0

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

c=s⋅preq−msendpip⏟half-spread+1reject⋅Δm⏟rejection costc = \underbrace{s \cdot \frac{p_{\text{req}} - m_{\text{send}}}{\text{pip}}}_{\text{half-spread}} + \underbrace{\mathbb{1}_{\text{reject}} \cdot \Delta m}_{\text{rejection cost}}

with s=+1s = +1 for buys and −1-1 for sells and Δm\Delta m 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.

IN [09]python
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)
OUT [09]Effective cost by provider
          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.361

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

IN [10]python
ejec = ords[~ords["rechazada"]]
markout = ejec.groupby("lp")["mov"].agg(["mean", "size"])
markout["mean"] = markout["mean"].round(3)
markout
OUT [10]Markout of the fills during the hold
           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   299

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

Own ordersRejection by moveTimesMarkoutEffective costQuestions to the LPFlow allocation
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

  1. 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
IN [11]python
ords["efectivo"] = ords["semi_spread"] + ords["coste_rechazo"]
ords.pivot_table(index="lp", columns="sesion",
                 values="efectivo", aggfunc="mean").round(3)
OUT [11]Effective cost by session
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.393

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

  1. 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 > 0 and compute their rejection rate and their effective cost by provider. Which provider would they choose?
Solution
IN [12]python
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)
OUT [12]A flow that anticipates the price
          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.53

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

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

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

  2. GFXC, FX Global Code, last updated December 2024, Principle 17; PDF of the Code. ↩

  3. GFXC, Execution Principles Working Group Report on Last Look, August 2021, sections 2 and 5. ↩

Each answer is folded under its question

Self-assessment

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

  2. 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).

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

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

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

Resources