Capstone — FX RFQ market making: learning to quote

Everything in Track 2 was building to this. We are a dealer streaming prices in EUR/USD. Clients send us Requests (“I want to trade 5M, what’s your price?”); we answer with a quote; the ones they accept become Fills — our trades. We never chose a direction; the client did. What we control is how we price, and the risk we take on as a result.

The dataset, three interleaved step-indexed streams

  • Market — a level-1 book (bid/ask/sizes) whose mid random-walks. Our reference. Steps, not clock time.
  • Requests — client RFQs: side (their direction), qty.
  • Fills — the quotes they lifted. No partial fills: all-or-nothing.

The decision: quote width and skew

Each RFQ, the agent picks two numbers:

  • width — the half-spread it charges. Wide earns more per fill but wins fewer; tight wins flow but on a thin edge.
  • skew — a directional shading. Long and a client wants to buy (flattening us)? Skew tight to win it. A trade that piles on more inventory? Skew wide.

Why this is a real MDP, and genuinely hard

Inventory is the state. Every fill leaves us holding risk that marks to market as the mid moves — and on a driftless mid, that PnL has zero mean but large variance. So the objective is not “earn spread”; it is “earn spread per unit of inventory risk”. The agent must learn the Avellaneda–Stoikov instinct — skew to offload, widen when nervous — from reward alone.

We train PPO (our Track 2 workhorse) on synthetic data, then judge it on a held-out market against two baselines: a fixed spread, and a hand-crafted inventory-skewing dealer. Beating the skewing baseline on risk-adjusted PnL is the bar.

Plugging in real data. The env consumes a market DataFrame (step, bid_px, ask_px, bid_sz, ask_sz, mid) and a requests DataFrame (step, req_id, client_id, side, qty). Swap the synthetic generators for your own files with those columns and everything below runs unchanged.

Run order matters. The cells below build on each other. Run them top to bottom (Jupyter: Run → Run All Cells; VS Code: Run All). If you hit NameError (or similar), you skipped the Setup cell — run it first.

Setup — imports & configuration

Run this cell first. It imports every library and defines the module-level constants the rest of the notebook relies on.

import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from stable_baselines3 import PPO
from stable_baselines3.common.monitor import Monitor
from stable_baselines3.common.callbacks import BaseCallback
from common.fx_market_making import (
    generate_market, generate_requests, FXMarketMakingEnv,
    fixed_spread_policy, inventory_skew_policy, PIP,
)
from common import sharpe_ratio
SEED = 0
N_TRAIN = 8000
N_TEST = 4000
TOTAL_STEPS = 300_000

show_sample_data (function)

Print a small slice of the three streams so the schema is concrete.

We generate a short market, a few RFQs, run a fixed-spread dealer over it, and show the fills its quotes produced. This is exactly the data shape the environment consumes — and the shape your real files should have.

def show_sample_data():
    rng = np.random.default_rng(42)
    mkt = generate_market(12, rng, vol_pips=0.5)
    req = generate_requests(mkt, rng, arrival_rate=0.7)
    # window=1 so even the earliest RFQs are quoted; a tight 0.3-pip quote wins
    # most of them, so the fills table is populated for illustration.
    env = FXMarketMakingEnv(mkt, req, window=1)
    env.reset()
    done = False
    while not done:
        _, _, term, trunc, _ = env.step(fixed_spread_policy(env, 0.3, 0.0))
        done = term or trunc
    print("MARKET (level-1 book we quote off)")
    print(mkt.to_string(index=False))
    print("\nREQUESTS (client RFQs)")
    print(req.to_string(index=False))
    print("\nFILLS (quotes the client accepted = our trades)")
    print(pd.DataFrame(env.fills).to_string(index=False) if env.fills else "(none)")

make_env (function)

Build a market-making env from a fresh synthetic market + RFQ stream.

A different seed gives a different price path and client flow, which is how we get a genuinely held-out test set — the single most important guard against fooling ourselves in trading RL.

def make_env(seed, n):
    rng = np.random.default_rng(seed)
    market = generate_market(n, rng)
    requests = generate_requests(rng=rng, market=market)
    return FXMarketMakingEnv(market, requests)

rollout (function)

Run one full pass of env under an action function; collect traces.

Returns per-step PnL (edge + mark-to-market), the inventory path, and the width/skew the agent quoted — enough to score it and to see whether it learned to skew with inventory.

def rollout(env, act_fn):
    obs, _ = env.reset()
    pnls, inv_path, widths, skews = [], [], [], []
    done = False
    while not done:
        action = act_fn(env, obs)
        widths.append(env.min_hs + (np.clip(action[0], -1, 1) + 1) / 2 * (env.max_hs - env.min_hs))
        skews.append(np.clip(action[1], -1, 1) * env.max_skew)
        obs, reward, term, trunc, info = env.step(action)
        pnls.append(info["edge_usd"] + info["inv_mtm"])
        inv_path.append(info["inventory"])
        done = term or trunc
    return {
        "pnls": pnls, "inventory": inv_path, "widths": widths, "skews": skews,
        "cash_pnl": float(env.cash), "n_fills": len(env.fills),
        "inv_std_m": float(np.std(inv_path)) / 1e6,
        "sharpe": sharpe_ratio(pnls),
    }

LearningCurve (class)

Log each episode’s reward during training for a learning-curve plot.

class LearningCurve(BaseCallback):

    def __init__(self):
        super().__init__()
        self.rewards = []

    def _on_step(self):
        for info in self.locals.get("infos", []):
            ep = info.get("episode")
            if ep is not None:
                self.rewards.append(ep["r"])
        return True

train_ppo (function)

Train PPO on the market-making env.

PPO is on-policy and robust — a sensible default when the reward is as noisy as inventory mark-to-market makes this one. We give it a slightly larger rollout (n_steps) so each update sees many RFQs and inventory cycles.

def train_ppo(train_env, total_steps):
    model = PPO(
        "MlpPolicy", train_env, seed=SEED, device="cpu", verbose=0,
        n_steps=4096, batch_size=256, gae_lambda=0.95, gamma=0.99,
        ent_coef=0.005, learning_rate=3e-4, n_epochs=10,
    )
    cb = LearningCurve()
    model.learn(total_timesteps=total_steps, callback=cb, progress_bar=False)
    return model, cb.rewards

main (function)

def main():
    print("=" * 68, "\nA sample of the FX RFQ dataset\n", "=" * 68, sep="")
    show_sample_data()

    # --- train on one market, hold out another --------------------------------
    train_env = Monitor(make_env(SEED, N_TRAIN))
    print(f"\nTraining PPO for {TOTAL_STEPS:,} steps on the market-making env...")
    model, curve = train_ppo(train_env, TOTAL_STEPS)

    test_env = make_env(SEED + 999, N_TEST)

    # --- score PPO and the two baselines on the SAME held-out market ----------
    def ppo_act(env, obs):
        action, _ = model.predict(obs, deterministic=True)
        return action

    results = {
        "PPO (learned)":      rollout(test_env, ppo_act),
        "fixed 1.0 pip":      rollout(test_env, lambda e, o: fixed_spread_policy(e, 1.0, 0.0)),
        "inventory-skew (AS)": rollout(test_env, lambda e, o: inventory_skew_policy(e, 1.0)),
    }

    print("\nHeld-out performance (higher cash & Sharpe, lower inv-risk = better)")
    print(f"{'policy':22s}{'cash PnL':>12s}{'fills':>8s}{'inv std':>10s}{'Sharpe':>9s}")
    for name, r in results.items():
        print(f"{name:22s}{r['cash_pnl']:>12,.0f}{r['n_fills']:>8d}"
              f"{r['inv_std_m']:>9.1f}M{r['sharpe']:>9.2f}")

    # --- plots ----------------------------------------------------------------
    fig, axes = plt.subplots(2, 2, figsize=(13, 9))

    from common import plot_learning_curve
    plot_learning_curve(curve, window=20, title="PPO learning curve (episode reward)", ax=axes[0, 0])

    ax = axes[0, 1]
    for name, r in results.items():
        ax.plot(np.cumsum(r["pnls"]) / 1e3, label=name)
    ax.axhline(0, color="k", lw=0.6); ax.set_title("Cumulative PnL, held-out ($k)")
    ax.set_xlabel("step"); ax.set_ylabel("cum PnL ($k)"); ax.legend(); ax.grid(alpha=0.3)

    ax = axes[1, 0]
    for name, r in results.items():
        ax.plot(np.asarray(r["inventory"]) / 1e6, label=name, lw=0.9)
    ax.axhline(0, color="k", lw=0.6); ax.set_title("Inventory path (lower variance = better risk control)")
    ax.set_xlabel("step"); ax.set_ylabel("inventory (M units)"); ax.legend(); ax.grid(alpha=0.3)

    ax = axes[1, 1]
    ppo = results["PPO (learned)"]
    inv_m = np.asarray(ppo["inventory"]) / 1e6
    ax.scatter(inv_m, ppo["skews"], s=4, alpha=0.3)
    ax.set_title("Did PPO learn to skew? (quoted skew vs inventory)")
    ax.set_xlabel("inventory (M units)"); ax.set_ylabel("quoted skew (pips)"); ax.grid(alpha=0.3)

    plt.tight_layout(); plt.show()

    ppo, as_, fx = results["PPO (learned)"], results["inventory-skew (AS)"], results["fixed 1.0 pip"]
    print(
        f"\nTakeaway: the bar is Avellaneda-Stoikov — a hand-built dealer that "
        f"skews with inventory (Sharpe {as_['sharpe']:.2f}). PPO reached Sharpe "
        f"{ppo['sharpe']:.2f}, essentially matching it, and did so holding LESS "
        f"risk (inventory std {ppo['inv_std_m']:.1f}M vs {as_['inv_std_m']:.1f}M) "
        f"while the naive fixed-spread dealer languishes at {fx['sharpe']:.2f} with "
        f"a reckless {fx['inv_std_m']:.0f}M inventory swing. PPO earns less gross "
        f"cash than the fixed dealer precisely because it declines the toxic flow "
        f"that would load it up — the bottom-right scatter shows it learned to skew "
        f"quotes against its inventory purely from reward, never told the AS "
        f"formula. That is the whole point: RL rediscovers the market-making "
        f"instinct from data, so on REAL flow — where AS's Brownian/Poisson "
        f"assumptions break — it has room to do better than the closed form. Swap "
        f"the synthetic generators in make_env() for your market/requests files "
        f"(same columns) to run this on live data."
    )

Run the lesson

Execute everything above, then run main().

main()
====================================================================
A sample of the FX RFQ dataset
====================================================================
MARKET (level-1 book we quote off)
 step   bid_px   ask_px  bid_sz  ask_sz      mid
    0 1.084985 1.085045 6000000 7000000 1.085015
    1 1.084933 1.084993 5000000 6000000 1.084963
    2 1.084971 1.085031 3000000 2000000 1.085001
    3 1.085018 1.085078 6000000 5000000 1.085048
    4 1.084920 1.084980 4000000 2000000 1.084950
    5 1.084855 1.084915 4000000 6000000 1.084885
    6 1.084862 1.084922 4000000 5000000 1.084892
    7 1.084846 1.084906 2000000 3000000 1.084876
    8 1.084845 1.084905 1000000 1000000 1.084875
    9 1.084802 1.084862 4000000 7000000 1.084832
   10 1.084846 1.084906 7000000 4000000 1.084876
   11 1.084885 1.084945 1000000 7000000 1.084915

REQUESTS (client RFQs)
 step    req_id  client_id side      qty
    1 RFQ000001     BANK_X  BUY  3000000
    2 RFQ000002  ACME_FUND SELL  5000000
    5 RFQ000005    HEDGE_Y  BUY  1000000
    6 RFQ000006     BANK_X  BUY  1000000
    7 RFQ000007     BANK_X  BUY  3000000
    8 RFQ000008 RETAIL_AGG  BUY 10000000
   10 RFQ000010    HEDGE_Y SELL  3000000
   11 RFQ000011    HEDGE_Y SELL  2000000

FILLS (quotes the client accepted = our trades)
 step    req_id  client_id side      qty  markup_pips   edge_usd  inventory_after
    1 RFQ000001     BANK_X  BUY  3000000          0.3  90.000006       -3000000.0
    5 RFQ000005    HEDGE_Y  BUY  1000000          0.3  30.000002       -4000000.0
    6 RFQ000006     BANK_X  BUY  1000000          0.3  30.000002       -5000000.0
    8 RFQ000008 RETAIL_AGG  BUY 10000000          0.3 300.000021      -15000000.0
   10 RFQ000010    HEDGE_Y SELL  3000000          0.3  90.000006      -12000000.0

Training PPO for 300,000 steps on the market-making env...

Held-out performance (higher cash & Sharpe, lower inv-risk = better)
policy                    cash PnL   fills   inv std   Sharpe
PPO (learned)              125,865     409      4.6M     1.85
fixed 1.0 pip              200,600     514     50.1M     0.20
inventory-skew (AS)        197,126     533     10.0M     1.64


Takeaway: the bar is Avellaneda-Stoikov — a hand-built dealer that skews with inventory (Sharpe 1.64). PPO reached Sharpe 1.85, essentially matching it, and did so holding LESS risk (inventory std 4.6M vs 10.0M) while the naive fixed-spread dealer languishes at 0.20 with a reckless 50M inventory swing. PPO earns less gross cash than the fixed dealer precisely because it declines the toxic flow that would load it up — the bottom-right scatter shows it learned to skew quotes against its inventory purely from reward, never told the AS formula. That is the whole point: RL rediscovers the market-making instinct from data, so on REAL flow — where AS's Brownian/Poisson assumptions break — it has room to do better than the closed form. Swap the synthetic generators in make_env() for your market/requests files (same columns) to run this on live data.