Lesson 7.6.6 · 65 min

Capstone C5: crypto market making or arbitrage with counterparty control

The brief is a small crypto arbitrage desk that collects perpetual funding without betting on the price, or that quotes both sides, and that survives the bankruptcy of an exchange. This guide sets the scope, runs the skeleton on the level 3 data, including the planted bankruptcy of dedalo, and applies the common rubric to what is defended before the panel.

Fig. — Final capital of the C5 skeleton funding carry by how it is spread across exchanges, with dedalo's planted bankruptcy in November 2022 (level 3 synthetic data, 2020-2025)
Level 7 lessons
  1. 7.1 · Software engineering for trading

    1. 7.1.1 Project structure, configuration and quality: from notebook to repository
    2. 7.1.2 Performance: measure, profile and compile only what is needed
    3. 7.1.3 Patterns: events, message queues and hexagonal architecture
  2. 7.2 · Infrastructure and deployment

    1. 7.2.1 Docker and docker-compose: reproducible images with uv
    2. 7.2.2 VPS, cloud and latency; CI/CD with GitHub Actions
    3. 7.2.3 Orchestration and monitoring: task graphs, structured logs, metrics, alerts and heartbeats
    4. 7.2.4 Security and continuity: 2FA, IP allowlists, backups and failure procedures
  3. 7.3 · MLOps for trading models

    1. 7.3.1 Tracking experiments, models and data: MLflow, DVC and a run reproduced end to end
    2. 7.3.2 Scheduled retraining, automatic validation and champion/challenger
    3. 7.3.3 Live model monitoring and automatic shutdown
    4. 7.3.4 Model serving: ONNX, CPU or GPU inference and the cost of LLMs in production
  4. 7.4 · Regulation, ethics and taxation

    1. 7.4.1 MiFID II: clients, suitability and algorithmic trading
    2. 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
    3. 7.4.3 PRIIPs, the KID and US ETFs
    4. 7.4.4 MiCA after the transitional period: checking a provider in the registers
    5. 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
    6. 7.4.6 Trading taxes in Spain
    7. 7.4.7 Ethics: personal data, artificial intelligence and selling signals
  5. 7.5 · Quant career

    1. 7.5.1 Quant roles: six jobs around the same system
    2. 7.5.2 Where to work: what each firm does and what the registers say
    3. 7.5.3 Public portfolio and interviews: show judgment, not returns
    4. 7.5.4 Certifications, training and prop firms: what each one certifies and what a challenge measures
  6. 7.6 · Final capstone

    1. 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
    2. 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
    3. 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
    4. 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
    5. 7.6.5 Capstone C4: a research platform with LLMs and alternative data
    6. 7.6.6 Capstone C5: crypto market making or arbitrage with counterparty control

The brief

A treasury desk trading its own capital wants a return that does not depend on the direction of cryptocurrency prices. It commissions a system from one of these two families, chosen by the student:

  • Funding and basis arbitrage. Buy spot and sell the perpetual (or a quarterly future) in the same quantity, collect the funding or lock in the basis, and spread the capital across several exchanges (lessons 2.3.7, 3.7.1 and 3.7.2).
  • Market making. Quote both sides of a book, earn the bid-ask spread and control inventory and adverse selection, with an Avellaneda-Stoikov-type model (lesson 2.3.6).

In both, the brief puts the same thing first: the capital is deposited on exchanges that can fail. The system has to state how much is on each one, why, what takes it out and how much is lost if one goes bankrupt. A strategy that returns a lot with all the capital on a single exchange does not meet the brief, even if the backtest is good.

Out of scope: DeFi and MEV (lesson 3.7.3), options, directional strategies, leverage above what the skeleton justifies, trading with real money and any exchange that does not pass the check of lesson 7.4.4 (authorization as a crypto-asset service provider under MiCA, or an equivalent registration outside the EU).

DeliverableWhat it containsHow it is checked
Repository7.6.1 template, uv and Docker, one command per stagemake todo reproduces the report on a clean machine
Counterparty registerExchanges, authorization, limits, segregation, withdrawal testsEvery exchange holding capital has its profile and its limit
Trial logEvery configuration tested, with its seriesThe DSR's number of trials comes from this file
Validation reportWalk-forward, DSR, bankruptcy and liquidation scenariosStopping rules are dated before the results
ExecutorTwo legs with own identifiers, unwind, reconcileTests with injected failures in the simulator
Paper trading journalFour weeks on demo accountsEvery funding period has its row and its reconciliation
Technical report and defense7.6.1 template, 20-minute presentationBefore the panel

Architecture

Exchange dataCarry signalAllocationCounterpartiesRisk and marginccxt executorExchange 1Exchange 2ReconciliationMonitor
Mermaid source
flowchart TB
  D[Exchange data] --> S[Carry signal]
  S --> A[Allocation]
  C[Counterparties] --> A
  A --> R[Risk and margin]
  R --> E[ccxt executor]
  E --> X1[Exchange 1]
  E --> X2[Exchange 2]
  X1 --> K[Reconciliation]
  X2 --> K
  K --> M[Monitor]

The flow is that of any live system (lesson 4.5.1), with two pieces specific to the project. The counterparty monitor is an input to the allocation on the same level as the signal: it can take an exchange out of the allocation even if it is the one that pays the most. And the allocation does not simply split by expected return: it splits with per-exchange limits that are set with the bankruptcy scenario, not with the backtest.

PieceLibraryWhere it was learned
Funding, mark price and liquidationpandas1.2.6, 3.7.1
Cash-and-carry, basis, differentials and counterpartypandas2.3.7, 3.7.2
Market making and inventorynumpy2.3.6
Unified API, errors, rate limits and idempotencyccxt, ccxt.pro3.7.4
Signatures, nonces, clocks and user streamsccxt, native APIs4.4.4
Credentials and least privilegeenvironment variables, secrets manager4.4.5
Counterparty risk and stresspandas4.2.4
Margin and liquidations in derivativespandas4.2.6
Orders, reconnections and reconciliationstate machine, ccxt4.5.2, 4.5.5
Multiple testing and DSRtrial log2.2.4, 2.5.4
Monitoring and shutdownmetrics, expiring permission7.2.3, 7.3.3
Providers authorized under MiCAESMA and CNMV registers3.7.5, 7.4.4

Data

The skeleton uses the level 3 data (public/curso/datos/nivel-3/); the market making variant, the level 2 data:

FileWhat it isWhat it is for here
cripto3_funding.parquet8-hour funding, index, mark and last price of the perpetual; 4 exchanges, 2 assets, 2020-2025Signal, collection and hedge
cripto3_exchanges.csvExchange profiles and the timeline of the dedalo bankruptcyCounterparty scenario and alarm
cripto3_1h.parquet1-hour bars of the alfa perpetual with the markMargin and liquidations
cripto3_futuros.parquet, cripto3_diario.parquetQuarterlies and daily closes by exchangeBasis and dislocations (3.7.2)
xsn_funding_8h.csv, xsn_ccxt_ohlcv.jsonFunding of another token and the ccxt formatTests of the data loader
mm2_medio.parquet, mm2_flujo.parquetOne session second by second with informed flowMarket making variant

The known answer, according to the level 3 README: the funding formula is Binance's (premium plus a clamped interest term), with a floor of 0.01% per period, 10.95% a year; the bullish premium narrows with a half-life of 2.5 years, and dedalo suspends withdrawals on November 8, 2022 at 12:00 and sets balances to zero on the 11th. Lesson 3.7.2 measured the carry by exchange and year and the premium of dedalo over alfa.

With real data, the funding history is downloaded from each exchange with ccxt (fetch_funding_rate_history) or with its native API; for example, the Bybit API v5 documentation describes the funding history endpoint. Fees by tier, funding caps, each contract's interval and the margin rules are read in each exchange's documentation on the day the profile is written, with the date: they change, and they are not copied from a tutorial. The funding interval is not assumed to be 8 hours: it is computed from the timestamps of the history. The authorization checks under MiCA (ESMA's interim register, the register of the CNMV, Spain's securities market regulator) are those of lesson 7.4.4.

Executable skeleton

The skeleton is the backbone of the funding arbitrage, end to end, in the course's main environment and without network access: data, signal, allocation with limits and alarm, margin, two-leg execution with ccxt, validation and report. The market making variant is built on the simulator from 2.3.6 (exercise 2).

Fingerprinted data and the bankruptcy timeline

IN [01]python
import hashlib
import json

import numpy as np
import pandas as pd
from scipy.stats import norm

def huella(ruta):
    """Abbreviated SHA-256 of a data file (goes into the report)."""
    with open(ruta, "rb") as f:
        return hashlib.sha256(f.read()).hexdigest()[:12]

FICHEROS = ["cripto3_funding.parquet", "cripto3_exchanges.csv",
            "cripto3_1h.parquet"]
HUELLAS = {f: huella(f) for f in FICHEROS}

fu = pd.read_parquet("cripto3_funding.parquet")
def tabla(col):
    return fu.pivot(index="ts", columns=["exchange", "activo"],
                    values=col)
F, I, P = (tabla("tasa_funding"), tabla("precio_indice"),
           tabla("ultimo"))
ev = pd.read_csv("cripto3_exchanges.csv").dropna(subset=["ts_utc"])
EV = dict(zip(ev["evento"], pd.to_datetime(ev["ts_utc"], utc=True)))
print(F.shape, "8-h periods x pairs;", F.index[0].date(), "to",
      F.index[-1].date())
print(ev[["exchange", "ts_utc", "evento"]].to_string(index=False))
OUT [01]Data, fingerprints and the dedalo timeline
(6575, 8) 8-h periods x pairs; 2020-01-01 to 2025-12-31
exchange           ts_utc                evento
  dedalo 2022-11-06 00:00               rumores
  dedalo 2022-11-08 12:00 retiradas_suspendidas
  dedalo 2022-11-11 14:00      cese_negociacion
  dedalo 2022-11-11 23:00               quiebra

6,575 eight-hour periods per pair, from 2020 to 2025, and the dedalo timeline (event keys in Spanish in the data: rumores rumors, retiradas_suspendidas withdrawals suspended, cese_negociacion trading halted, quiebra bankruptcy): rumors on November 6, 2022, withdrawals suspended on the 8th at 12:00, trading halted on the 11th at 14:00 and bankruptcy that night. In the funding data, dedalo has no rows after the last period before the halt.

Signal, allocation and simulator

The signal is the 7-day carry: the mean of the last 21 published funding rates, annualized. A pair enters when its carry exceeds one threshold and exits when it falls below another (hysteresis, so as not to pay fees on every oscillation). The capital is split equally among the active pairs, with a per-exchange limit, and each unit of notional requires 1+1/L1 + 1/L of capital: the spot and the margin of the short at L=3L = 3. The simulator decides in each period with what has been published up to that moment and collects in the next one the funding and the hedge: the difference between what the spot does (the index) and what the perpetual does, which is not zero even though the position is neutral. Each change in notional pays two sides of 5 bp.

IN [02]python
PER = 3 * 365                       # funding periods per year
carry7 = F.rolling(21).mean() * PER  # 7-day annual carry, at t
cobertura = (I.pct_change() - P.pct_change()).to_numpy()
pares = list(F.columns)
exch = np.array([e for e, _ in pares])
ts, Fv, Cv = F.index, F.to_numpy(), carry7.to_numpy()

def simular(entrada=0.11, salida=0.0, limite=0.30, alarma=True,
            modo="igual", coste=5e-4, L=3, vetos=()):
    """Long spot + short perpetual on each active pair. Decides at t
    with what is published up to t; collects funding and hedge at t + 1.
    `vetos`: (exchange, from, to) during which the capital is withdrawn."""
    vetos = list(vetos) + ([("dedalo", EV["rumores"], None)]
                           if alarma else [])
    activo, nom = np.zeros(len(pares), bool), np.zeros(len(pares))
    cap_u = 1 + 1 / L          # capital per unit of notional
    capital, congelado, perdida, giro = 1.0, 0.0, 0.0, 0.0
    atrapado, serie = False, []
    for t in range(len(ts) - 1):
        fuera = {e for e, d, h in vetos
                 if ts[t] >= d and (h is None or ts[t] < h)}
        if ts[t] >= EV["retiradas_suspendidas"] and not atrapado:
            atrapado = True                # whatever is left, trapped
            if "dedalo" not in fuera:
                congelado = nom[exch == "dedalo"].sum() * cap_u
        if atrapado:
            fuera.add("dedalo")
        if ts[t] >= EV["cese_negociacion"] and congelado:
            perdida = congelado / capital
            capital, congelado = capital - congelado, 0.0
        c = Cv[t]
        ok = ~np.isnan(c) & ~np.isin(exch, list(fuera))
        activo = np.where(activo, c > salida, c > entrada) & ok
        w = np.zeros(len(pares))
        if activo.any() and modo == "mejor":
            w[np.where(activo, c, -np.inf).argmax()] = 1.0
        elif activo.any():
            w[activo] = 1 / activo.sum()
        for e in set(exch[w > 0]):        # per-counterparty limit
            m = exch == e
            w[m] *= min(1.0, limite / w[m].sum())
        nuevo = (capital - congelado) * w / cap_u
        cambio = np.abs(nuevo - nom).sum()
        giro += cambio
        nom = nuevo
        cobro = (np.nan_to_num(Fv[t + 1])
                 + np.nan_to_num(cobertura[t + 1]))
        capital += (nom * cobro).sum() - 2 * coste * cambio
        serie.append(capital)
    return pd.Series(serie, index=ts[1:]), perdida, giro

def metricas(s):
    r = s.pct_change().dropna()
    anos = (s.index[-1] - s.index[0]).days / 365.25
    return {"final": s.iloc[-1],
            "annual %": 100 * (max(s.iloc[-1], 1e-9) ** (1 / anos) - 1),
            "Sharpe": r.mean() / r.std() * np.sqrt(PER),
            "max drawdown %": 100 * (s / s.cummax() - 1).min()}

base, perdida, giro = simular()
print({k: round(float(v), 2) for k, v in metricas(base).items()})
anos = 100 * base.resample("YE").last().pct_change().dropna()
print("by year %:", {d.year: round(v, 1) for d, v in anos.items()})
OUT [02]Signal and simulator: carry, costs, limits and counterparty
{'final': 2.29, 'annual %': 14.84, 'Sharpe': 14.22, 'max drawdown %': -0.75}
by year %: {2021: 27.2, 2022: 7.6, 2023: 7.9, 2024: 7.4, 2025: 6.2}

With entry at 11% (just above the formula's floor), exit when the week's carry turns negative, a 30% limit per exchange and the counterparty alarm, the capital grows from 1 to 2.29 in six years: 14.8% a year, with a maximum drawdown of 0.75%. By year, 27.2% in 2021 and between 6.2% and 7.9% since 2022: the premium narrows and the carry approaches the floor divided by the capital each unit of notional requires (10.95% / 1.33 ≈ 8.2%), minus costs. The Sharpe of 14.2 is not a typo: it is what a carry gives without the tail inside the sample.

Counterparty: concentration, limits and alarm

The alarm in the previous cell takes dedalo out of the allocation as soon as the insolvency rumors event appears in the incident log (in real life, the counterparty monitor: news, withdrawal delays, spot discount against the index, fund outflows). Without it, and with other ways of allocating:

IN [03]python
VARIANTES = {
    "all on the highest payer": dict(limite=1.0, alarma=False,
                                     modo="mejor"),
    "spread, no limit": dict(limite=1.0, alarma=False),
    "spread, 30% limit": dict(limite=0.30, alarma=False),
    "spread, 15% limit": dict(limite=0.15, alarma=False),
    "30% limit and alarm": dict(limite=0.30, alarma=True),
}
filas = {}
for nombre, kw in VARIANTES.items():
    s, perd, _ = simular(**kw)
    filas[nombre] = metricas(s) | {"loss on dedalo %": 100 * perd}
pd.DataFrame(filas).T.round(2)
OUT [03]Counterparty: concentration, limits and alarm
                          final  annual %  Sharpe  max drawdown %  loss on dedalo %
all on the highest payer   0.01    -50.59   -0.20          -99.18             99.12
spread, no limit           1.42      5.98    0.47          -40.15             39.91
spread, 30% limit          1.61      8.25    0.72          -30.04             29.95
spread, 15% limit          1.38      5.50    0.90          -15.04             14.99
30% limit and alarm        2.29     14.84   14.22           -0.75              0.00

The figure at the top draws the first column. With all the capital on the exchange that pays the most, which in November 2022 was dedalo, 99% of the capital at that moment is lost. Spread equally with no limit, 39.9%; with a 30% limit, 30%; with 15%, 15%. The limit turns a total loss into a bounded loss, but it does not prevent it: whatever was on dedalo when withdrawals were suspended stays there. Only the alarm, which acts 60 hours earlier, leaves the loss at zero.

Two warnings for the technical report. The alarm is written after knowing the FTX case: this has to be said before the panel, and the cost of false alarms has to be measured too (exercise 1). And a 15% limit halves the loss compared with the 30% one, but it also cuts the return, because with few active exchanges part of the capital sits unused.

weak signalcleared upstrong signalrumor or delaycapital outmanual reviewNORMALWATCHWITHDRAWVETOED
Mermaid source
stateDiagram-v2
  [*] --> NORMAL
  NORMAL --> WATCH: weak signal
  WATCH --> NORMAL: cleared up
  WATCH --> WITHDRAW: strong signal
  NORMAL --> WITHDRAW: rumor or delay
  WITHDRAW --> VETOED: capital out
  VETOED --> NORMAL: manual review

Margin of the short leg

The perpetual short is liquidated if the mark rises to its liquidation price, even if the spot has gained the same amount (lessons 3.7.1 and 4.2.6). With the margin topped up every day, the question is on how many days the rise of the mark from the start of the day would have reached that price:

IN [04]python
h1 = pd.read_parquet("cripto3_1h.parquet")
h1["dia"] = h1["ts"].dt.floor("D")
ref = h1.groupby(["activo", "dia"])["mark_cierre"].first()
techo = h1.groupby(["activo", "dia"])["mark_maximo"].max()
subida = techo / ref - 1               # worst mark rise in the day
MMR = 0.005                            # maintenance margin
margen = {}
for L in (2, 3, 5, 10):
    umbral = (1 + 1 / L) / (1 + MMR) - 1
    margen[f"{L}x"] = {"liquidating rise %": 100 * umbral,
                      "days XBQ": int((subida["XBQ"] >= umbral).sum()),
                      "days XEQ": int((subida["XEQ"] >= umbral).sum())}
peor = 100 * subida.groupby(level=0).max()
print(f"worst one-day mark rise: XBQ {peor['XBQ']:.1f}%, "
      f"XEQ {peor['XEQ']:.1f}%")
pd.DataFrame(margen).T.round(1)
OUT [04]Margin of the short leg: days that would liquidate
worst one-day mark rise: XBQ 15.9%, XEQ 18.1%
     liquidating rise %  days XBQ  days XEQ
2x                 49.3       0.0       0.0
3x                 32.7       0.0       0.0
5x                 19.4       0.0       0.0
10x                 9.5      16.0      37.0

The worst one-day rise in the mark was 15.9% on XBQ and 18.1% on XEQ. At 3x it takes 32.7%: no day. At 5x, 19.4%: none, by a narrow margin on XEQ. At 10x a 9.5% rise is enough, and that happened on 16 days on XBQ and 37 on XEQ. The skeleton uses 3x with daily top-ups; the technical report has to justify the real top-up frequency, which depends on how long it takes to move funds between accounts.

Two-leg execution with ccxt

The executor talks to the exchanges through the ccxt unified API. In the skeleton, each exchange is an in-memory class that inherits from ccxt.Exchange: unified markets (XBQ/USDQ and the linear perpetual XBQ/USDQ:USDQ), precision, orders, balance and positions with the ccxt structures. On cenit, the perpetual is down:

IN [05]python
import ccxt
from ccxt.base.errors import ExchangeNotAvailable

class Simulado(ccxt.Exchange):
    """In-memory exchange with the ccxt unified API: markets,
    orders, balance and positions. No network (the HTTP version with
    signing and rate limits is in lesson 3.7.4)."""
    def describe(self):
        return self.deep_extend(super().describe(), {
            "id": "simulado", "has": {"createOrder": True,
                                      "fetchPositions": True}})

    def __init__(self, nombre, precio, caidos=()):
        super().__init__({})
        self.id, self.px, self.caidos = nombre, precio, set(caidos)
        self.reloj, self.n = 1_704_067_200_000, 0   # 2024-01-01 UTC
        self.saldo, self.pos = {"USDQ": 100_000.0, "XBQ": 0.0}, 0.0
        comun = {"base": "XBQ", "quote": "USDQ", "baseId": "XBQ",
                 "quoteId": "USDQ", "active": True, "taker": 5e-4,
                 "maker": 2e-4, "limits": {"cost": {"min": 10}},
                 "precision": {"amount": 0.001, "price": 0.1}}
        mercados = (
            {"id": "XBQUSDQ", "symbol": "XBQ/USDQ", "type": "spot",
             "spot": True},
            {"id": "XBQ-PERP", "symbol": "XBQ/USDQ:USDQ",
             "type": "swap", "swap": True, "linear": True,
             "contract": True, "settle": "USDQ", "contractSize": 1})
        self.set_markets([self.safe_market_structure(comun | m)
                          for m in mercados])

    def create_order(self, symbol, type, side, amount, price=None,
                     params={}):
        if symbol in self.caidos:
            raise ExchangeNotAvailable(f"{self.id} 503 {symbol}")
        m, s = self.market(symbol), 1 if side == "buy" else -1
        q = float(self.amount_to_precision(symbol, amount))
        px = round(self.px * (1 + s * 2e-4), 1)    # half spread
        fee = round(q * px * m["taker"], 4)
        if m["spot"]:
            self.saldo["XBQ"] += s * q
            self.saldo["USDQ"] -= s * q * px + fee
        else:
            self.pos += s * q
            self.saldo["USDQ"] -= fee
        self.reloj, self.n = self.reloj + 250, self.n + 1
        return self.safe_order({
            "id": f"{self.id}-{self.n}", "timestamp": self.reloj,
            "clientOrderId": params.get("clientOrderId"),
            "symbol": symbol, "type": type, "side": side, "amount": q,
            "filled": q, "average": px, "status": "closed",
            "fee": {"cost": fee, "currency": "USDQ"}}, m)

    def fetch_positions(self, symbols=None, params={}):
        return [self.safe_position({
            "symbol": "XBQ/USDQ:USDQ", "contracts": abs(self.pos),
            "side": "short" if self.pos < 0 else "long", "info": {}})]

def abrir_par(ex, q, cid):
    """Buys spot and sells the perpetual; if the second leg fails,
    unwinds the first. Returns the executed orders."""
    hechas = [ex.create_order("XBQ/USDQ", "market", "buy", q,
                              params={"clientOrderId": f"{cid}-c"})]
    try:
        hechas.append(ex.create_order(
            "XBQ/USDQ:USDQ", "market", "sell", q,
            params={"clientOrderId": f"{cid}-p"}))
    except ExchangeNotAvailable as e:
        print(f"{ex.id}: {e}; unwinding the spot leg")
        hechas.append(ex.create_order(
            "XBQ/USDQ", "market", "sell", q,
            params={"clientOrderId": f"{cid}-x"}))
    return hechas

def conciliar(ex, ordenes):
    """Positions according to our own orders versus the exchange."""
    libro = {"XBQ/USDQ": 0.0, "XBQ/USDQ:USDQ": 0.0}
    for o in ordenes:
        libro[o["symbol"]] += o["filled"] * (1 if o["side"] == "buy"
                                             else -1)
    p = ex.fetch_positions()[0]
    real = {"XBQ/USDQ": ex.saldo["XBQ"], "XBQ/USDQ:USDQ":
            p["contracts"] * (-1 if p["side"] == "short" else 1)}
    coste = 100_000 - ex.saldo["USDQ"] - ex.saldo["XBQ"] * ex.px
    return libro == real, real, round(coste, 2)

for ex in (Simulado("alfa", 42_000.0),
           Simulado("cenit", 42_010.0, caidos=["XBQ/USDQ:USDQ"])):
    ordenes = abrir_par(ex, 0.5, f"c5-{ex.id}-0001")
    print(ex.id, [(o["clientOrderId"], o["side"], o["average"])
                  for o in ordenes])
    print("  matches, positions, cost:", conciliar(ex, ordenes))
OUT [05]Two legs against simulated exchanges with ccxt
alfa [('c5-alfa-0001-c', 'buy', 42008.4), ('c5-alfa-0001-p', 'sell', 41991.6)]
  matches, positions, cost: (True, {'XBQ/USDQ': 0.5, 'XBQ/USDQ:USDQ': -0.5}, 25.2)
cenit: cenit 503 XBQ/USDQ:USDQ; unwinding the spot leg
cenit [('c5-cenit-0001-c', 'buy', 42018.4), ('c5-cenit-0001-x', 'sell', 42001.6)]
  matches, positions, cost: (True, {'XBQ/USDQ': 0.0, 'XBQ/USDQ:USDQ': 0.0}, 29.4)

On alfa both legs go through and the reconciliation matches: 0.5 XBQ of spot and −0.5 of perpetual, with a cost of 25.2 USDQ between fees and spread. On cenit, the spot goes through, the perpetual returns a 503 and the executor unwinds the spot with its own identifier (-x): the position ends at zero and the attempt costs 29.4 USDQ. Our own identifiers (clientOrderId) are what make it possible to know, after a timeout, whether an order exists without duplicating it (lesson 3.7.4).

Against a real exchange, the same code uses the exchange's ccxt class and its demo account. It is marked skip: it needs network access and keys, which are read from the environment and never have withdrawal permission (lesson 4.4.5).

IN [06]ejecutor_demo.pypython
import os

import ccxt

ex = ccxt.bybit({"apiKey": os.environ["BYBIT_KEY"],
                 "secret": os.environ["BYBIT_SECRET"]})
ex.enable_demo_trading(True)        # demo account
ex.load_markets()
hist = ex.fetch_funding_rate_history("BTC/USDT:USDT", limit=21)
horas = (hist[-1]["timestamp"] - hist[0]["timestamp"]) / 3.6e6 / 20
carry7 = (sum(h["fundingRate"] for h in hist) / len(hist)
          * 24 / horas * 365)
if carry7 > 0.11:
    q = float(ex.amount_to_precision("BTC/USDT:USDT", 0.01))
    ex.create_order("BTC/USDT", "market", "buy", q,
                    params={"clientOrderId": "c5-0001-c"})
    ex.create_order("BTC/USDT:USDT", "market", "sell", q,
                    params={"clientOrderId": "c5-0001-p"})
print(ex.fetch_positions(["BTC/USDT:USDT"]))

enable_demo_trading exists in ccxt version 4.5.89 for Bybit and for Binance. Before using it, read in each exchange's documentation what its demo environment covers (markets, fictitious balances, limits): it is not identical to the real one.

Trial log, validation and report

The eight threshold combinations (entry at 8, 11, 15 and 20%; exit at 0 and 5%) are stored in experimentos/registro_pruebas.jsonl, append-only and with the fields of guide 7.6.1, and the Deflated Sharpe's number of trials is counted from that file. The walk-forward chooses each year with the previous ones and does not trade if the chosen configuration does not win in training.

IN [07]python
REJILLA = [(e, s) for e in (0.08, 0.11, 0.15, 0.20)
           for s in (0.0, 0.05)]
import os

REG = "experimentos/registro_pruebas.jsonl"    # append-only (7.6.1)
os.makedirs("experimentos", exist_ok=True)

def sharpe(x):
    return float(x.mean() / x.std() * np.sqrt(PER))

series = {}
for e, s in REJILLA:
    x = simular(e, s)[0].pct_change().dropna()
    series[(e, s)] = x
    cfg = {"señal": "carry7", "entrada": e, "salida": s,
           "limite": 0.30, "apalancamiento": 3, "coste_pb": 5}
    with open(REG, "a", encoding="utf-8") as f:
        f.write(json.dumps({
            "id": hashlib.sha256(json.dumps(cfg, sort_keys=True)
                                 .encode()).hexdigest()[:10],
            "hipotesis": "the 7-day carry persists",
            "config": cfg, "datos": HUELLAS["cripto3_funding.parquet"],
            "resultado": {"sharpe": round(sharpe(x), 4), "n": len(x)}},
            ensure_ascii=False) + "\n")
with open(REG, encoding="utf-8") as f:
    N = sum(1 for _ in f)

oos = []
for ano in range(2021, 2026):
    ent = {k: sharpe(x[x.index.year < ano]) for k, x in series.items()}
    elegida = max(ent, key=ent.get)
    x = series[elegida][series[elegida].index.year == ano]
    oos.append(x if ent[elegida] > 0 else 0 * x)
    print(ano, "chosen", elegida, f"| out of sample: {100 * x.sum():.1f}%"
          f" (Sharpe {sharpe(x):.1f})")
oos = pd.concat(oos)

def max_esperado(n):
    g = np.euler_gamma
    return ((1 - g) * norm.ppf(1 - 1 / n)
            + g * norm.ppf(1 - 1 / (n * np.e)))

def psr(x, umbral):
    sr = x.mean() / x.std()
    a, k = x.skew(), x.kurt() + 3
    return float(norm.cdf((sr - umbral) * np.sqrt(len(x) - 1) / np.sqrt(
        1 - a * sr + (k - 1) / 4 * sr ** 2)))

sr = pd.Series({k: x.mean() / x.std() for k, x in series.items()})
mejor = max(series, key=lambda k: sharpe(series[k]))
sr0 = sr.std() * max_esperado(N)
print(f"N = {N}; best {mejor}: Sharpe {sharpe(series[mejor]):.1f},"
      f" DSR {psr(series[mejor], sr0):.3f}; walk-forward: Sharpe "
      f"{sharpe(oos):.1f}")
anual = 100 * oos[oos.index.year >= 2023].sum() / 3
print(f"net carry 2023-2025: {anual:.1f}% a year; a bankruptcy with "
      f"30% of the capital inside wipes out {30 / anual:.1f} years")
OUT [07]Trial log, walk-forward and Deflated Sharpe
2021 chosen (0.08, 0.0) | out of sample: 23.9% (Sharpe 21.2)
2022 chosen (0.08, 0.0) | out of sample: 7.4% (Sharpe 11.2)
2023 chosen (0.08, 0.0) | out of sample: 7.6% (Sharpe 13.7)
2024 chosen (0.08, 0.0) | out of sample: 7.2% (Sharpe 14.9)
2025 chosen (0.08, 0.0) | out of sample: 6.0% (Sharpe 12.8)
N = 8; best (0.08, 0.0): Sharpe 14.3, DSR 1.000; walk-forward: Sharpe 14.3
net carry 2023-2025: 6.9% a year; a bankruptcy with 30% of the capital inside wipes out 4.3 years
IN [08]python
informe = {
    "proyecto": "C5", "datos": HUELLAS,
    "estrategia": {"entrada": mejor[0], "salida": mejor[1],
                   "limite_por_exchange": 0.30, "apalancamiento": 3,
                   "coste_pb": 5, "n_pruebas": N},
    "validacion": {"sharpe_walk_forward": round(sharpe(oos), 1),
                   "dsr": round(psr(series[mejor], sr0), 3),
                   "carry_neto_2023_2025_pct": round(anual, 1)},
    "riesgo": {"perdida_dedalo_sin_alarma_pct": round(
                   100 * simular(alarma=False)[1], 1),
               "anos_de_carry_por_quiebra_al_limite": round(
                   30 / anual, 1),
               "dias_liquidacion_3x": margen["3x"]["days XBQ"]
                                      + margen["3x"]["days XEQ"]},
}
informe["decision"] = (
    "paper trading" if informe["validacion"]["dsr"] >= 0.95
    and informe["validacion"]["sharpe_walk_forward"] > 0
    else "stop or redesign")
with open("informe_c5.json", "w", encoding="utf-8") as f:
    json.dump(informe, f, ensure_ascii=False, indent=1)
for parte in ("estrategia", "validacion", "riesgo", "decision"):
    print(parte, json.dumps(informe[parte], ensure_ascii=False))
OUT [08]The run report
estrategia {"entrada": 0.08, "salida": 0.0, "limite_por_exchange": 0.3, "apalancamiento": 3, "coste_pb": 5, "n_pruebas": 8}
validacion {"sharpe_walk_forward": 14.3, "dsr": 1.0, "carry_neto_2023_2025_pct": 6.9}
riesgo {"perdida_dedalo_sin_alarma_pct": 30.0, "anos_de_carry_por_quiebra_al_limite": 4.3, "dias_liquidacion_3x": 0}
decision "paper trading"

The walk-forward chooses the 8% entry with no exit threshold every year, and the out-of-sample Sharpe is 14.3; the Deflated Sharpe with 8 trials is 1.000. The automatic rule says “paper trading”. The technical report also has to state the other line of the report, riesgo (risk; the report's keys are in Spanish): with 30% of the capital on one exchange, a bankruptcy wipes out 4.3 years of net carry at the 2023-2025 pace (6.9% a year). That is the number that decides the size of the limits, and it does not come out of any statistical test.

Validation

The validation of the carry has two parts that cannot substitute for each other.

The statistical part, as in any project of the course: annual walk-forward validation with an expanding window (lesson 2.5.2), trial log and Deflated Sharpe (2.5.4), or CPCV (2.5.3) if there are more parameters. Here it serves its purpose: checking that the thresholds are not fitted to the sample and that the carry survives costs in every year. In the market making variant, the parameters (γ\gamma, kk, inventory limit) are chosen on some sessions and measured on others, and the metric includes adverse selection measured separately (2.3.6).

The scenario part, which is the one that decides. A carry has a return distribution with many small positive days and a rare jump: its sample Sharpe is huge as long as the jump is not in the sample. What is validated is the loss in each scenario and its relation to the carry:

ScenarioHow it is measuredWhat is recorded
Bankruptcy of an exchangeCapital on it when withdrawals closeLoss and years of carry it wipes out
Liquidation of the short legRise of the mark between margin top-upsDays that would liquidate at each leverage
Prolonged negative fundingConsecutive weeks paying (2022 in the data)Loss and turnover of the hysteresis
Orphaned legFailure of the second orderCost of unwinding and frequency in paper trading
Spot dislocationDiscount of one exchange against the index (3.7.2)Hedge loss if its spot is used

The stopping rules, dated in the technical report before week 4: out-of-sample net carry not positive in the last year; DSR below 0.95 with the number of trials recorded; a bankruptcy at the limit that wipes out more than five years of net carry, unless the limit is lowered; any day of liquidation at the chosen leverage in the available history. The thresholds are the course's criteria, not a regulation.

Risks and paper trading

What really brings down a crypto arbitrage desk:

  • Counterparty. Bankruptcy, frozen withdrawals, commingling of client funds. The FTX case, according to the CFTC and SEC complaints, is summarized in lesson 3.7.2. Defenses: limits, withdrawing profits, exchanges authorized under MiCA (7.4.4), periodic withdrawal tests and the alarm.
  • Liquidation of the short leg due to a fast rise with the margin not topped up, or to a wick in the last price on an exchange that liquidates on it instead of on the mark (3.7.1).
  • Orphaned and duplicated legs. An order that goes through on one exchange and not on the other, or a retry that duplicates the order after a timeout (3.7.4).
  • Funding that changes sign and leaves the desk paying while the hysteresis decides.
  • Keys. A leaked key with withdrawal permission empties the account; the executor's keys only trade, and withdrawal addresses are on a whitelist configured by hand (4.4.5).
  • Market making: adverse selection, inventory that builds up in a sell-off and quotes that stay live when the process crashes (cancel-on-disconnect, if the exchange offers it; 4.5.2).

The limits and the kill switch: maximum capital per exchange, maximum leverage, minimum distance to the liquidation price, maximum number of orders per minute, maximum daily loss (4.2.5), and the state of each counterparty in the diagram above. An exchange in WITHDRAW receives no orders that increase exposure.

The plan for the four weeks of paper trading, on the demo accounts of at least two exchanges:

  1. Every funding period: download of the published funding, signal, allocation, orders for both legs and reconciliation of orders, balances and positions (4.5.5). An unexplained difference blocks the next period.
  2. Every day: margin and distance to liquidation per position, state of each counterparty, hedge (spot versus perpetual) and slippage against the 5 bp assumption (4.5.3).
  3. Every week: carry collected versus simulated for the same dates, executor incidents (orphaned legs, rejections, rate limits) and a deliberate failure test (cutting the connection halfway through an opening).
  4. At the end: report with the differences between the simulator and what was executed. Four weeks say nothing about the tail; they say whether the executor, the reconciliation and the monitor work.

Milestones and rubric

The schedule is that of guide 7.6.1: 40 to 80 hours over about 10 weeks, with the four weeks of paper trading in parallel with the technical report.

WeekVerifiable deliverable
1Template repository; funding loader with fingerprints; counterparty register with the 7.4.4 check
2Signal, simulator with costs, margin and hedge; known answer of level 3 reproduced
3Allocation with limits and alarm; bankruptcy scenario; stopping rules written and dated
4Walk-forward, DSR and scenarios; decision to move to paper trading
5ccxt executor against the simulator: two legs, unwind, idempotency, reconciliation, with tests
6Demo accounts, keys without withdrawal, counterparty monitor and alerts
7-10Paper trading, reconciliation per period and monitor; technical report; defense rehearsal

The rubric is the course's common one (the course's own criteria, not an external standard), with the evidence it asks for in C5:

CriterionWeightEvidence in C5
Reproducibility15%uv.lock, Docker, one command; data fingerprints; deterministic simulator with its own clock
Data15%Funding with its publication time, defunct exchanges included (no survivorship bias), dated profiles
Anti-overfitting validation20%Walk-forward, DSR with the log, bankruptcy and liquidation scenarios with their loss
Risk and execution15%Costs, margin, per-counterparty limits, alarm, two-leg executor with unwind and reconciliation
Paper trading and monitoring15%Four weeks on demo, reconciliation per period, counterparty state
Code quality10%Tests of the executor with injected failures, of the simulator and of the allocation; lint; configuration outside the code
Technical report and defense10%7.6.1 template; limits justified with the bankruptcy scenario

Mistakes that fail the project on their own, in their C5 form: demonstrable look-ahead (using a period's funding to decide the position that collects it); results that do not reproduce with the documented command; a backtest without costs or without margin; API keys in the repository or in its history, all the more so with withdrawal permission; hiding the number of trials or choosing the period after the fact (for example, starting in 2023 so as not to see the bankruptcy, or removing dedalo from the universe, which is survivorship bias); fewer than four weeks of paper trading.

Exercises

  1. The counterparty alarm fires on a false rumor about alfa on March 4, 2024 and the capital is withdrawn for a week. How much does that false alarm cost compared with the skeleton?
Solution
IN [09]python
falsa = [("alfa", pd.Timestamp("2024-03-04", tz="UTC"),
          pd.Timestamp("2024-03-11", tz="UTC"))]
s_f, _, _ = simular(vetos=falsa)
coste = 1 - s_f.iloc[-1] / base.iloc[-1]
b24 = base[base.index.year == 2024]
diario = (b24.iloc[-1] / b24.iloc[0] - 1) / 365
print(f"final capital: {base.iloc[-1]:.4f} without the false alarm, "
      f"{s_f.iloc[-1]:.4f} with it")
print(f"cost: {100 * coste:.3f}% of the capital, about "
      f"{coste / diario:.1f} days of 2024 carry")
OUT [09]What a false alarm costs
final capital: 2.2940 without the false alarm, 2.2920 with it
cost: 0.087% of the capital, about 4.3 days of 2024 carry

The false alarm costs 0.087% of the capital, about 4.3 days of 2024 carry: the alfa legs are closed and reopened (four sides with costs) and for a week part of the capital sits unused, because the 30% limit prevents moving what leaves alfa to the other exchanges. Compared with the 30% lost in a bankruptcy at the limit, an alarm that fires by mistake several times a year is still cheap. What has to be watched is that it does not fire so often that the system spends more time out than in, and each trigger has to be logged with its reason to calibrate it.

  1. If you choose market making: add to the Avellaneda-Stoikov simulator from lesson 2.3.6 a hard inventory limit (do not quote the side that would exceed it) and a maker fee. How much of the result is left?
Solution
IN [10]python
flujo = pd.read_parquet("mm2_flujo.parquet")
medio = pd.read_parquet("mm2_medio.parquet")
inicio = pd.Timestamp("2025-03-12 13:30:00", tz="UTC")
T = len(medio)
m = np.r_[medio["medio"].iloc[0], medio["medio"].to_numpy()]
seg = np.floor((flujo["ts"] - inicio).dt.total_seconds()).astype(int)
seg = seg.clip(0, T - 1).to_numpy()
signo = np.where(flujo["lado"] == "compra", 1, -1)
alcance = flujo["alcance"].to_numpy()
por_seg = [[] for _ in range(T)]
for i, s in enumerate(seg):
    por_seg[s].append(i)

def creador(q_max, comision, gamma=1e-4, k=35.4, sigma=0.0065):
    """Avellaneda-Stoikov (2.3.6) with a hard inventory limit and a
    maker fee on the cash amount of each fill."""
    q, caja, n = 0, 0.0, 0
    for s in range(T):
        resto = T - s
        r = m[s] - q * gamma * sigma ** 2 * resto
        semi = 0.5 * (gamma * sigma ** 2 * resto
                      + 2 / gamma * np.log(1 + gamma / k))
        bid = np.floor((r - semi) / 0.01 + 1e-9) * 0.01
        ask = np.ceil((r + semi) / 0.01 - 1e-9) * 0.01
        for i in por_seg[s]:
            if (signo[i] == 1 and q - 100 >= -q_max
                    and alcance[i] >= ask - m[s] - 1e-9):
                q, caja = q - 100, caja + 100 * ask * (1 - comision)
                n += 1
            elif (signo[i] == -1 and q + 100 <= q_max
                    and alcance[i] >= m[s] - bid - 1e-9):
                q, caja = q + 100, caja - 100 * bid * (1 + comision)
                n += 1
    return {"result": caja + q * m[T], "fills": n}

pd.DataFrame({
    "no limit, no fee": creador(10 ** 9, 0.0),
    "limit 1000, no fee": creador(1000, 0.0),
    "limit 1000, maker 1 bp": creador(1000, 1e-4),
    "limit 1000, maker 2 bp": creador(1000, 2e-4),
}).T.round(1)
OUT [10]Market making with inventory limit and fee
                         result    fills
no limit, no fee        38061.0  13169.0
limit 1000, no fee      38135.0  13163.0
limit 1000, maker 1 bp  31748.4  13163.0
limit 1000, maker 2 bp  25361.7  13163.0

With Avellaneda-Stoikov the inventory already stays small (in 2.3.6 it never went above 1,700 shares), so the limit of 1,000 barely changes the result: 38,135 USD versus 38,061. The fee does: each basis point of maker fee on the traded cash amount (about 64 million USD in 13,163 fills of 100 shares) takes about 6,400 USD, 17% of the result. Each exchange's fee structure (maker, taker, volume discounts) decides whether market making makes sense, and it is read on its official page with the date. The hard limit is still mandatory: it protects against what the model does not foresee, such as a price jump or an informed flow that does not stop.

  1. Before depositing capital on a new exchange, what goes into its profile in the counterparty register and which signals feed its state in the monitor?
Solution

In the profile: legal entity and LEI, country, authorization as a crypto-asset service provider under MiCA and its check in the ESMA and CNMV registers with the date (lessons 3.7.5 and 7.4.4), segregation of client assets according to the terms of the contract, services that will be used, capital limit and its justification with the bankruptcy scenario, permissions of each key (no withdrawal), address whitelist, result of the last withdrawal test and date of the next review. In the monitor: delays or failures in test withdrawals, spot discount against the index of the other exchanges (3.7.2), funding very different from the rest without a market cause, API outages beyond the usual, news about solvency or supervisory actions, and changes in the ESMA register or in the list of unauthorized entities (7.4.4). Weak signals move to WATCH; a strong one, or several weak ones at once, to WITHDRAW.

Each answer is folded under its question

Self-assessment

  1. The best configuration of the skeleton has a Sharpe of 14.3 and a Deflated Sharpe of 1.000 with 8 trials. What do you conclude?

    • AThat the strategy is validated and can be traded with high leverage
    • BThat statistical validation does not see the risk that matters: the sample without the bankruptcy has no jumps, and counterparty risk is validated with scenarios and limits
    • CThat there is look-ahead in the code
    • DThat more configurations must be tested so the DSR goes down
    Show answer

    Answer B. That statistical validation does not see the risk that matters: the sample without the bankruptcy has no jumps, and counterparty risk is validated with scenarios and limits

    A carry collects little and almost always; its loss arrives all at once and rarely (a bankruptcy, a liquidation). The sample Sharpe measures the calm part. With the dedalo bankruptcy included and no alarm, the same system loses 30% of its capital in one day: that is what has to be defended before the panel.

  2. On cenit, the short perpetual leg returns a 503 error after the spot has been bought. What does the skeleton's executor do?

    • AIt retries the perpetual sale until it goes through
    • BIt keeps the spot bought and waits for the next period
    • CIt opens the short on another exchange
    • DIt unwinds the spot leg, logs the incident and reconciles: net position zero at the cost of the round trip
    Show answer

    Answer D. It unwinds the spot leg, logs the incident and reconciles: net position zero at the cost of the round trip

    A single leg is a directional bet the strategy does not want to hold. Retrying blindly after an error can duplicate orders (lesson 3.7.4); hedging on another exchange changes the counterparty without a prior decision. Unwinding costs 29.4 USDQ in the example and leaves the book square.

  3. With the capital spread out and a limit of 30% per exchange, but no alarm, what happens in November 2022?

    • AThe part that was on dedalo when withdrawals were suspended is lost: 30% of the capital, the whole limit
    • BNothing: the limit prevents any loss
    • CAll the capital is lost
    • DOnly one week of funding is lost
    Show answer

    Answer A. The part that was on dedalo when withdrawals were suspended is lost: 30% of the capital, the whole limit

    The limit bounds the loss, it does not prevent it: the capital that was on dedalo on November 8 at 12:00 was trapped. What prevents it in the skeleton is the counterparty alarm, which withdraws the capital on the rumors of the 6th, before the suspension.

  4. With daily margin top-ups, how many days in the period would have liquidated the short leg at 3x and at 10x?

    • ANone in either case
    • BMany at 3x and none at 10x
    • CNone at 3x (it would take a 32.7% rise in the mark in one day) and 53 between XBQ and XEQ at 10x
    • DOne at 3x and one at 10x
    Show answer

    Answer C. None at 3x (it would take a 32.7% rise in the mark in one day) and 53 between XBQ and XEQ at 10x

    The worst one-day rise in the mark was 15.9% on XBQ and 18.1% on XEQ, far from the 32.7% that liquidates at 3x. At 10x a 9.5% rise is enough, and that happened on 16 days on XBQ and 37 on XEQ. The last points of return from leverage are paid for with liquidations.

  5. Which of these facts fails the project on its own, according to the common rubric of 7.6.1?

    • AThat the net carry from 2023 to 2025 is only 6.9% a year
    • BAn API key with withdrawal permission stored in the repository's git history
    • CChoosing market making instead of arbitrage
    • DThat paper trading shows higher slippage than assumed
    Show answer

    Answer B. An API key with withdrawal permission stored in the repository's git history

    Credentials in the repository or in its history fail the project on their own, and a key with withdrawal permission is the worst of all (lesson 4.4.5). A modest carry or higher slippage than expected are results that are documented and defended.

Key points

  • C5 is a crypto arbitrage or market making desk whose main risk is not the price but the counterparties: the exchanges hold the capital.
  • The skeleton collects funding with long spot and a 3x short perpetual on the exchanges that pay more than the formula's floor, with 5 bp per side and daily margin top-ups: 14.8% a year, falling to 6-8% from 2022.
  • Facing the dedalo bankruptcy, concentrating on the highest payer loses everything; spreading with a 30% limit loses 30%; only the alarm that withdraws the capital on the rumors avoids the loss.
  • The executor sends both legs with its own identifiers, unwinds an orphaned leg and reconciles against what the exchange reports before the next period.
  • A Sharpe of 14 and a DSR of 1.000 do not validate a carry: they measure the calm part. With 30% of the capital on one exchange, a bankruptcy wipes out 4.3 years of net carry.
  • Paper trading runs on the exchanges' demo accounts, with keys without withdrawal permission and monitoring of each counterparty.

Resources