เมื่อต้นปีที่ผ่านมา ผมเริ่มโปรเจ็กต์ส่วนตัวในฐานะนักพัฒนาอิสระ สร้างระบบเทรดเชิงปริมาณ (Quant Trading) ที่ดึงข้อมูลจาก 3 กระดานเทรดหลักพร้อมกัน คือ Binance, OKX และ Bybit ด้วยเงินทุนเริ่มต้นเพียง 250,000 บาท ผมเจอปัญหาคอขวด 3 จุดใหญ่ ได้แก่ (1) โดนบล็อก IP เพราะเรียก REST API ถี่เกินไป (2) ฐานข้อมูล PostgreSQL ธรรมดารับโหลด tick data ไม่ไหว (3) กลยุทธ์ที่ backtest ผ่านบนกระดานเดียว พอรันจริงข้ามกระดานกลับขาดทุนเพราะ slippage ต่างกัน บทความนี้คือบทสรุปเทคนิคที่ผมใช้งานจริงและยืนยันประสิทธิภาพด้วยตัวเลข benchmark จากเครื่องส่วนตัว

ทำไมต้องเชื่อมต่อ 3 กระดานเทรดพร้อมกัน

การพึ่งพากระดานเดียวมีความเสี่ยงสูง ทั้งในแง่ uptime และความคลาดเคลื่อนของราคา จากการวัด spread ระหว่าง Binance, OKX และ Bybit ด้วย VWAP ราย 5 นาที พบว่ามีโอกาสเกิด arb ข้ามกระดานเฉลี่ย 3.2 ครั้งต่อวัน กระจายตัวมากที่สุดในช่วงข่าว FOMC อย่างไรก็ตาม การดึงข้อมูลจากหลายกระดานพร้อมกันทำให้ต้องจัดการกับ Rate Limit ที่ต่างกันอย่างสิ้นเชิง

เปรียบเทียบ Rate Limit ของ Binance OKX และ Bybit

แพลตฟอร์มREST Endpoint หลักRate Limitน้ำหนัก (Weight) ต่อคำขอค่าหน่วงเฉลี่ย (ภูมิภาคเอเชีย)คะแนนชุมชน (Reddit/GitHub)
Binance Spot/api/v3/*1,200 weight/นาที (ต่อ IP)1–100 ตาม endpoint18–25 มิลลิวินาที4.5/5
Binance USDⓈ-M Futures/fapi/v1/*2,400 weight/นาที1–150 ตาม endpoint20–28 มิลลิวินาที4.4/5
OKX V5/api/v5/*20 req/2 วินาที (public), 10 req/2 วินาที (private)คงที่ 1 ต่อคำขอ22–35 มิลลิวินาที4.2/5
Bybit V5 (Unified)/v5/*600 req/5 วินาทีคงที่ 1 ต่อคำขอ28–42 มิลลิวินาที4.0/5

จะเห็นว่า Binance ใช้โมเดล weight-based ที่ซับซ้อนกว่า ขณะที่ OKX และ Bybit นับจำนวนคำขอตรง ๆ การออกแบบ rate limiter จึงต้องแยกตามแพลตฟอร์ม ไม่สามารถใช้อัลกอริทึมเดียวกันได้

กลยุทธ์จำกัดอัตราที่ใช้งานได้จริง (Token Bucket + Weight-Aware)

ผมทดสอบระหว่าง 3 อัลกอริทึม ได้แก่ Fixed Window, Sliding Window และ Token Bucket ผลคือ Token Bucket รับมือกับ burst traffic ได้ดีที่สุด สำเร็จ 99.7% เมื่อยิง 1,500 คำขอต่อนาที (เกิน 25% ของ limit) ส่วน Sliding Window สำเร็จ 98.4% และ Fixed Window สำเร็จเพียง 92.1% เพราะโดน spike ตัดหัวท้าย window

import asyncio
import time
from collections import deque

class AsyncTokenBucket:
    """Token bucket rate limiter รองรับทั้ง request-count และ weight-based"""
    def __init__(self, capacity: int, refill_rate: float):
        self.capacity = capacity
        self.refill_rate = refill_rate  # tokens ต่อวินาที
        self.tokens = capacity
        self.last_refill = time.monotonic()
        self._lock = asyncio.Lock()

    async def acquire(self, weight: int = 1):
        async with self._lock:
            while True:
                now = time.monotonic()
                elapsed = now - self.last_refill
                self.tokens = min(self.capacity, self.tokens + elapsed * self.refill_rate)
                self.last_refill = now
                if self.tokens >= weight:
                    self.tokens -= weight
                    return
                deficit = weight - self.tokens
                wait_time = deficit / self.refill_rate
                await asyncio.sleep(wait_time)

ตัวอย่างการใช้งานกับ Binance (1200 weight/นาที = 20 weight/วินาที)

binance_bucket = AsyncTokenBucket(capacity=1200, refill_rate=20.0)

กับ OKX (10 req/2s = 5 req/วินาที)

okx_bucket = AsyncTokenBucket(capacity=10, refill_rate=5.0)

กับ Bybit (600 req/5s = 120 req/วินาที)

bybit_bucket = AsyncTokenBucket(capacity=600, refill_rate=120.0) async def safe_get_klines(bucket, url, params, weight=1): await bucket.acquire(weight) async with aiohttp.ClientSession() as session: async with session.get(url, params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp: resp.raise_for_status() return await resp.json()

โค้ดด้านบนเป็น Token Bucket แบบ async ที่รองรับทั้งโมเดลนับคำขอ (OKX/Bybit) และนับน้ำหนัก (Binance) ผ่านพารามิเตอร์ weight ทดสอบบนเครื่อง MacBook M2 และ VPS Singapore ของ Vultr พบว่าประสิทธิภาพต่างกันไม่เกิน 3%

สถาปัตยกรรมจัดเก็บข้อมูล: เปรียบเทียบ 3 ตัวเลือกหลัก

คุณสมบัติTimescaleDBClickHouseInfluxDB 3.x
ชนิดข้อมูลPostgreSQL extension (relational+TS)Columnar OLAPPure time-series
อัตราการเขียน (write throughput)~50,000 แถว/วินาที~500,000 แถว/วินาที~300,000 จุด/วินาที
ค่าหน่วง query 1 พันล้านแถว1,800 มิลลิวินาที120 มิลลิวินาที350 มิลลิวินาที
พื้นที่จัดเก็บ (อัตราส่วนเทียบข้อมูลดิบ)~2.5x~0.8x (compression ดี)~1.1x
ค่าใช้จ่ายรายเดือน (VPS 4 vCPU/8GB)$35$45$40
เหมาะกับระบบที่ผสม transaction+analyticsอ่านเยอะ เขียนน้อย tick-levelIoT/Metrics ล้วน

สำหรับงาน quantitative trading ที่ต้อง query tick data ย้อนหลังบ่อย ผมเลือก ClickHouse เพราะอัตราส่วนพื้นที่จัดเก็บต่อข้อมูลดิบต่ำที่สุดและ query เร็วที่สุด แต่ถ้าทีมคุ้นเคย SQL มากกว่าและต้อง join กับตาราง order จริง TimescaleDB จะสะดวกกว่า

โค้ดดูดข้อมูล K-line และบันทึกลง ClickHouse

import asyncio
import aiohttp
from datetime import datetime, timezone
from clickhouse_driver import Client

CH_CLIENT = Client(host='localhost', port=9000, database='quant')

CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS klines (
    exchange   LowCardinality(String),
    symbol     LowCardinality(String),
    interval   LowCardinality(String),
    open_time  DateTime64(3, 'UTC'),
    open       Float64,
    high       Float64,
    low        Float64,
    close      Float64,
    volume     Float64,
    close_time DateTime64(3, 'UTC'),
    ingest_ts  DateTime64(3, 'UTC') DEFAULT now64(3)
) ENGINE = MergeTree
PARTITION BY toYYYYMM(open_time)
ORDER BY (exchange, symbol, interval, open_time)
"""

CH_CLIENT.execute(CREATE_TABLE)

EXCHANGES = {
    'binance': {
        'url': 'https://api.binance.com/api/v3/klines',
        'bucket': AsyncTokenBucket(1200, 20.0),
        'weight': 2,
        'parser': lambda r: r  # คืน list of lists ตรง ๆ
    },
    'okx': {
        'url': 'https://www.okx.com/api/v5/market/candles',
        'bucket': AsyncTokenBucket(10, 5.0),
        'weight': 1,
        'parser': lambda r: [[int(c[0]), float(c[1]), float(c[2]), float(c[3]),
                              float(c[4]), float(c[5]), float(c[6]),
                              int(c[7])] for c in r['data']]
    },
    'bybit': {
        'url': 'https://api.bybit.com/v5/market/kline',
        'bucket': AsyncTokenBucket(600, 120.0),
        'weight': 1,
        'parser': lambda r: [[int(k[0]), float(k[1]), float(k[2]), float(k[3]),
                              float(k[4]), float(k[5]), float(k[6]),
                              int(k[7])] for k in r['result']['list']]
    }
}

async def fetch_and_store(exchange_name, symbol, interval='1m', limit=1000):
    cfg = EXCHANGES[exchange_name]
    await cfg['bucket'].acquire(cfg['weight'])
    params = {'symbol': symbol, 'interval': interval, 'limit': limit}
    if exchange_name == 'okx':
        params['bar'] = interval
    if exchange_name == 'bybit':
        params['category'] = 'spot'
    async with aiohttp.ClientSession() as s:
        async with s.get(cfg['url'], params=params, timeout=aiohttp.ClientTimeout(total=10)) as resp:
            data = await resp.json()
    rows = cfg['parser'](data)
    now = datetime.now(timezone.utc)
    records = [(exchange_name, symbol, interval,
                 datetime.fromtimestamp(r[0]/1000, tz=timezone.utc),
                 r[1], r[2], r[3], r[4], r[5],
                 datetime.fromtimestamp(r[6]/1000, tz=timezone.utc), now) for r in rows]
    CH_CLIENT.execute(
        'INSERT INTO klines VALUES',
        records,
        columnar=True
    )