ในโลกของการพัฒนาระบบเทรดและอัลกอริทึม (Algorithmic Trading) หนึ่งในความท้าทายที่ใหญ่ที่สุดคือการทดสอบ Backtest ด้วยข้อมูล Orderbook ในอดีต ผมเคยเจอสถานการณ์ที่ทำให้ทีมงานต้องหยุดพัฒนาไปหลายวัน:
ConnectionError: Failed to reconnect after 3 attempts
[2024-01-15 09:23:45] Connection timeout while fetching historical orderbook data from exchange
[2024-01-15 09:23:46] ERROR: Received 401 Unauthorized - API key expired or invalid
[2024-01-15 09:23:47] WARNING: Data gap detected - missing 847 orderbook snapshots between timestamp 1705312800000 and 1705316400000
[2024-01-15 09:23:48] CRITICAL: Orderbook state inconsistency - bid[0] price < ask[0] price violated
ข้อผิดพลาดเหล่านี้เกิดจากการที่เราไม่มีระบบ Orderbook Data Replay ที่แข็งแกร่งพอ วันนี้ผมจะมาแชร์วิธีการสร้างระบบ Reconstruct Orderbook และ Simulated Matching Engine ตั้งแต่เริ่มต้น พร้อมโค้ดที่พร้อมใช้งานจริง ซึ่งสามารถประยุกต์ใช้กับ HolySheep AI สำหรับการวิเคราะห์ข้อมูลขั้นสูงได้อีกด้วย
ทำความเข้าใจ Orderbook และ Data Replay
Orderbook คือบันทึกคำสั่งซื้อ-ขายที่รอการจับคู่ในตลาด โดยจะแสดงราคาและปริมาณของคำสั่งที่รอดำเนินการ การทำ Data Replay หมายถึงการนำข้อมูลประวัติมาเล่นซ้ำเพื่อ:
- Backtest กลยุทธ์การเทรด - ทดสอบว่ากลยุทธ์จะทำกำไรได้หรือไม่
- วิเคราะห์ Liquidity - ศึกษาความลึกของตลาดในช่วงเวลาต่างๆ
- ตรวจสอบ Slippage - คำนวณความคลาดเคลื่อนของราคาที่รัน
- Debug ระบบเทรด - หาสาเหตุของปัญหาที่เกิดขึ้นจริง
โครงสร้างข้อมูล Orderbook ใน Python
ก่อนจะเริ่มสร้างระบบ Replay เราต้องมีโครงสร้างข้อมูลที่เหมาะสมก่อน นี่คือ implementation ที่ใช้งานได้จริงใน production:
from dataclasses import dataclass, field
from typing import Dict, List, Tuple, Optional
from decimal import Decimal
import heapq
from enum import Enum
import time
from collections import defaultdict
class OrderSide(Enum):
BUY = "BUY"
SELL = "SELL"
class OrderType(Enum):
LIMIT = "LIMIT"
MARKET = "MARKET"
IOC = "IOC" # Immediate Or Cancel
FOK = "FOK" # Fill Or Kill
@dataclass
class Order:
order_id: str
symbol: str
side: OrderSide
price: Decimal
quantity: Decimal
order_type: OrderType = OrderType.LIMIT
timestamp: int = field(default_factory=lambda: int(time.time() * 1000))
filled_quantity: Decimal = field(default_factory=lambda: Decimal("0"))
status: str = "NEW"
def remaining_quantity(self) -> Decimal:
return self.quantity - self.filled_quantity
def is_fully_filled(self) -> bool:
return self.filled_quantity >= self.quantity
@dataclass
class OrderbookLevel:
price: Decimal
quantity: Decimal
orders: List[Order] = field(default_factory=list)
def __lt__(self, other):
# For max heap (buy orders), higher price = higher priority
# For min heap (sell orders), lower price = higher priority
return self.price < other.price if self.price != other.price else False
class OrderbookSide:
"""Orderbook side using sorted containers for efficient price-level management"""
def __init__(self, is_bid: bool):
self.is_bid = is_bid # True = bid (buy), False = ask (sell)
# Price level storage: price -> list of orders
self.levels: Dict[Decimal, List[Order]] = {}
# Price indexing for quick lookup
self._prices: List[Decimal] = []
self._heap_initialized = False
def _get_sort_key(self):
"""Return sort key based on side (descending for bids, ascending for asks)"""
return not self.is_bid # Bids sorted descending, asks sorted ascending
def add_order(self, order: Order) -> List[Tuple[Order, Order, Decimal, Decimal]]:
"""Add order to the book and return list of trades made"""
trades = []
remaining_qty = order.remaining_quantity()
if remaining_qty <= 0:
return trades
# Get sorted prices (bids: high to low, asks: low to high)
prices = self.get_sorted_prices()
for price in prices:
# For buys, we can only match if price >= best ask
# For sells, we can only match if price <= best bid
if self.is_bid and price > order.price:
break
if not self.is_bid and price < order.price:
continue
level_orders = self.levels.get(price, [])
for existing_order in level_orders[:]: # Copy list to avoid mutation issues
if remaining_qty <= 0:
break
match_qty = min(remaining_qty, existing_order.remaining_quantity())
# Create trade
trade_price = price
trades.append((order, existing_order, match_qty, trade_price))
# Update quantities
order.filled_quantity += match_qty
existing_order.filled_quantity += match_qty
remaining_qty -= match_qty
# Update level quantity
self.levels[price] = [o for o in level_orders if not o.is_fully_filled()]
if not self.levels[price]:
del self.levels[price]
return trades
def get_sorted_prices(self) -> List[Decimal]:
"""Return prices sorted appropriately for this side"""
if not self.levels:
return []
prices = list(self.levels.keys())
if self.is_bid:
return sorted(prices, reverse=True) # Highest first
else:
return sorted(prices) # Lowest first
def get_best_price(self) -> Optional[Decimal]:
"""Get best (highest for bid, lowest for ask) price"""
prices = self.get_sorted_prices()
return prices[0] if prices else None
def get_quantity_at_price(self, price: Decimal) -> Decimal:
"""Get total quantity at specific price level"""
if price not in self.levels:
return Decimal("0")
return sum(o.remaining_quantity() for o in self.levels[price])
def remove_order(self, order: Order) -> bool:
"""Remove specific order from the book"""
if order.price not in self.levels:
return False
self.levels[order.price] = [
o for o in self.levels[order.price]
if o.order_id != order.order_id
]
if not self.levels[order.price]:
del self.levels[order.price]
return True
def get_depth(self, levels: int = 10) -> List[Tuple[Decimal, Decimal]]:
"""Get depth (price, quantity) for top N levels"""
sorted_prices = self.get_sorted_prices()
result = []
for price in sorted_prices[:levels]:
total_qty = self.get_quantity_at_price(price)
result.append((price, total_qty))
return result
class Orderbook:
"""Complete orderbook with bid and ask sides"""
def __init__(self, symbol: str):
self.symbol = symbol
self.bids = OrderbookSide(is_bid=True)
self.asks = OrderbookSide(is_bid=False)
self.trades: List[dict] = []
self.order_map: Dict[str, Order] = {}
self.sequence_number: int = 0
def add_order(self, order: Order) -> List[dict]:
"""Add order and return list of trades"""
self.order_map[order.order_id] = order
self.sequence_number += 1
# Determine which side to add based on order side
if order.side == OrderSide.BUY:
opposing_side = self.asks
own_side = self.bids
else:
opposing_side = self.bids
own_side = self.asks
# Check if order can match immediately
can_match = self._can_match(order, opposing_side)
if can_match or order.order_type == OrderType.MARKET:
trades = opposing_side.add_order(order)
self._process_trades(trades, order)
# If not fully filled and limit order, add to book
if not order.is_fully_filled() and order.order_type == OrderType.LIMIT:
if order.price not in own_side.levels:
own_side.levels[order.price] = []
own_side.levels[order.price].append(order)
return self.trades[-len(trades) if trades else 0:]
def _can_match(self, order: Order, opposing_side: OrderbookSide) -> bool:
"""Check if order can match against opposing side"""
best_price = opposing_side.get_best_price()
if best_price is None:
return False
if order.side == OrderSide.BUY:
return order.price >= best_price
else:
return order.price <= best_price
def _process_trades(self, trades: List[Tuple[Order, Order, Decimal, Decimal]], incoming: Order):
"""Process and record trades"""
for buyer, seller, quantity, price in trades:
trade = {
"trade_id": f"T{self.sequence_number}",
"symbol": self.symbol,
"price": price,
"quantity": quantity,
"buyer_order_id": buyer.order_id,
"seller_order_id": seller.order_id,
"timestamp": int(time.time() * 1000),
"sequence": self.sequence_number
}
self.trades.append(trade)
def get_spread(self) -> Tuple[Optional[Decimal], Optional[Decimal]]:
"""Get best bid and ask prices"""
return self.bids.get_best_price(), self.asks.get_best_price()
def get_mid_price(self) -> Optional[Decimal]:
"""Calculate mid price"""
best_bid, best_ask = self.get_spread()
if best_bid is None or best_ask is None:
return None
return (best_bid + best_ask) / 2
def get_orderbook_snapshot(self) -> dict:
"""Get complete orderbook state for snapshot/replay"""
return {
"symbol": self.symbol,
"timestamp": int(time.time() * 1000),
"sequence": self.sequence_number,
"bids": self.bids.get_depth(20),
"asks": self.asks.get_depth(20),
"spread": self.get_spread(),
"mid_price": self.get_mid_price(),
"total_bid_qty": sum(q for _, q in self.bids.get_depth(20)),
"total_ask_qty": sum(q for _, q in self.asks.get_depth(20))
}
Unit test for basic functionality
if __name__ == "__main__":
book = Orderbook("BTC/USDT")
# Add some orders
order1 = Order(
order_id="O1", symbol="BTC/USDT",
side=OrderSide.SELL, price=Decimal("50000"),
quantity=Decimal("1.5"), order_type=OrderType.LIMIT
)
book.add_order(order1)
order2 = Order(
order_id="O2", symbol="BTC/USDT",
side=OrderSide.BUY, price=Decimal("50100"),
quantity=Decimal("0.5"), order_type=OrderType.LIMIT
)
book.add_order(order2)
print(f"Spread: {book.get_spread()}")
print(f"Mid Price: {book.get_mid_price()}")
print(f"Snapshot: {book.get_orderbook_snapshot()}")
ระบบ Orderbook Data Replay Engine
ต่อไปคือหัวใจสำคัญของระบบ - Data Replay Engine ที่สามารถอ่านข้อมูลประวัติและเล่นซ้ำเพื่อ reconstruct orderbook state:
import json
import gzip
import mmap
from typing import Generator, Iterator, Optional, Callable
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from pathlib import Path
import struct
from collections import deque
import bisect
@dataclass
class OrderbookUpdate:
"""Single orderbook update from data feed"""
timestamp: int # milliseconds since epoch
sequence: int
symbol: str
side: str # "BID" or "ASK"
price: Decimal
quantity: Decimal
action: str # "ADD", "MODIFY", "DELETE"
order_id: Optional[str] = None
@classmethod
def from_binary(cls, data: bytes) -> 'OrderbookUpdate':
"""Parse from binary format (common in HFT systems)"""
# Assuming format: timestamp(8), seq(8), symbol_len(2), symbol(n),
# side(1), price(8), qty(8), action(1), order_id_len(2), order_id(n)
offset = 0
timestamp = struct.unpack('>Q', data[offset:offset+8])[0]
offset += 8
sequence = struct.unpack('>Q', data[offset:offset+8])[0]
offset += 8
symbol_len = struct.unpack('>H', data[offset:offset+2])[0]
offset += 2
symbol = data[offset:offset+symbol_len].decode('utf-8')
offset += symbol_len
side = data[offset:offset+1].decode('utf-8')
offset += 1
price = struct.unpack('>d', data[offset:offset+8])[0]
offset += 8
quantity = struct.unpack('>d', data[offset:offset+8])[0]
offset += 8
action = data[offset:offset+1].decode('utf-8')
return cls(
timestamp=timestamp, sequence=sequence, symbol=symbol,
side=side, price=Decimal(str(price)),
quantity=Decimal(str(quantity)), action=action
)
@dataclass
class HistoricalDataConfig:
"""Configuration for historical data replay"""
data_path: Path
start_time: Optional[int] = None
end_time: Optional[int] = None
symbols: list = field(default_factory=list)
replay_speed: float = 1.0 # 1.0 = real-time, 0.0 = instant, 2.0 = 2x speed
buffer_size: int = 10000
validate_data: bool = True
class OrderbookReplayEngine:
"""Engine for replaying historical orderbook data"""
def __init__(self, config: HistoricalDataConfig):
self.config = config
self.orderbooks: Dict[str, Orderbook] = {}
self.update_buffer: deque = deque(maxlen=config.buffer_size)
self.replay_start_time: Optional[int] = None
self.last_processed_timestamp: Optional[int] = None
# Statistics
self.stats = {
"total_updates": 0,
"total_trades": 0,
"updates_per_symbol": defaultdict(int),
"sequence_gaps": [],
"data_gaps": []
}
def load_update_file(self, filepath: Path) -> Generator[OrderbookUpdate, None, None]:
"""Load updates from compressed JSON Lines file"""
is_gz = filepath.suffix == '.gz'
with (gzip.open if is_gz else open)(filepath, 'rt') as f:
for line in f:
try:
data = json.loads(line.strip())
update = OrderbookUpdate(
timestamp=data['t'],
sequence=data['s'],
symbol=data['sym'],
side=data['side'],
price=Decimal(str(data['p'])),
quantity=Decimal(str(data['q'])),
action=data['a'],
order_id=data.get('oid')
)
# Apply filters
if self.config.start_time and update.timestamp < self.config.start_time:
continue
if self.config.end_time and update.timestamp > self.config.end_time:
break
if self.config.symbols and update.symbol not in self.config.symbols:
continue
yield update
except (json.JSONDecodeError, KeyError) as e:
print(f"Warning: Failed to parse line: {e}")
continue
def load_from_binary(self, filepath: Path) -> Generator[OrderbookUpdate, None, None]:
"""Load updates from binary format with memory mapping for large files"""
with open(filepath, 'rb') as f:
# Memory map for efficient random access
with mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ) as mm:
pos = 0
while pos < len(mm):
# Read message length
msg_len_bytes = mm[pos:pos+4]
if len(msg_len_bytes) < 4:
break
msg_len = struct.unpack('>I', msg_len_bytes)[0]
pos += 4
# Read message
msg_data = mm[pos:pos+msg_len]
pos += msg_len
try:
update = OrderbookUpdate.from_binary(msg_data)
yield update
except Exception as e:
print(f"Warning: Failed to parse binary message: {e}")
continue
def replay(self, filepath: Path,
on_update: Optional[Callable[[str, Orderbook, OrderbookUpdate], None]] = None,
on_trade: Optional[Callable[[str, dict], None]] = None) -> Dict[str, Orderbook]:
"""Main replay function with callbacks for real-time processing"""
# Choose loader based on file extension
if filepath.suffix == '.bin':
updates = self.load_from_binary(filepath)
else:
updates = self.load_update_file(filepath)
prev_sequence: Dict[str, int] = {}
for update in updates:
# Initialize orderbook for symbol if needed
if update.symbol not in self.orderbooks:
self.orderbooks[update.symbol] = Orderbook(update.symbol)
# Track sequence gaps
if update.symbol in prev_sequence:
expected_seq = prev_sequence[update.symbol] + 1
if update.sequence != expected_seq:
gap_size = update.sequence - expected_seq
self.stats['sequence_gaps'].append({
'symbol': update.symbol,
'expected': expected_seq,
'actual': update.sequence,
'gap': gap_size,
'timestamp': update.timestamp
})
prev_sequence[update.symbol] = update.sequence
# Detect data gaps (time gaps)
if self.last_processed_timestamp:
time_diff = update.timestamp - self.last_processed_timestamp
if time_diff > 1000: # Gap > 1 second
self.stats['data_gaps'].append({
'from': self.last_processed_timestamp,
'to': update.timestamp,
'gap_ms': time_diff
})
# Apply update to orderbook
book = self.orderbooks[update.symbol]
self._apply_update(book, update)
# Update statistics
self.stats['total_updates'] += 1
self.stats['updates_per_symbol'][update.symbol] += 1
# Buffer the update
self.update_buffer.append({
'update': update,
'book_state': book.get_orderbook_snapshot()
})
# Trigger callbacks
if on_update:
on_update(update.symbol, book, update)
self.last_processed_timestamp = update.timestamp
return self.orderbooks
def _apply_update(self, book: Orderbook, update: OrderbookUpdate):
"""Apply single update to orderbook"""
if update.action == "DELETE":
# Find and remove order
if update.order_id and update.order_id in book.order_map:
order = book.order_map[update.order_id]
if update.side == "BID":
book.bids.remove_order(order)
else:
book.asks.remove_order(order)
elif update.action == "ADD":
# Create new order
order = Order(
order_id=update.order_id or f"G{book.sequence_number}",
symbol=update.symbol,
side=OrderSide.BUY if update.side == "BID" else OrderSide.SELL,
price=update.price,
quantity=update.quantity,
order_type=OrderType.LIMIT,
timestamp=update.timestamp
)
book.add_order(order)
elif update.action == "MODIFY":
# Modify existing order
if update.order_id and update.order_id in book.order_map:
order = book.order_map[update.order_id]
# Remove old, add new with updated quantity
if update.side == "BID":
book.bids.remove_order(order)
else:
book.asks.remove_order(order)
if update.quantity > 0:
order.quantity = update.quantity
order.filled_quantity = Decimal("0")
if update.side == "BID":
book.bids.levels.setdefault(order.price, []).append(order)
else:
book.asks.levels.setdefault(order.price, []).append(order)
def get_state_at_timestamp(self, timestamp: int, symbol: str) -> Optional[dict]:
"""Get orderbook state at specific timestamp using buffered data"""
if symbol not in self.orderbooks:
return None
# Binary search in buffer for closest update
timestamps = [item['update'].timestamp for item in self.update_buffer]
idx = bisect.bisect_right(timestamps, timestamp)
if idx > 0:
# Return state before this timestamp
state = self.orderbooks[symbol].get_orderbook_snapshot()
state['requested_timestamp'] = timestamp
state['actual_timestamp'] = timestamps[idx-1]
return state
return None
def export_replay_summary(self) -> dict:
"""Export summary statistics of the replay"""
return {
'config': {
'data_path': str(self.config.data_path),
'start_time': self.config.start_time,
'end_time': self.config.end_time,
'symbols': self.config.symbols
},
'statistics': {
**self.stats,
'sequence_gaps_count': len(self.stats['sequence_gaps']),
'data_gaps_count': len(self.stats['data_gaps']),
'symbols_processed': list(self.orderbooks.keys())
},
'final_state': {
symbol: book.get_orderbook_snapshot()
for symbol, book in self.orderbooks.items()
}
}
Example: Replay with real-time callbacks
def on_update_callback(symbol: str, book: Orderbook, update: OrderbookUpdate):
"""Example callback for processing updates in real-time"""
spread = book.get_spread()
if spread[0] and spread[1]:
spread_bps = float((spread[1] - spread[0]) / spread[0]) * 10000
# Could send to analytics, storage, or trading system
# print(f"[{update.timestamp}] {symbol} spread: {spread_bps:.2f} bps")
def on_trade_callback(symbol: str, trade: dict):
"""Example callback for processing trades"""
# Could update position tracking, PnL calculation, etc.
pass
Usage example
if __name__ == "__main__":
config = HistoricalDataConfig(
data_path=Path("./data/orderbook_updates.jsonl.gz"),
start_time=None, # From beginning
end_time=None, # To end
symbols=["BTC/USDT", "ETH/USDT"],
replay_speed=0, # Instant replay for backtesting
buffer_size=50000
)
engine = OrderbookReplayEngine(config)
# Replay data file
data_file = Path("./data/2024_01_15_orderbook_updates.jsonl.gz")
orderbooks = engine.replay(
data_file,
on_update=on_update_callback,
on_trade=on_trade_callback
)
# Get summary
summary = engine.export_replay_summary()
print(json.dumps(summary, indent=2, default=str))
Simulated Matching Engine สำหรับ Backtest
เมื่อมี Orderbook Replay แล้ว ต่อไปคือ Simulated Matching Engine ที่จะจับคู่คำสั่งซื้อ-ขายแบบเหมือนจริง พร้อมคำนวณ Fill Price, Slippage และ Market Impact:
from typing import Dict, List, Tuple, Optional
from dataclasses import dataclass, field
from decimal import Decimal
import numpy as np
from enum import Enum
class FillType(Enum):
TAKER = "TAKER" # Aggressive order that immediately matches
MAKER = "MAKER" # Passive order that sits in book
PARTIAL = "PARTIAL" # Partially filled
@dataclass
class Fill:
"""Record of a filled order"""
order_id: str
symbol: str
side: OrderSide
price: Decimal
quantity: Decimal
fill_type: FillType
fees: Decimal
slippage_bps: Decimal # Basis points slippage from mid price
timestamp: int
market_impact_bps: Decimal = Decimal("0")
@dataclass
class BacktestConfig:
"""Configuration for backtest simulation"""
initial_capital: Decimal
maker_fee: Decimal = Decimal("0.001") # 0.1%
taker_fee: Decimal = Decimal("0.002") # 0.2%
slippage_model: str = "fixed" # "fixed", "volume_based", "realistic"
fixed_slippage_bps: Decimal = Decimal("1") # 1 basis point
max_slippage_bps: Decimal = Decimal("50") # Max 50 bps
market_impact_factor: Decimal = Decimal("0.0001") # Impact per unit volume
# For volume-based slippage
volume_bins: List[float] = field(default_factory=lambda: [0, 100, 1000, 10000, 100000])
slippage_per_bin: List[float] = field(default_factory=lambda: [0.5, 1, 2, 5, 10])
class SimulatedMatchingEngine:
"""Matching engine that simulates fills for backtesting"""
def __init__(self, orderbook_engine: OrderbookReplayEngine, config: BacktestConfig):
self.orderbook_engine = orderbook_engine
self.config = config
self.positions: Dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
self.cash: Decimal = config.initial_capital
self.equity_curve: List[dict] = []
self.fills: List[Fill] = []
self.pending_orders: Dict[str, Order] = {}
self.equity_by_symbol: Dict[str, Decimal] = defaultdict(lambda: Decimal("0"))
def submit_order(self, order: Order) -> List[Fill]:
"""Submit order and simulate fill against orderbook"""
if order.symbol not in self.orderbook_engine.orderbooks:
print(f"Warning: Symbol {order.symbol} not found in orderbook data")
return []
book = self.orderbook_engine.orderbooks[order.symbol]
snapshot = book.get_orderbook_snapshot()
fills = []
if order.side == OrderSide.BUY:
# Buy order matches against asks
best_ask, _ = book.get_spread()
if best_ask is None:
return []
# Determine fill price based on slippage model
fill_price = self._calculate_fill_price(
order