---
title: "3.3.1 FX microstructure: OTC, ECN and last look"
description: "How currencies trade: OTC, ECNs and an aggregated book of several providers, sessions and last look measured order by order with the FX Global Code."
url: "https://pythonparatrading.com/en/python-trading-course/level-3/fx-microstructure-otc-ecn-last-look/"
lang: "en"
image: "https://pythonparatrading.com/og/en/python-trading-course/level-3/fx-microstructure-otc-ecn-last-look.png"
---

# 3.3.1 · 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.

- Course: [Level 3: Derivatives: futures, spreads, forex and options](https://pythonparatrading.com/en/python-trading-course/level-3/)
- Module: 3.3 · Advanced forex
- Duration: 60 min
- Prerequisites: [1.1.3 · Order book: bid, ask, spread and depth](https://pythonparatrading.com/en/python-trading-course/level-1/order-book-bid-ask-spread-depth/), [1.2.4 · Forex: pairs, pips, lots, rollover and CFDs](https://pythonparatrading.com/en/python-trading-course/level-1/forex-pairs-pips-lots-and-rollover/), [2.4.7 · Cost modeling: commissions, bid-ask spread, impact, financing and securities borrowing](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-transaction-cost-modeling/), [2.4.8 · Fill modeling: limit orders, queue, partial fills, gaps and stops](https://pythonparatrading.com/en/python-trading-course/level-2/backtest-order-fill-modeling/)

## By the end of this lesson

- Describe the OTC structure of the currency market: bank and non-bank liquidity providers, platforms, aggregators and clients.
- Build the aggregated book of several providers with pandas and measure its bid-ask spread, who sets the best price and how long it is crossed.
- Explain last look according to Principle 17 of the FX Global Code and distinguish the symmetric price check from the asymmetric one.
- Measure, on a client's orders, the hold time, the rejection conditional on the price move and the markout of the fills, and recover the planted parameters.
- Compare providers by their effective cost (half-spread plus the cost of rejections) and not only by the quoted bid-ask spread.

## 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
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 part[^1]:

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

```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)
```
```output
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:

```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)
```
```output
         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.* Dumbbell chart: for each provider, one point with the median spread in the Tokyo hour and another with London's, joined by a line. LP-Atlas 2.02 and 1.26 pips; LP-Borea 1.66 and 1.04; LP-Eris 1.28 and 0.80; LP-Cirro 0.92 and 0.58; LP-Delta 0.74 and 0.46. The aggregated book, time-weighted mean, 0.70 in Tokyo and 0.19 in London (highlighted): aggregation narrows the spread most when activity is high.

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.

```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)
```
```output
            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.

```python
l = libros["londres"]
cuota = l.groupby("lp_bid")["dur"].sum() / l["dur"].sum()
(100 * cuota).round(1).sort_values(ascending=False)
```
```output
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 price[^2].
The client sends an order against a published price; the provider holds it for a few milliseconds, checks and
decides.

```mermaid
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 measuring[^3]. 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.

<aside class="callout" data-kind="nota">

Last look exists for an economic reason. A provider publishes prices to hundreds of clients at once; if the price
moves, all of them can send it orders against the old quote in the same millisecond. Without a way to protect itself,
it would quote wider spreads for everyone. Last look is the protection; the client pays the price of that protection
in the form of rejections. The question is not whether last look is good or bad, but how much it costs with each
provider.

</aside>

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

```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)
```
```output
          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.

```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)
```
```output
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:

```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)
```
```output
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.* Lines with the rejection rate (0 to 100%) against the price move during the hold, from −0.3 to +0.3 pips (positive = against the provider). LP-Delta, highlighted, is at 0–1% with negative or zero moves and jumps to 93–94% with any positive move. LP-Cirro and LP-Eris jump the same way, LP-Cirro at 50% at +0.1 (its threshold) and 82–96% beyond; LP-Eris at 70–80%. LP-Borea, symmetric, rejects between 38% and 69% on both sides and nothing when the price is still.

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

```python
ords.pivot_table(index="lp", columns="estado",
                 values="retencion_ms", aggfunc="median")
```
```output
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 = \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 = +1$ for buys and $-1$ for sells and $\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.

```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)
```
```output
          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.

```python
ejec = ords[~ords["rechazada"]]
markout = ejec.groupby("lp")["mov"].agg(["mean", "size"])
markout["mean"] = markout["mean"].round(3)
markout
```
```output
           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.

```mermaid
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.**

```python
ords["efectivo"] = ords["semi_spread"] + ords["coste_rechazo"]
ords.pivot_table(index="lp", columns="sesion",
                 values="efectivo", aggfunc="mean").round(3)
```
```output
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.

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

```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)
```
```output
          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.

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

[^1]: Banco de España, [Triennial survey on the OTC foreign exchange and interest rate derivatives markets in Spain (2025)](https://www.bde.es/wbe/en/estadisticas/temas/mercados-financieros/encuesta-sobre-mercados-divisas-derivados-otc/encuesta-sobre-el-mercado-de-divisas-y-de-derivados-otc-2025.html) and [press release of September 30, 2025](https://www.bde.es/f/webbe/GAP/Secciones/SalaPrensa/NotasInformativas/25/presbe2025-89en.pdf), accessed on October 22, 2026.
[^2]: GFXC, [FX Global Code](https://www.globalfxc.org/fx_global_code.htm), last updated December 2024, Principle 17; [PDF of the Code](https://www.globalfxc.org/uploads/fx_global.pdf).
[^3]: GFXC, [Execution Principles Working Group Report on Last Look](https://www.globalfxc.org/docs/gfxc_report_last_look.pdf), August 2021, sections 2 and 5.

## Self-assessment

1. According to Principle 17 of the FX Global Code, what should last look be used for?

   - A. For the provider to decide whether the trade suits it in view of the client's intention
   - B. As a risk control mechanism to verify the validity of the trade and the price
   - C. To hedge in the market before accepting the order
   - D. To hold the order as long as needed until the price improves

   *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?

   - A. Asymmetric
   - B. None: they are background rejections
   - C. Symmetric: it rejects if the price has moved more than the tolerance in either direction
   - D. A price improvement

   *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?

   - 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
   - B. Delta is more expensive, because it rejects a lot
   - C. Atlas is cheaper, because it never rejects
   - D. They cannot be compared without knowing the order size

   *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?

   - A. A risk-free arbitrage opportunity
   - B. An error in the data
   - C. That the providers collude
   - 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

   *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?

   - A. Because it charges hidden fees
   - B. Because the fills are done at a worse price than requested
   - C. By chance
   - 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

   *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

### Research

- [Limit Order Book - Aggregated Market Liquidity](https://pythonparatrading.com/en/limit-order-book-and-market-liquidity/) · Research-0008 · 11 min
- [Quantitative Models and Algorithmic Execution](https://pythonparatrading.com/en/quantitative-models-algorithmic-execution/) · Research-0009 · 11 min

### Documentation

- [FX Global Code (December 2024): Principle 17](https://www.globalfxc.org/uploads/fx_global.pdf) · globalfxc.org
- [GFXC (2021): Execution Principles Working Group Report on Last Look](https://www.globalfxc.org/docs/gfxc_report_last_look.pdf) · globalfxc.org
- [Banco de España: triennial survey on the OTC foreign exchange and derivatives markets (2025)](https://www.bde.es/wbe/en/estadisticas/temas/mercados-financieros/encuesta-sobre-mercados-divisas-derivados-otc/encuesta-sobre-el-mercado-de-divisas-y-de-derivados-otc-2025.html) · bde.es
- [Oomen (2017): Last look, Quantitative Finance 17(7)](https://doi.org/10.1080/14697688.2016.1262545) · doi.org

### Data

- [Level 3 data: a client's orders to five QEVUSD providers (Parquet)](https://pythonparatrading.com/curso/datos/nivel-3/fx3_ecn_ordenes.parquet) · Download · fx3_ecn_ordenes.parquet

Previous lesson: [3.2.6 · Seasonality of spreads: multi-year analysis, patterns and traps](https://pythonparatrading.com/en/python-trading-course/level-3/seasonality-of-futures-spreads/)

Next lesson: [3.3.2 · Carry and crash risk, momentum and value in currencies; baskets](https://pythonparatrading.com/en/python-trading-course/level-3/currency-carry-momentum-value/)
