Files
estudio_mercados/notebooks/.ipynb_checkpoints/01_matching_engine_fifo-checkpoint.ipynb
T

599 lines
22 KiB
Plaintext

{
"cells": [
{
"cell_type": "markdown",
"metadata": {},
"source": [
"# Matching Engine FIFO\n",
"\n",
"Motor de matching de órdenes con prioridad precio-tiempo (FIFO).\n",
"\n",
"**Objetivo:** Implementar un order book con matching FIFO que podamos usar después para simular mercados con datos reales de exchanges.\n",
"\n",
"**Estructura:**\n",
"1. Tipos de datos (Order, Trade, OrderBook)\n",
"2. Order Book con inserción y cancelación\n",
"3. Matching engine FIFO\n",
"4. Tests y visualización"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 1. Tipos de datos"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from __future__ import annotations\n",
"from dataclasses import dataclass, field\n",
"from enum import Enum\n",
"from typing import Optional\n",
"from collections import defaultdict\n",
"from sortedcontainers import SortedDict\n",
"import time\n",
"import uuid\n",
"\n",
"\n",
"class Side(Enum):\n",
" BUY = \"buy\"\n",
" SELL = \"sell\"\n",
"\n",
"\n",
"class OrderType(Enum):\n",
" LIMIT = \"limit\"\n",
" MARKET = \"market\"\n",
"\n",
"\n",
"class OrderStatus(Enum):\n",
" NEW = \"new\"\n",
" PARTIAL = \"partial\"\n",
" FILLED = \"filled\"\n",
" CANCELLED = \"cancelled\"\n",
"\n",
"\n",
"@dataclass\n",
"class Order:\n",
" \"\"\"Una orden en el libro.\"\"\"\n",
" side: Side\n",
" price: float # 0 para market orders\n",
" qty: float # cantidad original\n",
" remaining: float = 0 # cantidad pendiente\n",
" order_type: OrderType = OrderType.LIMIT\n",
" order_id: str = field(default_factory=lambda: str(uuid.uuid4()))\n",
" timestamp: float = field(default_factory=time.time)\n",
" status: OrderStatus = OrderStatus.NEW\n",
"\n",
" def __post_init__(self):\n",
" if self.remaining == 0:\n",
" self.remaining = self.qty\n",
"\n",
"\n",
"@dataclass\n",
"class Trade:\n",
" \"\"\"Un trade ejecutado por el matching engine.\"\"\"\n",
" price: float\n",
" qty: float\n",
" buyer_order_id: str\n",
" seller_order_id: str\n",
" timestamp: float = field(default_factory=time.time)\n",
" trade_id: str = field(default_factory=lambda: str(uuid.uuid4()))\n",
"\n",
"\n",
"print(\"Tipos definidos: Side, OrderType, OrderStatus, Order, Trade\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 2. Order Book\n",
"\n",
"Estructura del libro de órdenes:\n",
"- **Bids** (compras): ordenados por precio descendente, FIFO dentro del mismo precio\n",
"- **Asks** (ventas): ordenados por precio ascendente, FIFO dentro del mismo precio\n",
"\n",
"Usamos `SortedDict` para mantener los niveles de precio ordenados y `deque` para la cola FIFO en cada nivel."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from collections import deque\n",
"\n",
"\n",
"class OrderBook:\n",
" \"\"\"Libro de órdenes con niveles de precio ordenados y colas FIFO por nivel.\"\"\"\n",
"\n",
" def __init__(self):\n",
" # SortedDict: price -> deque[Order]\n",
" # Bids: negamos el precio para que SortedDict ordene desc\n",
" self._bids: SortedDict = SortedDict() # key = -price\n",
" self._asks: SortedDict = SortedDict() # key = price\n",
" self._orders: dict[str, Order] = {} # order_id -> Order (lookup rápido)\n",
"\n",
" def add(self, order: Order) -> None:\n",
" \"\"\"Añade una orden al libro (sin matching, solo inserción).\"\"\"\n",
" book = self._bids if order.side == Side.BUY else self._asks\n",
" key = -order.price if order.side == Side.BUY else order.price\n",
"\n",
" if key not in book:\n",
" book[key] = deque()\n",
" book[key].append(order)\n",
" self._orders[order.order_id] = order\n",
"\n",
" def cancel(self, order_id: str) -> Optional[Order]:\n",
" \"\"\"Cancela una orden por ID. Retorna la orden cancelada o None.\"\"\"\n",
" order = self._orders.pop(order_id, None)\n",
" if order is None:\n",
" return None\n",
"\n",
" book = self._bids if order.side == Side.BUY else self._asks\n",
" key = -order.price if order.side == Side.BUY else order.price\n",
"\n",
" if key in book:\n",
" q = book[key]\n",
" try:\n",
" q.remove(order)\n",
" except ValueError:\n",
" pass\n",
" if not q:\n",
" del book[key]\n",
"\n",
" order.status = OrderStatus.CANCELLED\n",
" return order\n",
"\n",
" @property\n",
" def best_bid(self) -> Optional[float]:\n",
" \"\"\"Mejor precio de compra.\"\"\"\n",
" if not self._bids:\n",
" return None\n",
" return -self._bids.peekitem(0)[0]\n",
"\n",
" @property\n",
" def best_ask(self) -> Optional[float]:\n",
" \"\"\"Mejor precio de venta.\"\"\"\n",
" if not self._asks:\n",
" return None\n",
" return self._asks.peekitem(0)[0]\n",
"\n",
" @property\n",
" def spread(self) -> Optional[float]:\n",
" \"\"\"Spread bid-ask.\"\"\"\n",
" if self.best_bid is None or self.best_ask is None:\n",
" return None\n",
" return self.best_ask - self.best_bid\n",
"\n",
" @property\n",
" def midprice(self) -> Optional[float]:\n",
" \"\"\"Precio medio.\"\"\"\n",
" if self.best_bid is None or self.best_ask is None:\n",
" return None\n",
" return (self.best_bid + self.best_ask) / 2\n",
"\n",
" def depth(self, side: Side, levels: int = 5) -> list[tuple[float, float]]:\n",
" \"\"\"Profundidad del libro: [(price, total_qty), ...] para N niveles.\"\"\"\n",
" book = self._bids if side == Side.BUY else self._asks\n",
" result = []\n",
" for key in book.islice(0, levels):\n",
" price = -key if side == Side.BUY else key\n",
" total_qty = sum(o.remaining for o in book[key])\n",
" result.append((price, total_qty))\n",
" return result\n",
"\n",
" def __repr__(self):\n",
" bids = self.depth(Side.BUY, 3)\n",
" asks = self.depth(Side.SELL, 3)\n",
" return f\"OrderBook(best_bid={self.best_bid}, best_ask={self.best_ask}, spread={self.spread}, bids_top3={bids}, asks_top3={asks})\"\n",
"\n",
"\n",
"print(\"OrderBook definido\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 3. Matching Engine FIFO\n",
"\n",
"Lógica de matching:\n",
"1. Orden de **compra** se matchea contra asks (de menor a mayor precio)\n",
"2. Orden de **venta** se matchea contra bids (de mayor a menor precio)\n",
"3. Dentro de cada nivel de precio: **FIFO** (primera en llegar, primera en ejecutarse)\n",
"4. El precio del trade es siempre el de la orden **pasiva** (la que ya estaba en el libro)\n",
"5. Si la orden agresora no se llena completamente, se inserta en el libro como orden pasiva"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"class MatchingEngineFIFO:\n",
" \"\"\"Motor de matching con prioridad precio-tiempo (FIFO).\"\"\"\n",
"\n",
" def __init__(self):\n",
" self.book = OrderBook()\n",
" self.trades: list[Trade] = []\n",
"\n",
" def submit(self, order: Order) -> list[Trade]:\n",
" \"\"\"Procesa una orden: matchea lo posible y el resto va al libro.\"\"\"\n",
" new_trades = self._match(order)\n",
" self.trades.extend(new_trades)\n",
"\n",
" # Si queda cantidad y es limit, insertar en el libro\n",
" if order.remaining > 0 and order.order_type == OrderType.LIMIT:\n",
" order.status = OrderStatus.PARTIAL if order.remaining < order.qty else OrderStatus.NEW\n",
" self.book.add(order)\n",
"\n",
" return new_trades\n",
"\n",
" def _match(self, aggressor: Order) -> list[Trade]:\n",
" \"\"\"Matchea la orden agresora contra el lado opuesto del libro.\"\"\"\n",
" trades = []\n",
"\n",
" # Seleccionar el lado opuesto\n",
" if aggressor.side == Side.BUY:\n",
" passive_book = self.book._asks # asks ordenados asc\n",
" price_key_fn = lambda k: k # key es el precio directo\n",
" can_match = lambda passive_price: (\n",
" aggressor.order_type == OrderType.MARKET or\n",
" passive_price <= aggressor.price\n",
" )\n",
" else:\n",
" passive_book = self.book._bids # bids ordenados desc (key negado)\n",
" price_key_fn = lambda k: -k # desnegar para obtener precio real\n",
" can_match = lambda passive_price: (\n",
" aggressor.order_type == OrderType.MARKET or\n",
" passive_price >= aggressor.price\n",
" )\n",
"\n",
" keys_to_remove = []\n",
"\n",
" for key in list(passive_book.keys()):\n",
" if aggressor.remaining <= 0:\n",
" break\n",
"\n",
" passive_price = price_key_fn(key)\n",
" if not can_match(passive_price):\n",
" break # los siguientes niveles son peores\n",
"\n",
" queue = passive_book[key]\n",
"\n",
" while queue and aggressor.remaining > 0:\n",
" passive = queue[0] # FIFO: primera de la cola\n",
" fill_qty = min(aggressor.remaining, passive.remaining)\n",
"\n",
" # Ejecutar trade al precio pasivo\n",
" trade = Trade(\n",
" price=passive_price,\n",
" qty=fill_qty,\n",
" buyer_order_id=aggressor.order_id if aggressor.side == Side.BUY else passive.order_id,\n",
" seller_order_id=passive.order_id if aggressor.side == Side.BUY else aggressor.order_id,\n",
" )\n",
" trades.append(trade)\n",
"\n",
" # Actualizar cantidades\n",
" aggressor.remaining -= fill_qty\n",
" passive.remaining -= fill_qty\n",
"\n",
" if passive.remaining <= 0:\n",
" passive.status = OrderStatus.FILLED\n",
" queue.popleft()\n",
" self.book._orders.pop(passive.order_id, None)\n",
" else:\n",
" passive.status = OrderStatus.PARTIAL\n",
"\n",
" if not queue:\n",
" keys_to_remove.append(key)\n",
"\n",
" # Limpiar niveles vacíos\n",
" for key in keys_to_remove:\n",
" del passive_book[key]\n",
"\n",
" if aggressor.remaining <= 0:\n",
" aggressor.status = OrderStatus.FILLED\n",
"\n",
" return trades\n",
"\n",
" def cancel(self, order_id: str) -> Optional[Order]:\n",
" \"\"\"Cancela una orden del libro.\"\"\"\n",
" return self.book.cancel(order_id)\n",
"\n",
"\n",
"print(\"MatchingEngineFIFO definido\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 4. Tests básicos"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"def test_basic_match():\n",
" \"\"\"Dos órdenes opuestas al mismo precio → 1 trade.\"\"\"\n",
" engine = MatchingEngineFIFO()\n",
"\n",
" # Sell limit a 100\n",
" sell = Order(side=Side.SELL, price=100.0, qty=10.0)\n",
" engine.submit(sell)\n",
"\n",
" # Buy limit a 100 → debe matchear\n",
" buy = Order(side=Side.BUY, price=100.0, qty=10.0)\n",
" trades = engine.submit(buy)\n",
"\n",
" assert len(trades) == 1, f\"Expected 1 trade, got {len(trades)}\"\n",
" assert trades[0].price == 100.0\n",
" assert trades[0].qty == 10.0\n",
" assert buy.status == OrderStatus.FILLED\n",
" assert sell.status == OrderStatus.FILLED\n",
" print(\"✓ test_basic_match\")\n",
"\n",
"\n",
"def test_partial_fill():\n",
" \"\"\"Buy de 15 contra sell de 10 → fill parcial, 5 queda en libro.\"\"\"\n",
" engine = MatchingEngineFIFO()\n",
"\n",
" sell = Order(side=Side.SELL, price=100.0, qty=10.0)\n",
" engine.submit(sell)\n",
"\n",
" buy = Order(side=Side.BUY, price=100.0, qty=15.0)\n",
" trades = engine.submit(buy)\n",
"\n",
" assert len(trades) == 1\n",
" assert trades[0].qty == 10.0\n",
" assert buy.remaining == 5.0\n",
" assert buy.status == OrderStatus.PARTIAL\n",
" assert engine.book.best_bid == 100.0\n",
" print(\"✓ test_partial_fill\")\n",
"\n",
"\n",
"def test_fifo_priority():\n",
" \"\"\"Dos sells al mismo precio → la primera se llena primero (FIFO).\"\"\"\n",
" engine = MatchingEngineFIFO()\n",
"\n",
" sell1 = Order(side=Side.SELL, price=100.0, qty=5.0)\n",
" sell2 = Order(side=Side.SELL, price=100.0, qty=5.0)\n",
" engine.submit(sell1)\n",
" engine.submit(sell2)\n",
"\n",
" buy = Order(side=Side.BUY, price=100.0, qty=7.0)\n",
" trades = engine.submit(buy)\n",
"\n",
" assert len(trades) == 2, f\"Expected 2 trades, got {len(trades)}\"\n",
" assert trades[0].qty == 5.0 # sell1 completamente llena\n",
" assert trades[1].qty == 2.0 # sell2 parcial\n",
" assert sell1.status == OrderStatus.FILLED\n",
" assert sell2.status == OrderStatus.PARTIAL\n",
" assert sell2.remaining == 3.0\n",
" print(\"✓ test_fifo_priority\")\n",
"\n",
"\n",
"def test_price_priority():\n",
" \"\"\"Sell a 99 antes que sell a 100 → buyer obtiene mejor precio.\"\"\"\n",
" engine = MatchingEngineFIFO()\n",
"\n",
" sell_expensive = Order(side=Side.SELL, price=100.0, qty=5.0)\n",
" sell_cheap = Order(side=Side.SELL, price=99.0, qty=5.0)\n",
" engine.submit(sell_expensive)\n",
" engine.submit(sell_cheap)\n",
"\n",
" buy = Order(side=Side.BUY, price=100.0, qty=8.0)\n",
" trades = engine.submit(buy)\n",
"\n",
" assert len(trades) == 2\n",
" assert trades[0].price == 99.0 # primero la más barata\n",
" assert trades[0].qty == 5.0\n",
" assert trades[1].price == 100.0 # luego la cara\n",
" assert trades[1].qty == 3.0\n",
" print(\"✓ test_price_priority\")\n",
"\n",
"\n",
"def test_no_match_spread():\n",
" \"\"\"Buy a 99, sell a 100 → no matchea, ambas en libro.\"\"\"\n",
" engine = MatchingEngineFIFO()\n",
"\n",
" sell = Order(side=Side.SELL, price=100.0, qty=10.0)\n",
" engine.submit(sell)\n",
"\n",
" buy = Order(side=Side.BUY, price=99.0, qty=10.0)\n",
" trades = engine.submit(buy)\n",
"\n",
" assert len(trades) == 0\n",
" assert engine.book.best_bid == 99.0\n",
" assert engine.book.best_ask == 100.0\n",
" assert engine.book.spread == 1.0\n",
" print(\"✓ test_no_match_spread\")\n",
"\n",
"\n",
"def test_market_order():\n",
" \"\"\"Market order matchea a cualquier precio disponible.\"\"\"\n",
" engine = MatchingEngineFIFO()\n",
"\n",
" sell = Order(side=Side.SELL, price=105.0, qty=10.0)\n",
" engine.submit(sell)\n",
"\n",
" buy = Order(side=Side.BUY, price=0, qty=5.0, order_type=OrderType.MARKET)\n",
" trades = engine.submit(buy)\n",
"\n",
" assert len(trades) == 1\n",
" assert trades[0].price == 105.0 # al precio de la pasiva\n",
" assert trades[0].qty == 5.0\n",
" print(\"✓ test_market_order\")\n",
"\n",
"\n",
"def test_cancel():\n",
" \"\"\"Cancelar una orden la remueve del libro.\"\"\"\n",
" engine = MatchingEngineFIFO()\n",
"\n",
" sell = Order(side=Side.SELL, price=100.0, qty=10.0)\n",
" engine.submit(sell)\n",
" assert engine.book.best_ask == 100.0\n",
"\n",
" cancelled = engine.cancel(sell.order_id)\n",
" assert cancelled is not None\n",
" assert cancelled.status == OrderStatus.CANCELLED\n",
" assert engine.book.best_ask is None\n",
" print(\"✓ test_cancel\")\n",
"\n",
"\n",
"# Ejecutar todos\n",
"test_basic_match()\n",
"test_partial_fill()\n",
"test_fifo_priority()\n",
"test_price_priority()\n",
"test_no_match_spread()\n",
"test_market_order()\n",
"test_cancel()\n",
"print(\"\\n=== Todos los tests pasaron ===\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 5. Visualización del Order Book"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"import matplotlib.pyplot as plt\n",
"import numpy as np\n",
"\n",
"\n",
"def plot_orderbook(engine: MatchingEngineFIFO, levels: int = 10, title: str = \"Order Book\"):\n",
" \"\"\"Visualiza la profundidad del order book.\"\"\"\n",
" bids = engine.book.depth(Side.BUY, levels)\n",
" asks = engine.book.depth(Side.SELL, levels)\n",
"\n",
" fig, ax = plt.subplots(figsize=(10, 5))\n",
"\n",
" if bids:\n",
" bid_prices, bid_qtys = zip(*bids)\n",
" bid_cum = np.cumsum(bid_qtys)\n",
" ax.barh(range(len(bids)), bid_qtys, color='#2ecc71', alpha=0.7, label='Bids')\n",
" for i, (p, q) in enumerate(bids):\n",
" ax.text(q + 0.1, i, f\"{p:.2f} ({q:.1f})\", va='center', fontsize=9)\n",
"\n",
" if asks:\n",
" ask_prices, ask_qtys = zip(*asks)\n",
" y_offset = len(bids) + 1 # gap visual\n",
" ax.barh(range(y_offset, y_offset + len(asks)), ask_qtys, color='#e74c3c', alpha=0.7, label='Asks')\n",
" for i, (p, q) in enumerate(asks):\n",
" ax.text(q + 0.1, y_offset + i, f\"{p:.2f} ({q:.1f})\", va='center', fontsize=9)\n",
"\n",
" ax.set_yticks([])\n",
" ax.set_xlabel('Quantity')\n",
" ax.set_title(f\"{title}\\nSpread: {engine.book.spread:.2f} | Mid: {engine.book.midprice:.2f}\" if engine.book.spread else title)\n",
" ax.legend()\n",
" plt.tight_layout()\n",
" plt.show()"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Crear un libro con varias órdenes para visualizar\n",
"import random\n",
"random.seed(42)\n",
"\n",
"engine = MatchingEngineFIFO()\n",
"\n",
"# Poblar bids alrededor de 100\n",
"for i in range(20):\n",
" price = round(100 - random.uniform(0.1, 2.0), 2)\n",
" qty = round(random.uniform(1, 20), 1)\n",
" engine.submit(Order(side=Side.BUY, price=price, qty=qty))\n",
"\n",
"# Poblar asks alrededor de 100\n",
"for i in range(20):\n",
" price = round(100 + random.uniform(0.1, 2.0), 2)\n",
" qty = round(random.uniform(1, 20), 1)\n",
" engine.submit(Order(side=Side.SELL, price=price, qty=qty))\n",
"\n",
"print(engine.book)\n",
"plot_orderbook(engine, levels=8, title=\"Order Book Sintético\")"
]
},
{
"cell_type": "markdown",
"metadata": {},
"source": [
"## 6. Simulación: impacto de una market order\n",
"\n",
"Veamos cómo una market order grande barre niveles del libro y mueve el precio."
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"# Estado antes\n",
"print(\"=== ANTES ===\")\n",
"print(f\"Best ask: {engine.book.best_ask}\")\n",
"print(f\"Best bid: {engine.book.best_bid}\")\n",
"print(f\"Spread: {engine.book.spread:.4f}\")\n",
"print(f\"Midprice: {engine.book.midprice:.4f}\")\n",
"print(f\"\\nAsk depth (5 niveles): {engine.book.depth(Side.SELL, 5)}\")\n",
"\n",
"# Market buy grande: comprar 50 unidades\n",
"big_buy = Order(side=Side.BUY, price=0, qty=50.0, order_type=OrderType.MARKET)\n",
"trades = engine.submit(big_buy)\n",
"\n",
"print(f\"\\n=== MARKET BUY 50 ===\")\n",
"print(f\"Trades ejecutados: {len(trades)}\")\n",
"for t in trades:\n",
" print(f\" {t.qty:.1f} @ {t.price:.2f}\")\n",
"\n",
"avg_price = sum(t.price * t.qty for t in trades) / sum(t.qty for t in trades) if trades else 0\n",
"print(f\"\\nPrecio promedio ponderado: {avg_price:.4f}\")\n",
"print(f\"Slippage vs best ask: {avg_price - trades[0].price:.4f}\" if trades else \"\")\n",
"\n",
"print(f\"\\n=== DESPUÉS ===\")\n",
"print(f\"Best ask: {engine.book.best_ask}\")\n",
"print(f\"Best bid: {engine.book.best_bid}\")\n",
"print(f\"Spread: {engine.book.spread}\")\n",
"print(engine.book)"
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3 (ipykernel)",
"language": "python",
"name": "python3"
},
"language_info": {
"name": "python",
"version": "3.13.0"
}
},
"nbformat": 4,
"nbformat_minor": 4
}