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.
- Module
- 7.6 · Final capstone
- Duration
- 65 min
- Prerequisites
- 7.6.1 · Capstone: common requirements, the panel's rubric and preparing the defense, 2.3.6 · Introduction to market making: inventory, spread and adverse selection (Avellaneda-Stoikov), 2.5.4 · Deflated Sharpe Ratio and Probability of Backtest Overfitting (PBO), 3.7.1 · Perpetuals: funding mechanics, mark price and liquidations, 3.7.2 · Funding arbitrage, basis with futures and cross-exchange arbitrage, 3.7.4 · ccxt for trading on multiple exchanges, 4.2.4 · Stress testing and correlation, liquidity, gap and counterparty risk, 4.4.4 · Crypto exchange APIs: ccxt, Binance, Bybit, Kraken and Coinbase, 4.5.5 · Fault tolerance: idempotency, persistent state and reconciliation at startup, 7.4.4 · MiCA after the transitional period: checking a provider in the registers
- Author
- Jesús Cuesta
- Updated
Level 7 lessons
7.1 · Software engineering for trading
7.2 · Infrastructure and deployment
7.3 · MLOps for trading models
7.4 · Regulation, ethics and taxation
- 7.4.1 MiFID II: clients, suitability and algorithmic trading
- 7.4.2 ESMA and CFDs: leverage, close-out and negative balance
- 7.4.3 PRIIPs, the KID and US ETFs
- 7.4.4 MiCA after the transitional period: checking a provider in the registers
- 7.4.5 Market abuse: spoofing, layering and the bot that imitates them by accident
- 7.4.6 Trading taxes in Spain
- 7.4.7 Ethics: personal data, artificial intelligence and selling signals
7.5 · Quant career
7.6 · Final capstone
- 7.6.1 Capstone: common requirements, the panel's rubric and preparing the defense
- 7.6.2 Capstone C1: a multi-asset futures system, from forecast to paper account
- 7.6.3 Capstone C2: systematic options book, volatility surface and real-time Greeks
- 7.6.4 Capstone C3: stock and ETF portfolio with ML, HRP and Spanish taxation
- 7.6.5 Capstone C4: a research platform with LLMs and alternative data
- 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).
| Deliverable | What it contains | How it is checked |
|---|---|---|
| Repository | 7.6.1 template, uv and Docker, one command per stage | make todo reproduces the report on a clean machine |
| Counterparty register | Exchanges, authorization, limits, segregation, withdrawal tests | Every exchange holding capital has its profile and its limit |
| Trial log | Every configuration tested, with its series | The DSR's number of trials comes from this file |
| Validation report | Walk-forward, DSR, bankruptcy and liquidation scenarios | Stopping rules are dated before the results |
| Executor | Two legs with own identifiers, unwind, reconcile | Tests with injected failures in the simulator |
| Paper trading journal | Four weeks on demo accounts | Every funding period has its row and its reconciliation |
| Technical report and defense | 7.6.1 template, 20-minute presentation | Before the panel |
Architecture
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.
| Piece | Library | Where it was learned |
|---|---|---|
| Funding, mark price and liquidation | pandas | 1.2.6, 3.7.1 |
| Cash-and-carry, basis, differentials and counterparty | pandas | 2.3.7, 3.7.2 |
| Market making and inventory | numpy | 2.3.6 |
| Unified API, errors, rate limits and idempotency | ccxt, ccxt.pro | 3.7.4 |
| Signatures, nonces, clocks and user streams | ccxt, native APIs | 4.4.4 |
| Credentials and least privilege | environment variables, secrets manager | 4.4.5 |
| Counterparty risk and stress | pandas | 4.2.4 |
| Margin and liquidations in derivatives | pandas | 4.2.6 |
| Orders, reconnections and reconciliation | state machine, ccxt | 4.5.2, 4.5.5 |
| Multiple testing and DSR | trial log | 2.2.4, 2.5.4 |
| Monitoring and shutdown | metrics, expiring permission | 7.2.3, 7.3.3 |
| Providers authorized under MiCA | ESMA and CNMV registers | 3.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:
| File | What it is | What it is for here |
|---|---|---|
cripto3_funding.parquet | 8-hour funding, index, mark and last price of the perpetual; 4 exchanges, 2 assets, 2020-2025 | Signal, collection and hedge |
cripto3_exchanges.csv | Exchange profiles and the timeline of the dedalo bankruptcy | Counterparty scenario and alarm |
cripto3_1h.parquet | 1-hour bars of the alfa perpetual with the mark | Margin and liquidations |
cripto3_futuros.parquet, cripto3_diario.parquet | Quarterlies and daily closes by exchange | Basis and dislocations (3.7.2) |
xsn_funding_8h.csv, xsn_ccxt_ohlcv.json | Funding of another token and the ccxt format | Tests of the data loader |
mm2_medio.parquet, mm2_flujo.parquet | One session second by second with informed flow | Market 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
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))(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 quiebra6,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 of capital: the spot and the margin of the short at . 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.
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()}){'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:
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) 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.00The 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.
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:
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)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.0The 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:
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))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).
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.
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")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 yearsinforme = {
"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))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 (, , 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:
| Scenario | How it is measured | What is recorded |
|---|---|---|
| Bankruptcy of an exchange | Capital on it when withdrawals close | Loss and years of carry it wipes out |
| Liquidation of the short leg | Rise of the mark between margin top-ups | Days that would liquidate at each leverage |
| Prolonged negative funding | Consecutive weeks paying (2022 in the data) | Loss and turnover of the hysteresis |
| Orphaned leg | Failure of the second order | Cost of unwinding and frequency in paper trading |
| Spot dislocation | Discount 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:
- 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.
- 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).
- 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).
- 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.
| Week | Verifiable deliverable |
|---|---|
| 1 | Template repository; funding loader with fingerprints; counterparty register with the 7.4.4 check |
| 2 | Signal, simulator with costs, margin and hedge; known answer of level 3 reproduced |
| 3 | Allocation with limits and alarm; bankruptcy scenario; stopping rules written and dated |
| 4 | Walk-forward, DSR and scenarios; decision to move to paper trading |
| 5 | ccxt executor against the simulator: two legs, unwind, idempotency, reconciliation, with tests |
| 6 | Demo accounts, keys without withdrawal, counterparty monitor and alerts |
| 7-10 | Paper 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:
| Criterion | Weight | Evidence in C5 |
|---|---|---|
| Reproducibility | 15% | uv.lock, Docker, one command; data fingerprints; deterministic simulator with its own clock |
| Data | 15% | Funding with its publication time, defunct exchanges included (no survivorship bias), dated profiles |
| Anti-overfitting validation | 20% | Walk-forward, DSR with the log, bankruptcy and liquidation scenarios with their loss |
| Risk and execution | 15% | Costs, margin, per-counterparty limits, alarm, two-leg executor with unwind and reconciliation |
| Paper trading and monitoring | 15% | Four weeks on demo, reconciliation per period, counterparty state |
| Code quality | 10% | Tests of the executor with injected failures, of the simulator and of the allocation; lint; configuration outside the code |
| Technical report and defense | 10% | 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
- The counterparty alarm fires on a false rumor about
alfaon March 4, 2024 and the capital is withdrawn for a week. How much does that false alarm cost compared with the skeleton?
Solution
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")final capital: 2.2940 without the false alarm, 2.2920 with it
cost: 0.087% of the capital, about 4.3 days of 2024 carryThe 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.
- 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
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) 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.0With 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.
- 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
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.
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.
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.
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.
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.