| |
| """Live WebSocket capture for Coinbase + Kraken — cross-exchange depth. |
| |
| Records L2 order book snapshots from Coinbase and Kraken simultaneously, |
| so you can compute cross-exchange spreads (the Stage 4 Transformer signal). |
| |
| Usage: |
| # Record Coinbase BTC-USD depth for 2 hours |
| python scripts/capture_cross_exchange.py --exchange coinbase --hours 2 |
| |
| # Record Kraken XBT-USD depth for 30 minutes |
| python scripts/capture_cross_exchange.py --exchange kraken --minutes 30 |
| |
| # Record both exchanges simultaneously |
| python scripts/capture_cross_exchange.py --exchange both --hours 2 |
| """ |
| import argparse |
| import asyncio |
| import json |
| import logging |
| import signal |
| import time |
| from datetime import datetime |
| from pathlib import Path |
|
|
| import pandas as pd |
|
|
| logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") |
| logger = logging.getLogger(__name__) |
|
|
|
|
| class CoinbaseDepthRecorder: |
| """Records L2 depth from Coinbase Advanced Trade WebSocket. |
| |
| Coinbase uses a different protocol — subscribes to level2_batch channel. |
| """ |
|
|
| WS_URL = "wss://ws-feed.exchange.coinbase.com" |
|
|
| def __init__(self, product_ids: list[str], out_path: Path) -> None: |
| self.product_ids = product_ids |
| self.out_path = out_path |
| self.records: list[dict] = [] |
| self._running = False |
| self._start_time = 0.0 |
| self._last_save = 0.0 |
|
|
| async def run(self, duration_seconds: float) -> None: |
| self._running = True |
| self._start_time = time.time() |
|
|
| def signal_handler(sig, frame): |
| self._running = False |
| signal.signal(signal.SIGINT, signal_handler) |
|
|
| logger.info("=" * 70) |
| logger.info(" COINBASE DEPTH CAPTURE") |
| logger.info(" Products: %s", ", ".join(self.product_ids)) |
| logger.info(" Duration: %.0f seconds", duration_seconds) |
| logger.info(" Output: %s", self.out_path) |
| logger.info("=" * 70) |
|
|
| import websockets |
|
|
| subscribe_msg = { |
| "type": "subscribe", |
| "product_ids": self.product_ids, |
| "channels": ["level2_batch"], |
| } |
|
|
| while self._running and (time.time() - self._start_time) < duration_seconds: |
| try: |
| |
| async with websockets.connect(self.WS_URL, ping_interval=10, max_size=16 * 1024 * 1024) as ws: |
| await ws.send(json.dumps(subscribe_msg)) |
| logger.info("Connected to Coinbase WebSocket. Recording...") |
|
|
| while self._running and (time.time() - self._start_time) < duration_seconds: |
| try: |
| raw = await asyncio.wait_for(ws.recv(), timeout=5.0) |
| msg = json.loads(raw) |
| self._handle_message(msg) |
| except asyncio.TimeoutError: |
| pass |
|
|
| if time.time() - self._last_save > 60: |
| self._save() |
| elapsed = time.time() - self._start_time |
| rate = len(self.records) / max(1, elapsed) |
| logger.info(" %d records (%.1f rec/sec, %.0fs elapsed)", |
| len(self.records), rate, elapsed) |
| except Exception as e: |
| logger.error("Coinbase WebSocket error: %s — reconnecting in 2s...", e) |
| await asyncio.sleep(2) |
|
|
| self._save() |
| logger.info("Capture complete: %d records -> %s", len(self.records), self.out_path) |
|
|
| def _handle_message(self, msg) -> None: |
| """Handle Coinbase level2_batch messages. |
| |
| Coinbase sends several message types: |
| - Subscriptions confirmations (dict with type='subscriptions') |
| - Heartbeats (dict with type='heartbeat') |
| - Tickers (dict with type='ticker') |
| - Level2 snapshots (dict with type='snapshot') |
| - Level2 updates (dict with type='l2update') |
| - Some messages come as lists (arrays) — skip those |
| |
| Defensive: every .get() call is preceded by an isinstance check. |
| """ |
| |
| if not isinstance(msg, dict): |
| return |
|
|
| msg_type = msg.get("type") |
| ts = int(time.time() * 1000) |
| product = msg.get("product_id", "") |
|
|
| |
| if msg_type not in ("snapshot", "l2update"): |
| return |
|
|
| try: |
| if msg_type == "snapshot": |
| |
| |
| |
| bids = msg.get("bids", [])[:10] |
| asks = msg.get("asks", [])[:10] |
| self._record_snapshot(ts, product, bids, asks) |
| elif msg_type == "l2update": |
| |
| |
| |
| |
| changes = msg.get("changes", []) |
| bids = [] |
| asks = [] |
| for change in changes: |
| if not isinstance(change, (list, tuple)) or len(change) < 3: |
| continue |
| side, price, size = change[0], change[1], change[2] |
| if side == "buy": |
| bids.append([price, size]) |
| elif side == "sell": |
| asks.append([price, size]) |
| bids = bids[:10] |
| asks = asks[:10] |
| if bids or asks: |
| self._record_snapshot(ts, product, bids, asks) |
| except Exception as e: |
| logger.debug("Handle message error: %s", e) |
|
|
| def _record_snapshot(self, ts: int, product: str, bids: list, asks: list) -> None: |
| try: |
| best_bid = float(bids[0][0]) if bids else 0.0 |
| best_ask = float(asks[0][0]) if asks else 0.0 |
| bid_depth_10 = sum(float(b[1]) for b in bids[:10]) |
| ask_depth_10 = sum(float(a[1]) for a in asks[:10]) |
| obi = (bid_depth_10 - ask_depth_10) / (bid_depth_10 + ask_depth_10) if (bid_depth_10 + ask_depth_10) > 0 else 0.0 |
| mid = (best_bid + best_ask) / 2.0 if best_bid and best_ask else 0.0 |
| spread_bps = ((best_ask - best_bid) / mid * 10_000) if mid > 0 else 0.0 |
|
|
| self.records.append({ |
| "timestamp_ms": ts, |
| "exchange": "coinbase", |
| "symbol": product, |
| "best_bid": best_bid, |
| "best_ask": best_ask, |
| "bid_depth_10": bid_depth_10, |
| "ask_depth_10": ask_depth_10, |
| "obi_10": obi, |
| "mid_price": mid, |
| "spread_bps": spread_bps, |
| }) |
| except (ValueError, IndexError, TypeError): |
| pass |
|
|
| def _save(self) -> None: |
| if not self.records: |
| return |
| self.out_path.parent.mkdir(parents=True, exist_ok=True) |
| df = pd.DataFrame(self.records) |
| df.to_parquet(self.out_path, index=False) |
| self._last_save = time.time() |
|
|
|
|
| class KrakenDepthRecorder: |
| """Records L2 depth from Kraken WebSocket. |
| |
| Kraken uses the 'book' channel with depth parameter. |
| """ |
|
|
| WS_URL = "wss://ws.kraken.com" |
|
|
| def __init__(self, pairs: list[str], out_path: Path) -> None: |
| self.pairs = pairs |
| self.out_path = out_path |
| self.records: list[dict] = [] |
| self._running = False |
| self._start_time = 0.0 |
| self._last_save = 0.0 |
|
|
| async def run(self, duration_seconds: float) -> None: |
| self._running = True |
| self._start_time = time.time() |
|
|
| def signal_handler(sig, frame): |
| self._running = False |
| signal.signal(signal.SIGINT, signal_handler) |
|
|
| logger.info("=" * 70) |
| logger.info(" KRAKEN DEPTH CAPTURE") |
| logger.info(" Pairs: %s", ", ".join(self.pairs)) |
| logger.info(" Duration: %.0f seconds", duration_seconds) |
| logger.info(" Output: %s", self.out_path) |
| logger.info("=" * 70) |
|
|
| import websockets |
|
|
| |
| subscribe_msg = { |
| "event": "subscribe", |
| "pair": self.pairs, |
| "subscription": {"name": "book", "depth": 10}, |
| } |
|
|
| while self._running and (time.time() - self._start_time) < duration_seconds: |
| try: |
| |
| async with websockets.connect( |
| self.WS_URL, |
| ping_interval=30, |
| ping_timeout=60, |
| max_size=16 * 1024 * 1024, |
| close_timeout=10, |
| ) as ws: |
| await ws.send(json.dumps(subscribe_msg)) |
| logger.info("Connected to Kraken WebSocket. Recording...") |
|
|
| while self._running and (time.time() - self._start_time) < duration_seconds: |
| try: |
| raw = await asyncio.wait_for(ws.recv(), timeout=5.0) |
| self._handle_message(raw) |
| except asyncio.TimeoutError: |
| pass |
|
|
| if time.time() - self._last_save > 60: |
| self._save() |
| elapsed = time.time() - self._start_time |
| rate = len(self.records) / max(1, elapsed) |
| logger.info(" %d records (%.1f rec/sec, %.0fs elapsed)", |
| len(self.records), rate, elapsed) |
| except Exception as e: |
| logger.error("Kraken WebSocket error: %s — reconnecting in 2s...", e) |
| await asyncio.sleep(2) |
|
|
| self._save() |
| logger.info("Capture complete: %d records -> %s", len(self.records), self.out_path) |
|
|
| def _handle_message(self, raw: str) -> None: |
| """Handle Kraken book messages (array format).""" |
| try: |
| msg = json.loads(raw) |
| if not isinstance(msg, list) or len(msg) < 4: |
| return |
| |
| _, book_data, _, pair = msg[0], msg[1], msg[2], msg[-1] |
| ts = int(time.time() * 1000) |
|
|
| |
| bids = book_data.get("bs") or book_data.get("b") or [] |
| asks = book_data.get("as") or book_data.get("a") or [] |
|
|
| if not bids or not asks: |
| return |
|
|
| best_bid = float(bids[0][0]) |
| best_ask = float(asks[0][0]) |
| bid_depth_10 = sum(float(b[1]) for b in bids[:10]) |
| ask_depth_10 = sum(float(a[1]) for a in asks[:10]) |
| obi = (bid_depth_10 - ask_depth_10) / (bid_depth_10 + ask_depth_10) if (bid_depth_10 + ask_depth_10) > 0 else 0.0 |
| mid = (best_bid + best_ask) / 2.0 |
| spread_bps = ((best_ask - best_bid) / mid * 10_000) if mid > 0 else 0.0 |
|
|
| self.records.append({ |
| "timestamp_ms": ts, |
| "exchange": "kraken", |
| "symbol": pair, |
| "best_bid": best_bid, |
| "best_ask": best_ask, |
| "bid_depth_10": bid_depth_10, |
| "ask_depth_10": ask_depth_10, |
| "obi_10": obi, |
| "mid_price": mid, |
| "spread_bps": spread_bps, |
| }) |
| except Exception: |
| pass |
|
|
| def _save(self) -> None: |
| if not self.records: |
| return |
| self.out_path.parent.mkdir(parents=True, exist_ok=True) |
| df = pd.DataFrame(self.records) |
| df.to_parquet(self.out_path, index=False) |
| self._last_save = time.time() |
|
|
|
|
| async def run_both(duration: float, out_dir: Path) -> None: |
| """Record both Coinbase and Kraken simultaneously.""" |
| ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") |
| coinbase_recorder = CoinbaseDepthRecorder( |
| ["BTC-USD", "ETH-USD"], |
| out_dir / f"coinbase_depth_{ts_str}.parquet", |
| ) |
| kraken_recorder = KrakenDepthRecorder( |
| ["XBT/USD", "ETH/USD"], |
| out_dir / f"kraken_depth_{ts_str}.parquet", |
| ) |
| await asyncio.gather( |
| coinbase_recorder.run(duration), |
| kraken_recorder.run(duration), |
| ) |
|
|
|
|
| def main() -> int: |
| parser = argparse.ArgumentParser(description="Capture cross-exchange depth data") |
| parser.add_argument("--exchange", default="coinbase", choices=["coinbase", "kraken", "both"]) |
| parser.add_argument("--hours", type=float, default=2.0) |
| parser.add_argument("--minutes", type=float, default=0) |
| parser.add_argument("--out", default=None) |
| args = parser.parse_args() |
|
|
| if args.minutes > 0: |
| duration = args.minutes * 60 |
| elif args.hours > 0: |
| duration = args.hours * 3600 |
| else: |
| duration = float("inf") |
|
|
| out_dir = Path(args.out) if args.out else Path("data") |
| out_dir.mkdir(parents=True, exist_ok=True) |
|
|
| if args.exchange == "both": |
| asyncio.run(run_both(duration, out_dir)) |
| elif args.exchange == "coinbase": |
| ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") |
| recorder = CoinbaseDepthRecorder( |
| ["BTC-USD", "ETH-USD"], |
| out_dir / f"coinbase_depth_{ts_str}.parquet", |
| ) |
| asyncio.run(recorder.run(duration)) |
| elif args.exchange == "kraken": |
| ts_str = datetime.now().strftime("%Y%m%d_%H%M%S") |
| recorder = KrakenDepthRecorder( |
| ["XBT/USD", "ETH/USD"], |
| out_dir / f"kraken_depth_{ts_str}.parquet", |
| ) |
| asyncio.run(recorder.run(duration)) |
| return 0 |
|
|
|
|
| if __name__ == "__main__": |
| raise SystemExit(main()) |
|
|