Khi thị trường crypto ngày càng cạnh tranh, việc kết nối OKX API v5 một cách ổn định và hiệu quả trở thành yếu tố sống còn cho các nhà phát triển trading bot, data analyst và platform giao dịch. Bài viết này sẽ hướng dẫn chi tiết cách implement WebSocket multi-subscription, xử lý rate limiting thông minh và xây dựng hệ thống auto-reconnect bất khả chiến bại.
Kết luận ngắn: Nếu bạn cần tốc độ phản hồi dưới 50ms, chi phí API rẻ hơn 85% so với provider phương Tây, hãy cân nhắc đăng ký HolySheep AI — nền tảng hỗ trợ WeChat/Alipay với tín dụng miễn phí khi bắt đầu.
OKX API v5 có gì mới?
Phiên bản v5 của OKX mang đến nhiều cải tiến đáng chú ý:
- REST API endpoint mới:
https://www.okx.comvới cấu trúc path rõ ràng hơn - WebSocket v5: Hỗ trợ multiplexed connections — cho phép subscribe nhiều channel trên 1 connection
- Authentication mới: HMAC SHA256 với timestamp-based signature
- Rate limit chi tiết: Phân biệt rõ giữa REST và WebSocket limits
So sánh HolySheep với OKX API chính thức và đối thủ
| Tiêu chí | OKX API chính thức | HolySheep AI | AWS API Gateway |
|---|---|---|---|
| Giá tham chiếu | Miễn phí (có giới hạn) | GPT-4.1: $8/MTok Claude Sonnet 4.5: $15/MTok DeepSeek V3.2: $0.42/MTok |
$3.50/MTok + data transfer |
| Độ trễ trung bình | 80-150ms (AP region) | <50ms (optimized routing) | 100-200ms |
| Thanh toán | Card quốc tế, Wire | WeChat, Alipay, USDT | Card quốc tế, AWS credits |
| Phương thức | REST + WebSocket | REST, Streaming, Webhook | REST only |
| Độ phủ mô hình | N/A (Exchange API) | OpenAI, Anthropic, Google, DeepSeek | Chỉ OpenAI |
| Nhóm phù hợp | Dev crypto trading | Dev cần chi phí thấp, thị trường AP | Enterprise scaling |
Phù hợp / không phù hợp với ai
✅ Nên dùng OKX API v5 khi:
- Bạn đang xây dựng trading bot hoặc arbitrage system trên OKX
- Cần real-time market data với độ trễ thấp
- Muốn tự động hóa giao dịch spot và futures
- Cần WebSocket cho việc subscribe nhiều cặp token cùng lúc
❌ Không nên dùng khi:
- Dự án của bạn yêu cầu mức giá API rẻ hơn 85% (chuyển sang HolySheep)
- Bạn cần AI integration đồng thời với trading API
- Thị trường mục tiêu là Trung Quốc/SEA (cân nhắc HolySheep với thanh toán WeChat/Alipay)
Giá và ROI
Với chiến lược multi-subscription hiệu quả trên OKX API v5, bạn có thể giảm đáng kể số lượng connections cần thiết:
| Package | Giá | Tính năng | Phù hợp |
|---|---|---|---|
| OKX Free Tier | $0 | 5 requests/2s, 20 subscription channels | Học tập, testing |
| OKX VIP 1 | $50/tháng | 20 requests/2s, 100 channels | Retail traders |
| HolySheep AI | Từ $8/MTok | <50ms, WeChat/Alipay, free credits | Dev thị trường AP |
Implement WebSocket Multi-Subscription OKX v5
Dưới đây là code implementation hoàn chỉnh với error handling và auto-reconnect:
// OKX WebSocket v5 Multi-Subscription với Auto-Reconnect
// File: okx_websocket_client.py
import asyncio
import json
import time
import hmac
import hashlib
import base64
import websockets
from typing import Dict, List, Optional, Callable
from datetime import datetime
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class OKXv5WebSocketClient:
"""
OKX API v5 WebSocket Client với:
- Multi-channel subscription trên 1 connection
- Automatic reconnection với exponential backoff
- Rate limit awareness
- Heartbeat management
"""
# OKX WebSocket endpoints
WS_PUBLIC_URL = "wss://ws.okx.com:8443/ws/v5/public"
WS_PRIVATE_URL = "wss://ws.okx.com:8443/ws/v5/private"
# Rate limits OKX v5
MAX_SUBSCRIPTIONS_PER_CONNECTION = 100
MAX_MESSAGES_PER_SECOND = 50
PING_INTERVAL = 20 # seconds
PING_TIMEOUT = 10 # seconds
def __init__(
self,
api_key: str = "",
secret_key: str = "",
passphrase: str = "",
testnet: bool = False
):
self.api_key = api_key
self.secret_key = secret_key
self.passphrase = passphrase
# Connection state
self.ws: Optional[websockets.WebSocketClientProtocol] = None
self.is_connected = False
self.subscriptions: Dict[str, dict] = {}
# Reconnection config
self.max_reconnect_attempts = 10
self.base_reconnect_delay = 1 # seconds
self.max_reconnect_delay = 60 # seconds
# Message handlers
self.handlers: Dict[str, List[Callable]] = {}
# Rate limiting
self.message_timestamps: List[float] = []
self.rate_limit_lock = asyncio.Lock()
def _get_sign(self, timestamp: str, method: str, path: str, body: str = "") -> str:
"""Generate HMAC SHA256 signature for OKX authentication"""
message = timestamp + method + path + body
mac = hmac.new(
self.secret_key.encode('utf-8'),
message.encode('utf-8'),
hashlib.sha256
)
return base64.b64encode(mac.digest()).decode('utf-8')
def _get_auth_params(self) -> dict:
"""Generate authentication parameters for private channels"""
timestamp = datetime.utcnow().isoformat() + 'Z'
signature = self._get_sign(timestamp, "GET", "/users/self/verify")
return {
"op": "login",
"args": [
{
"apiKey": self.api_key,
"passphrase": self.passphrase,
"timestamp": timestamp,
"sign": signature
}
]
}
async def _check_rate_limit(self):
"""Check and enforce rate limiting"""
async with self.rate_limit_lock:
current_time = time.time()
# Remove timestamps older than 1 second
self.message_timestamps = [
ts for ts in self.message_timestamps
if current_time - ts < 1
]
if len(self.message_timestamps) >= self.MAX_MESSAGES_PER_SECOND:
sleep_time = 1 - (current_time - self.message_timestamps[0])
if sleep_time > 0:
logger.warning(f"Rate limit reached, sleeping {sleep_time:.2f}s")
await asyncio.sleep(sleep_time)
self.message_timestamps.append(current_time)
async def connect(self, private: bool = False) -> bool:
"""Establish WebSocket connection"""
url = self.WS_PRIVATE_URL if private else self.WS_PUBLIC_URL
try:
self.ws = await websockets.connect(
url,
ping_interval=self.PING_INTERVAL,
ping_timeout=self.PING_TIMEOUT
)
self.is_connected = True
logger.info(f"Connected to OKX WebSocket: {url}")
# Authenticate if private channel
if private and self.api_key:
await self._authenticate()
return True
except Exception as e:
logger.error(f"Connection failed: {e}")
self.is_connected = False
return False
async def _authenticate(self):
"""Send authentication login"""
auth_params = self._get_auth_params()
await self.ws.send(json.dumps(auth_params))
response = await asyncio.wait_for(
self.ws.recv(),
timeout=10
)
data = json.loads(response)
if data.get("event") == "login" and data.get("code") == "0":
logger.info("Authentication successful")
else:
logger.error(f"Authentication failed: {data}")
raise Exception("OKX authentication failed")
async def subscribe(
self,
channel: str,
inst_id: str = None,
inst_type: str = "SPOT",
uly: str = None,
exp_time: str = None
):
"""
Subscribe to a channel using OKX v5 format
Supported channels: ticker, trades, books, candles, mark-price
"""
if len(self.subscriptions) >= self.MAX_SUBSCRIPTIONS_PER_CONNECTION:
logger.warning("Max subscriptions reached, consider reconnecting")
return False
# Build subscription args
args = [{"channel": channel, "instType": inst_type}]
if inst_id:
args[0]["instId"] = inst_id
if uly:
args[0]["uly"] = uly
if exp_time:
args[0]["expTime"] = exp_time
subscribe_msg = {
"op": "subscribe",
"args": args
}
await self._check_rate_limit()
await self.ws.send(json.dumps(subscribe_msg))
response = await asyncio.wait_for(self.ws.recv(), timeout=5)
data = json.loads(response)
if data.get("event") == "subscribe" and data.get("code") == "0":
sub_key = f"{channel}:{inst_id or uly}"
self.subscriptions[sub_key] = args[0]
logger.info(f"Subscribed to {channel} for {inst_id or uly}")
return True
else:
logger.error(f"Subscription failed: {data}")
return False
async def unsubscribe(self, channel: str, inst_id: str = None):
"""Unsubscribe from a channel"""
args = [{"channel": channel, "instId": inst_id}] if inst_id else [{"channel": channel}]
unsubscribe_msg = {
"op": "unsubscribe",
"args": args
}
await self._check_rate_limit()
await self.ws.send(json.dumps(unsubscribe_msg))
response = await asyncio.wait_for(self.ws.recv(), timeout=5)
data = json.loads(response)
if data.get("event") == "unsubscribe":
sub_key = f"{channel}:{inst_id}"
self.subscriptions.pop(sub_key, None)
logger.info(f"Unsubscribed from {channel} for {inst_id}")
return True
return False
def register_handler(self, channel: str, handler: Callable):
"""Register callback handler for a channel type"""
if channel not in self.handlers:
self.handlers[channel] = []
self.handlers[channel].append(handler)
async def listen(self):
"""Main listen loop with automatic reconnection"""
reconnect_attempts = 0
while True:
try:
if not self.is_connected:
if reconnect_attempts >= self.max_reconnect_attempts:
logger.error("Max reconnect attempts reached")
break
delay = min(
self.base_reconnect_delay * (2 ** reconnect_attempts),
self.max_reconnect_delay
)
logger.info(f"Reconnecting in {delay}s (attempt {reconnect_attempts + 1})")
await asyncio.sleep(delay)
if await self.connect():
# Re-subscribe all previous subscriptions
for sub_key, params in self.subscriptions.items():
channel = params["channel"]
inst_id = params.get("instId")
inst_type = params.get("instType")
await self.subscribe(channel, inst_id, inst_type)
reconnect_attempts = 0
else:
reconnect_attempts += 1
async for message in self.ws:
try:
data = json.loads(message)
await self._handle_message(data)
except json.JSONDecodeError:
logger.warning(f"Invalid JSON: {message}")
except websockets.ConnectionClosed as e:
logger.warning(f"Connection closed: {e}")
self.is_connected = False
reconnect_attempts += 1
except Exception as e:
logger.error(f"Listen error: {e}")
self.is_connected = False
reconnect_attempts += 1
async def _handle_message(self, data: dict):
"""Route incoming messages to appropriate handlers"""
# Handle ping-pong
if data.get("event") == "pong":
return
# Handle subscription confirmations
if data.get("event") == "subscribe":
return
# Route data messages
if "data" in data:
arg = data.get("arg", {})
channel = arg.get("channel")
if channel in self.handlers:
for handler in self.handlers[channel]:
await handler(data["data"])
async def close(self):
"""Gracefully close connection"""
if self.ws:
await self.ws.close()
self.is_connected = False
logger.info("WebSocket connection closed")
========== USAGE EXAMPLE ==========
async def handle_ticker(tickers: List[dict]):
"""Handle incoming ticker data"""
for ticker in tickers:
logger.info(
f"Ticker: {ticker['instId']} - "
f"Last: {ticker['last']} - "
f"Bid: {ticker['bidPx']} - "
f"Ask: {ticker['askPx']}"
)
async def handle_trades(trades: List[dict]):
"""Handle incoming trade data"""
for trade in trades:
logger.info(
f"Trade: {trade['instId']} - "
f"Side: {trade['side']} - "
f"Price: {trade['px']} - "
f"Size: {trade['sz']}"
)
async def main():
# Initialize client
client = OKXv5WebSocketClient()
# Register handlers
client.register_handler("ticker", handle_ticker)
client.register_handler("trades", handle_trades)
# Connect and subscribe
await client.connect(private=False)
# Subscribe to multiple channels on single connection (OKX v5 feature)
await client.subscribe("ticker", inst_id="BTC-USDT", inst_type="SPOT")
await client.subscribe("ticker", inst_id="ETH-USDT", inst_type="SPOT")
await client.subscribe("trades", inst_id="BTC-USDT", inst_type="SPOT")
await client.subscribe("books", inst_id="BTC-USDT", inst_type="SPOT")
# Start listening
await client.listen()
if __name__ == "__main__":
asyncio.run(main())
Implement Rate Limiter với Token Bucket Algorithm
OKX API v5 có rate limit phức tạp. Dưới đây là implementation rate limiter thông minh:
// OKX v5 Rate Limiter với Token Bucket + Exponential Backoff
// File: rate_limiter.ts
interface RateLimitConfig {
requestsPerSecond: number;
burstCapacity: number;
endpointMultipliers: Record;
}
interface TokenBucket {
tokens: number;
lastRefill: number;
}
class OKXv5RateLimiter {
private buckets: Map = new Map();
private config: RateLimitConfig;
private queue: Array<{
endpoint: string;
priority: number;
resolve: (value: any) => void;
reject: (error: Error) => void;
}> = [];
private processing = false;
// OKX v5 specific rate limits
private readonly LIMITS = {
// REST API limits (requests per second by endpoint type)
GET: { requestsPerSecond: 20, burstCapacity: 20 },
GET_MARKET: { requestsPerSecond: 20, burstCapacity: 40 },
GET_ACCOUNT: { requestsPerSecond: 10, burstCapacity: 10 },
POST: { requestsPerSecond: 10, burstCapacity: 10 },
POST_ORDER: { requestsPerSecond: 5, burstCapacity: 5 },
// WebSocket limits
WS_MESSAGES: { requestsPerSecond: 50, burstCapacity: 100 },
WS_SUBSCRIPTIONS: { requestsPerSecond: 10, burstCapacity: 20 },
// Error code penalties
ERROR_RATE_LIMIT: { penaltySeconds: 5, backoffMultiplier: 2 },
ERROR_429: { penaltySeconds: 30, backoffMultiplier: 3 },
ERROR_5XX: { penaltySeconds: 10, backoffMultiplier: 2 },
};
private backoffState: Map = new Map();
constructor() {
this.config = {
requestsPerSecond: 20,
burstCapacity: 20,
endpointMultipliers: {
'/api/v5/market': 1.5,
'/api/v5/account': 0.5,
'/api/v5/trade': 0.3,
}
};
// Initialize buckets
Object.keys(this.LIMITS).forEach(key => {
this.buckets.set(key, {
tokens: this.LIMITS[key as keyof typeof this.LIMITS].burstCapacity,
lastRefill: Date.now()
});
});
}
private getBucket(endpointType: string): TokenBucket {
if (!this.buckets.has(endpointType)) {
this.buckets.set(endpointType, {
tokens: 10,
lastRefill: Date.now()
});
}
return this.buckets.get(endpointType)!;
}
private refillBucket(bucket: TokenBucket, limit: { requestsPerSecond: number; burstCapacity: number }): void {
const now = Date.now();
const timePassed = (now - bucket.lastRefill) / 1000;
const tokensToAdd = timePassed * limit.requestsPerSecond;
bucket.tokens = Math.min(bucket.burstCapacity, bucket.tokens + tokensToAdd);
bucket.lastRefill = now;
}
private calculateEndpointType(method: string, path: string): string {
if (path.includes('/market')) return 'GET_MARKET';
if (path.includes('/account')) return 'GET_ACCOUNT';
if (path.includes('/trade') && method === 'POST') return 'POST_ORDER';
return method;
}
private checkBackoff(endpointType: string): boolean {
const state = this.backoffState.get(endpointType);
if (!state) return true;
if (Date.now() < state.nextRetry) {
return false;
}
// Reset backoff after successful request
this.backoffState.delete(endpointType);
return true;
}
private applyBackoff(endpointType: string, errorCode: string): void {
let penalty = this.LIMITS.ERROR_5XX.penaltySeconds;
let multiplier = this.LIMITS.ERROR_5XX.backoffMultiplier;
if (errorCode === '429' || errorCode.includes('rate_limit')) {
penalty = this.LIMITS.ERROR_429.penaltySeconds;
multiplier = this.LIMITS.ERROR_429.backoffMultiplier;
} else if (errorCode.startsWith('5')) {
penalty = this.LIMITS.ERROR_RATE_LIMIT.penaltySeconds;
multiplier = this.LIMITS.ERROR_RATE_LIMIT.backoffMultiplier;
}
const currentState = this.backoffState.get(endpointType) || { attempts: 0, nextRetry: 0 };
const newAttempts = currentState.attempts + 1;
const backoffTime = penalty * Math.pow(multiplier, newAttempts - 1);
this.backoffState.set(endpointType, {
attempts: newAttempts,
nextRetry: Date.now() + (backoffTime * 1000)
});
console.warn(
Rate limit applied to ${endpointType}: +
Attempt ${newAttempts}, retry in ${backoffTime}s
);
}
async acquire(
method: string,
path: string,
priority: number = 5
): Promise<{ waitTime: number; endpointType: string }> {
const endpointType = this.calculateEndpointType(method, path);
const limit = this.LIMITS[endpointType as keyof typeof this.LIMITS] || this.LIMITS.GET;
const bucket = this.getBucket(endpointType);
// Check backoff
if (!this.checkBackoff(endpointType)) {
const state = this.backoffState.get(endpointType)!;
const waitTime = (state.nextRetry - Date.now()) / 1000;
throw new Error(
Backoff active for ${endpointType}, retry in ${waitTime.toFixed(1)}s
);
}
// Refill bucket
this.refillBucket(bucket, limit);
// Calculate wait time if no tokens available
if (bucket.tokens < 1) {
const waitTime = (1 - bucket.tokens) / limit.requestsPerSecond;
return { waitTime, endpointType };
}
// Consume token
bucket.tokens -= 1;
return { waitTime: 0, endpointType };
}
async withRateLimit(
fn: () => Promise,
method: string,
path: string
): Promise {
let retries = 0;
const maxRetries = 5;
while (retries < maxRetries) {
try {
const { waitTime, endpointType } = await this.acquire(method, path);
if (waitTime > 0) {
console.log(Rate limit: waiting ${waitTime.toFixed(3)}s);
await this.delay(waitTime);
}
const result = await fn();
// Reset backoff on success
this.backoffState.delete(endpointType);
return result;
} catch (error: any) {
const errorCode = error.response?.data?.code || error.code;
if (errorCode === '429' || errorCode?.includes('rate_limit')) {
retries++;
const waitTime = retries * 2; // Exponential backoff
console.warn(Rate limit hit (${retries}/${maxRetries}), waiting ${waitTime}s);
await this.delay(waitTime);
} else if (errorCode?.startsWith('5') || errorCode === '4001') {
retries++;
await this.delay(Math.min(1000 * Math.pow(2, retries), 30000));
} else {
throw error;
}
}
}
throw new Error(Max retries exceeded for ${method} ${path});
}
private delay(ms: number): Promise {
return new Promise(resolve => setTimeout(resolve, ms * 1000));
}
getStatus(): Record {
const status: Record = {};
this.buckets.forEach((bucket, key) => {
const limit = this.LIMITS[key as keyof typeof this.LIMITS];
status[key] = {
tokens: Math.round(bucket.tokens * 100) / 100,
backoff: this.backoffState.has(key)
};
});
return status;
}
}
// ========== INTEGRATION EXAMPLE ==========
class OKXAPIClient {
private baseURL = 'https://www.okx.com';
private rateLimiter = new OKXv5RateLimiter();
private httpClient: AxiosInstance;
constructor(apiKey: string, secretKey: string, passphrase: string) {
this.httpClient = axios.create({
baseURL: this.baseURL,
timeout: 30000,
});
// Add request interceptor for auth and rate limiting
this.httpClient.interceptors.request.use(async (config) => {
const timestamp = new Date().toISOString();
const method = config.method?.toUpperCase() || 'GET';
const path = config.url || '';
// Rate limit
await this.rateLimiter.withRateLimit(
async () => {},
method,
path
);
// Sign request
const signature = this.generateSignature(
timestamp,
method,
path,
config.data || ''
);
config.headers['OK-ACCESS-KEY'] = apiKey;
config.headers['OK-ACCESS-SIGN'] = signature;
config.headers['OK-ACCESS-TIMESTAMP'] = timestamp;
config.headers['OK-ACCESS-PASSPHRASE'] = passphrase;
config.headers['Content-Type'] = 'application/json';
return config;
});
// Add response interceptor for error handling
this.httpClient.interceptors.response.use(
response => response,
async (error) => {
if (error.response?.status === 429) {
console.error('429 Rate Limit Exceeded');
// Rate limiter will handle retry
}
return Promise.reject(error);
}
);
}
private generateSignature(
timestamp: string,
method: string,
path: string,
body: string
): string {
const message = timestamp + method + path + body;
const hmac = crypto.createHmac('sha256', this.secretKey);
return hmac.update(message).digest('base64');
}
// Example API calls
async getTicker(instId: string) {
return this.rateLimiter.withRateLimit(
() => this.httpClient.get(/api/v5/market/ticker?instId=${instId}),
'GET',
'/api/v5/market'
);
}
async getOrderBook(instId: string, depth: number = 25) {
return this.rateLimiter.withRateLimit(
() => this.httpClient.get(
/api/v5/market/books?instId=${instId}&sz=${depth}
),
'GET',
'/api/v5/market'
);
}
async placeOrder(order: OrderParams) {
return this.rateLimiter.withRateLimit(
() => this.httpClient.post('/api/v5/trade/order', order),
'POST',
'/api/v5/trade'
);
}
}
// Usage
const client = new OKXAPIClient(
process.env.OKX_API_KEY!,
process.env.OKX_SECRET_KEY!,
process.env.OKX_PASSPHRASE!
);
// Safe concurrent requests with rate limiting
async function fetchMultipleTickers(symbols: string[]) {
const promises = symbols.map(symbol => client.getTicker(symbol));
return Promise.all(promises);
}
// Monitor rate limiter status
setInterval(() => {
console.log('Rate limiter status:', client.rateLimiter.getStatus());
}, 60000);
Lỗi thường gặp và cách khắc phục
Lỗi 1: WebSocket Connection Timeout sau 60 giây không có message
Mô tả lỗi: Kết nối WebSocket bị đóng đột ngột sau khi idle quá lâu, log hiển thị ConnectionClosed: code=1006
Nguyên nhân: OKX có cơ chế timeout cho connection không active. Nếu không có message trong 60s, server sẽ disconnect.
# Fix: Implement ping-pong heartbeat mechanism
Thêm vào OKXv5WebSocketClient
class OKXv5WebSocketClient:
PING_INTERVAL = 20 # OKX yêu cầu ping mỗi 20-30 giây
PING_TIMEOUT = 5 # Timeout cho pong response
async def _heartbeat_loop(self):
"""Gửi ping định kỳ để giữ connection alive"""
while self.is_connected:
try:
ping_msg = json.dumps({"op": "ping", "args": [int(time.time() * 1000)]})
await asyncio.wait_for(
self.ws.send(ping_msg),
timeout=5
)
logger.debug("Ping sent")
# Chờ pong với timeout ngắn
try:
response = await asyncio.wait_for(
self.ws.recv(),
timeout=self.PING_TIMEOUT
)
data = json.loads(response)
if data.get("event") == "pong":
logger.debug("Pong received")
except asyncio.TimeoutError:
logger.warning("Pong timeout - connection may be dead")
self.is_connected = False
break
except Exception as e:
logger.error(f"Heartbeat error: {e}")
self.is_connected = False
break
await asyncio.sleep(self.PING_INTERVAL)
async def connect(self, private: bool = False) -> bool:
# ... existing code ...
# Start heartbeat task
self.heartbeat_task = asyncio.create_task(self._heartbeat_loop())
return True
Lỗi 2: Subscription bị Reject với error code "1"
Mô tả lỗi: Subscribe thành công nhưng không nhận được data, response trả về {"event": "error", "code": "1", "msg": "subscription failed"}
Nguyên nhân: Channel hoặc inst_id không đúng format, hoặc subscription type không phù hợp với endpoint.
# Fix: Validate subscription parameters trước khi gửi
VALID_INST_TYPES = ["SPOT", "MARGIN", "SWAP", "FUTURES", "OPTION"]
VALID_CHANNELS = [
"ticker", "trade", "books", "books-l2-tbt",
"candle1m", "candle5m", "candle15m", "candle1H",
"mark-price", "funding-rate", "index", "oracle"
]
def validate_subscription(channel: str, inst_id: str, inst_type: str) -> tuple[bool, str]:
"""
Validate subscription parameters theo OKX v5 spec
Returns: (is_valid, error_message)
"""
# Check inst_type
if inst_type not in VALID_INST_TYPES:
return False, f"Invalid inst_type '{inst_type}'. Valid: {VALID_INST_TYPES}"
# Check channel format
if not channel or channel not in VALID_CHANNELS:
return False, f"Invalid channel '{channel}'. Valid: {VALID_CHANNELS}"
# Validate inst_id format theo inst_type
if inst_type == "SPOT":
# Format: XXX-XXX (VD: BTC-USDT)
if not re.match(r'^[A-Z]+-[A-Z]+$', inst_id):
return False, f"SPOT inst_id must be XXX-XXX format, got: {inst_id}"
elif inst_type in ["SWAP", "FUTURES"]:
# Format: XXX-USD-XXXX (VD: BTC-USD-210625)
if not re