Buckets:
| #!/usr/bin/env python3 | |
| """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: | |
| # max_size=16MB — Coinbase level2_batch can be large | |
| 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. | |
| """ | |
| # Skip non-dict messages (Coinbase sometimes sends arrays) | |
| if not isinstance(msg, dict): | |
| return | |
| msg_type = msg.get("type") | |
| ts = int(time.time() * 1000) | |
| product = msg.get("product_id", "") | |
| # Only process level2 data — skip subscriptions, heartbeats, tickers | |
| if msg_type not in ("snapshot", "l2update"): | |
| return | |
| try: | |
| if msg_type == "snapshot": | |
| # Initial full book snapshot | |
| # Format: {"type": "snapshot", "product_id": "BTC-USD", | |
| # "bids": [["price", "size"], ...], "asks": [["price", "size"], ...]} | |
| bids = msg.get("bids", [])[:10] | |
| asks = msg.get("asks", [])[:10] | |
| self._record_snapshot(ts, product, bids, asks) | |
| elif msg_type == "l2update": | |
| # Incremental update | |
| # Format: {"type": "l2update", "product_id": "BTC-USD", | |
| # "changes": [["side", "price", "size"], ...]} | |
| # NOTE: changes is a list of LISTS, not dicts! | |
| 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 | |
| # Kraken subscription format | |
| subscribe_msg = { | |
| "event": "subscribe", | |
| "pair": self.pairs, | |
| "subscription": {"name": "book", "depth": 10}, | |
| } | |
| while self._running and (time.time() - self._start_time) < duration_seconds: | |
| try: | |
| # max_size=16MB + longer ping interval for Kraken | |
| 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 | |
| # Kraken book format: [channelID, [bids], [asks], pair] | |
| _, book_data, _, pair = msg[0], msg[1], msg[2], msg[-1] | |
| ts = int(time.time() * 1000) | |
| # book_data can be {"bs": [...], "as": [...]} or {"b": [...], "a": [...]} | |
| 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()) | |
Xet Storage Details
- Size:
- 14.2 kB
- Xet hash:
- cbbc7ab0d2861c10974f2fd9da672345539fd23af0321ba5807195155e7860dd
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.