ผมเคยเผชิญปัญหานี้ด้วยตัวเองเมื่อต้องดาวน์โหลดข้อมูลแท่งเทียน (candlestick) ย้อนหลัง 3 ปีของออปชั่น BTC-USD กว่า 4,200 สัญญา เพื่อสร้างโมเดล implied volatility surface ผลลปรากฏว่าโค้ดที่ใช้ requests แบบเธรดเดียวใช้เวลานานเกือบ 18 ชั่วโมง และถูกบล็อก IP กลางทางจากการละเมิด rate limit ของ OKX หลังจากปรับสถาปัตยกรรมใหม่ทั้งหมด เวลาลดลงเหลือ 47 นาที โดยไม่โดนแบนแม้แต่ครั้งเดียว บทความนี้จะแชร์เทคนิคระดับ production ทั้งหมด ตั้งแต่การวิเคราะห์ rate limit, การออกแบบ token bucket, การใช้ semaphore ควบคุม concurrency, ไปจนถึงการ resume หลัง network failure และการผสานรวมกับ HolySheep AI เพื่อวิเคราะห์ข้อมูลที่ดาวน์โหลดมา
ทำไมข้อมูลย้อนหลังออปชั่น OKX ถึงสำคัญ
ตลาดออปชั่นคริปโตของ OKX เป็นหนึ่งในตลาดที่มีสภาพคล่องสูงที่สุดในโลก ข้อมูลย้อนหลังมีมูลค่ามหาศาลสำหรับงานวิจัยเชิงปริมาณ ได้แก่
- Volatility Surface Construction — สร้างพื้นผิว implied volatility ตาม strike และ expiry เพื่อทำนายความผันผวน
- Greeks Backtesting — ทดสอบกลยุทธ์ delta-hedge, gamma-scalping ด้วยข้อมูลจริง
- Options Pricing Model Validation — ตรวจสอบความแม่นยำของ Black-Scholes, Heston, SABR
- Market Microstructure — วิเคราะห์พฤติกรรม bid-ask spread และ order flow
อย่างไรก็ตาม ออปชั่นมีจำนวน instrument มหาศาล ตัวอย่างเช่น BTC-USD มี expiry ทุกวันศุกร์, ทุกวันที่ 1, ทุกวันศุกร์สุดท้ายของเดือน และทุกวันศุกร์สุดท้ายของไตรมาส ตัวเลขรวม strike ทั้งหมดอาจเกิน 10,000 instruments ต่อหนึ่ง underlying ทำให้การดาวน์โหลดแบบขนานเป็นความจำเป็น ไม่ใช่ทางเลือก
สถาปัตยกรรม API ของ OKX และข้อจำกัดด้านอัตรา
OKX V5 API แบ่ง endpoint ออกเป็น 2 กลุ่มหลักที่เกี่ยวข้องกับการดึงข้อมูลออปชั่น
| Endpoint | วัตถุประสงค์ | Rate Limit | Rate Limit ต่อ IP |
|---|---|---|---|
| GET /api/v5/public/instruments | ดึงรายการ option instruments | 20 req / 2s | ใช่ |
| GET /api/v5/market/history-candles | แท่งเทียนย้อนหลัง | 20 req / 2s | ใช่ |
| GET /api/v5/market/option/instruments | ข้อมูลละเอียดของ option | 20 req / 2s | ใช่ |
| GET /api/v5/market/option-trades | ประวัติการซื้อขาย | 10 req / 2s | ใช่ |
| GET /api/v5/market/tickers | ราคาปัจจุบัน | 20 req / 2s | ใช่ |
จุดสำคัญที่ต้องเข้าใจคือ rate limit ของ OKX เป็นแบบ sliding window 2 วินาที ไม่ใช่ fixed bucket หากคุณส่ง 20 request ใน 0.5 วินาที ระบบจะคืน HTTP 429 ทันที นอกจากนี้ OKX ยังมี daily quota สำหรับบาง endpoint ขั้นสูง ดังนั้นการออกแบบ rate limiter จึงต้องสมดุลระหว่าง throughput กับความปลอดภัย
โค้ดระดับ Production: Token Bucket + Semaphore + Retry
โค้ดด้านล่างนี้เป็น production-grade downloader ที่ผมใช้งานจริง มีการผสมผสานเทคนิค 3 ชั้น ได้แก่ Token Bucket สำหรับ rate limit, Semaphore สำหรับ concurrency control, และ Exponential Backoff สำหรับ retry
"""
OKX Options Historical Data Downloader
รองรับการดาวน์โหลดแบบ batch, resume, และ rate-limit aware
ทดสอบบน Python 3.11+, aiohttp 3.9+
"""
import asyncio
import aiohttp
import time
import json
import os
from pathlib import Path
from dataclasses import dataclass, field
from typing import Optional, Dict, List
import pandas as pd
from datetime import datetime, timezone
============= Configuration =============
@dataclass
class OKXConfig:
base_url: str = "https://www.okx.com"
rate_per_2s: int = 18 # เผื่อ buffer 20% จาก 20
refill_per_sec: float = 9.0 # 18 / 2 = 9 tokens ต่อวินาที
max_concurrent: int = 8 # concurrent requests
max_retries: int = 6
retry_base_delay: float = 0.5
checkpoint_dir: str = "./checkpoint"
output_dir: str = "./data"
request_timeout: int = 30
============= Token Bucket =============
class AsyncTokenBucket:
"""Token bucket แบบ async ที่ปล่อย token อย่าง smooth"""
def __init__(self, capacity: int, refill_rate: float):
self.capacity = capacity
self.tokens = float(capacity)
self.refill_rate = refill_rate
self.last = time.monotonic()
self._lock = asyncio.Lock()
async def acquire(self, tokens: float = 1.0):
async with self._lock:
while True:
now = time.monotonic()
elapsed = now - self.last
self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
self.last = now
if self.tokens >= tokens:
self.tokens -= tokens
return
deficit = tokens - self.tokens
wait = deficit / self.refill_rate
await asyncio.sleep(wait)
============= Main Downloader =============
class OKXOptionsDownloader:
def __init__(self, config: OKXConfig):
self.cfg = config
self.bucket = AsyncTokenBucket(config.rate_per_2s, config.refill_per_sec)
self.sem = asyncio.Semaphore(config.max_concurrent)
Path(config.checkpoint_dir).mkdir(parents=True, exist_ok=True)
Path(config.output_dir).mkdir(parents=True, exist_ok=True)
# Metrics
self.metrics = {
"requests": 0,
"errors": 0,
"retries": 0,
"rate_limited": 0,
"latencies_ms": [],
"started": datetime.now(timezone.utc).isoformat()
}
def _checkpoint_path(self, key: str) -> Path:
safe = key.replace("/", "_").replace(":", "_")
return Path(self.cfg.checkpoint_dir) / f"{safe}.json"
def _load_checkpoint(self, key: str) -> Dict:
p = self._checkpoint_path(key)
if p.exists():
return json.loads(p.read_text())
return {"cursor": "", "count": 0, "completed": False}
def _save_checkpoint(self, key: str, state: Dict):
self._checkpoint_path(key).write_text(json.dumps(state))
async def _request(self, session: aiohttp.ClientSession,
endpoint: str, params: Dict) -> Dict:
"""Single API call with rate-limit + retry + metrics"""
url = f"{self.cfg.base_url}{endpoint}"
for attempt in range(self.cfg.max_retries):
async with self.sem:
await self.bucket.acquire(1.0)
t0 = time.perf_counter()
try:
async with session.get(
url, params=params,
timeout=aiohttp.ClientTimeout(total=self.cfg.request_timeout)
) as resp:
# บันทึก latency
latency_ms = (time.perf_counter() - t0) * 1000
self.metrics["latencies_ms"].append(latency_ms)
self.metrics["requests"] += 1
# Handle rate limit
if resp.status == 429:
self.metrics["rate_limited"] += 1
retry_after = float(resp.headers.get("Retry-After", 2))
await asyncio.sleep(retry_after)
continue
body = await resp.json()
# OKX-specific error codes
code = body.get("code", "0")
if code == "50011": # too many requests
self.metrics["rate_limited"] += 1
await asyncio.sleep(2 ** attempt)
self.metrics["retries"] += 1
continue
if code != "0":
raise RuntimeError(f"OKX error {code}: {body.get('msg')}")
return body
except (aiohttp.ClientError, asyncio.TimeoutError) as e:
self.metrics["errors"] += 1
self.metrics["retries"] += 1
if attempt == self.cfg.max_retries - 1:
raise
delay = self.cfg.retry_base_delay * (2 ** attempt)
await asyncio.sleep(delay)
raise RuntimeError(f"Failed after {self.cfg.max_retries} attempts")
async def list_options(self, session, underlying: str = "BTC-USD") -> List[str]:
"""ดึงรายการ option instruments ทั้งหมด"""
body = await self._request(session, "/api/v5/public/instruments", {
"instType": "OPTION",
"uly": underlying
})
return [item["instId"] for item in body["data"]]
async def download_one(self, session, inst_id: str, bar: str = "1m"):
"""ดาวน์โหลดข้อมูลทั้งหมดของ 1 instrument"""
ckpt = self._load_checkpoint(inst_id)
if ckpt["completed"]:
return ckpt["count"]
all_rows: List[list] = []
after = ckpt["cursor"]
batch_count = 0
while True:
params = {"instId": inst_id, "bar": bar, "limit": "100"}
if after:
params["after"] = after
body = await self._request(
session, "/api/v5/market/history-candles", params
)
batch = body["data"]
if not batch:
break
all_rows.extend(batch)
after = batch[-1][0] # ts ของแท่งเก่าสุด
batch_count += 1
# save checkpoint ทุก batch
self._save_checkpoint(inst_id, {
"cursor": after,
"count": len(all_rows),
"completed": False
})
if len(batch) < 100:
break
# แปลงเป็น DataFrame แล้วบันทึก
df = pd.DataFrame(all_rows, columns=[
"ts", "open", "high", "low", "close",
"vol", "volCcy", "volCcyQuote", "confirm"
])
df["ts"] = pd.to_datetime(df["ts"].astype(int), unit="ms")
out_path = Path(self.cfg.output_dir) / f"{inst_id}_{bar}.parquet"
df.to_parquet(out_path, compression="snappy")
self._save_checkpoint(inst_id, {
"cursor": after,
"count": len(all_rows),
"completed": True
})
return len(all_rows)
============= Entry Point =============
async def main():
cfg = OKXConfig(max_concurrent=8)
dl = OKXOptionsDownloader(cfg)
async with aiohttp.ClientSession(
connector=aiohttp.TCPConnector(limit=20, ttl_dns_cache=300),
headers={"User-Agent": "okx-options-loader/1.0"}
) as session:
# 1) ดึงรายการ instruments
inst_ids = await dl.list_options(session, "BTC-USD")
print(f"พบ option instruments: {len(inst_ids)} ตัว")
# 2) ดาวน์โหลดแบบ concurrent
t0 = time.time()
results = await asyncio.gather(
*[dl.download_one(session, iid, "1m") for iid in inst_ids],
return_exceptions=True
)
elapsed = time.time() - t0
# 3) รายงานผล
ok = sum(1 for r in results if isinstance(r, int))
print(f"\n=== สรุป ===")
print(f"สำเร็จ: {ok}/{len(inst_ids)}")
print(f"เวลา: {elapsed/60:.2f} นาที")
print(f"Throughput: {sum(r for r in results if isinstance(r, int))/elapsed:.1f} records/s")
if dl.metrics["latencies_ms"]:
lat = dl.metrics["latencies_ms"]
print(f"P50 latency: {sorted(lat)[len(lat)//2]:.1f} ms")
print(f"P99 latency: {sorted(lat)[int(len(lat)*0.99)]:.1f} ms")
if __name__ == "__main__":
asyncio.run(main())
Benchmark จริง: เปรียบเทียบ 4 กลยุทธ์
ผมทดสอบดาวน์โหลดข้อมูล BTC-USD options 1-minute candles ย้อนหลัง 90 วัน จำนวน 100 instruments บนเครื่อง MacBook Pro M2, network latency 47 ms ได้ผลลัพธ์ดังนี้
| กลยุทธ์ | Concurrency | เวลา (วินาที) | Throughput (rec/s) | จำนวน 429 | Success Rate |
|---|---|---|---|---|---|
| Sequential (requests) | 1 | 1,847 | 5.4 | 0 | 100% |
| ThreadPool (no rate limit) | 20 | 312 | 32.1 | 487 | 72% |
| Semaphore only | 8 | 418 | 23.9 | 12 | 98.5% |
| TokenBucket + Semaphore | 8 | 486 | 20.6 | 0 | 100% |
| TokenBucket + Semaphore + Resume | 8 | 471 (รวม 2 crash) | 21.2
แหล่งข้อมูลที่เกี่ยวข้องบทความที่เกี่ยวข้อง🔥 ลอง HolySheep AIเกตเวย์ AI API โดยตรง รองรับ Claude, GPT-5, Gemini, DeepSeek — หนึ่งคีย์ ไม่ต้อง VPN |