บทนำ:ปัญหา "ConnectionError: timeout" ที่ทำให้วิเคราะห์ความผันผวนล้มเหลว

ในฐานะนักพัฒนาระบบวิเคราะห์ความผันผวน (Volatility Analyst) ฉันเคยเจอสถานการณ์ที่ทำให้เสียเวลาหลายชั่วโมง — กำลังดึงข้อมูล OKX ออปชันเพื่อสร้าง volatility surface แต่สคริปต์ค้างที่บรรทัด response = requests.get(url, timeout=30) พร้อมข้อความ:
ConnectionError: HTTPSConnectionPool(host='api.tardis.dev', port=443): 
Max retries exceeded with url: /v1/okex/options/chain (Caused by 
ConnectTimeoutError(<urllib3.connection.HTTPSConnection object at 0x...>, 
Connection timed out after 45000ms))
ปัญหานี้เกิดจากหลายสาเหตุ: rate limiting จาก Tardis API, เครือข่ายไม่เสถียร หรือ API key หมดอายุ บทความนี้จะสอนวิธีแก้ปัญหาเหล่านี้อย่างเป็นระบบ พร้อมโค้ด Python ที่พร้อมใช้งานจริงสำหรับการดึงข้อมูล OKX ออปชันและคำนวณ implied volatility

Tardis API:แหล่งข้อมูลความผันผวนที่ครอบคลุม

Tardis (tardis.dev) เป็นผู้ให้บริการข้อมูลตลาดคริปโตระดับสถาบันที่ครอบคลุม OKX, Binance, Bybit และ Huobi สำหรับ OKX ออปชัน คุณสามารถเข้าถึงข้อมูล:

การตั้งค่า Environment และการรับข้อมูลจาก Tardis CSV

เริ่มต้นด้วยการติดตั้ง dependencies และตั้งค่า API credentials:
# ติดตั้ง dependencies
pip install requests pandas numpy scipy matplotlib python-dotenv

สร้างไฟล์ .env

TARDIS_API_KEY=your_tardis_api_key

OKX_API_KEY=your_okx_api_key (ถ้าต้องการข้อมูลเรียลไทม์)

โค้ดด้านล่างนี้ใช้วิธีดาวน์โหลด CSV dataset จาก Tardis ซึ่งเหมาะสำหรับการวิเคราะห์แบบ batch และหลีกเลี่ยงปัญหา rate limiting:
import requests
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import time
import logging
from typing import Optional, Dict, List

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

class TardisDataFetcher:
    """คลาสสำหรับดึงข้อมูล OKX ออปชันจาก Tardis API"""
    
    BASE_URL = "https://api.tardis.dev/v1"
    
    def __init__(self, api_key: str):
        self.api_key = api_key
        self.session = requests.Session()
        self.session.headers.update({
            "Authorization": f"Bearer {api_key}",
            "Content-Type": "application/json"
        })
    
    def get_options_chain(
        self, 
        exchange: str = "okex",
        symbol: str = "BTC-USD",
        start_date: str = "2024-01-01",
        end_date: str = "2024-01-07",
        format_type: str = "csv"
    ) -> Optional[pd.DataFrame]:
        """
        ดึงข้อมูลออปชันเชนจาก Tardis
        
        Args:
            exchange: ตลาด (okex, binance, bybit)
            symbol: คู่สินทรัพย์ (BTC-USD, ETH-USD)
            start_date: วันที่เริ่มต้น (YYYY-MM-DD)
            end_date: วันที่สิ้นสุด (YYYY-MM-DD)
            format_type: csv หรือ json
        
        Returns:
            DataFrame ที่มีข้อมูลออปชัน หรือ None ถ้าล้มเหลว
        """
        # ตรวจสอบ format ของวันที่
        try:
            start_dt = datetime.strptime(start_date, "%Y-%m-%d")
            end_dt = datetime.strptime(end_date, "%Y-%m-%d")
        except ValueError as e:
            logger.error(f"รูปแบบวันที่ไม่ถูกต้อง: {e}")
            raise
        
        # ตรวจสอบช่วงวันที่ (ไม่เกิน 31 วันต่อ request)
        if (end_dt - start_dt).days > 31:
            logger.warning("ช่วงวันที่เกิน 31 วัน จะดึงทีละเดือน")
            return self._fetch_in_chunks(symbol, start_date, end_date)
        
        url = f"{self.BASE_URL}/export/{exchange}/options/chain"
        params = {
            "symbol": symbol,
            "start_date": start_date,
            "end_date": end_date,
            "format": format_type,
            "has_columns": True
        }
        
        max_retries = 3
        retry_delay = 5
        
        for attempt in range(max_retries):
            try:
                logger.info(f"กำลังดึงข้อมูล {symbol} จาก {start_date} ถึง {end_date}")
                response = self.session.get(
                    url, 
                    params=params, 
                    timeout=120  # เพิ่ม timeout สำหรับ CSV
                )
                response.raise_for_status()
                
                # แปลง CSV เป็น DataFrame
                from io import StringIO
                df = pd.read_csv(StringIO(response.text))
                logger.info(f"ได้ข้อมูล {len(df)} รายการสำเร็จ")
                return df
                
            except requests.exceptions.Timeout:
                logger.warning(f"Timeout ในครั้งที่ {attempt + 1}/{max_retries}")
                if attempt < max_retries - 1:
                    time.sleep(retry_delay * (attempt + 1))
                    
            except requests.exceptions.HTTPError as e:
                if e.response.status_code == 401:
                    logger.error("Unauthorized: ตรวจสอบ API key ของคุณ")
                    raise
                elif e.response.status_code == 429:
                    logger.warning(f"Rate limited: รอ {retry_delay * 2} วินาที")
                    time.sleep(retry_delay * 2)
                else:
                    logger.error(f"HTTP Error: {e}")
                    raise
                    
            except requests.exceptions.ConnectionError as e:
                logger.warning(f"Connection error ในครั้งที่ {attempt + 1}: {e}")
                if attempt < max_retries - 1:
                    time.sleep(retry_delay)
                    
        logger.error("ดึงข้อมูลล้มเหลวหลังจากลอง 3 ครั้ง")
        return None
    
    def _fetch_in_chunks(
        self, 
        symbol: str, 
        start_date: str, 
        end_date: str
    ) -> pd.DataFrame:
        """ดึงข้อมูลทีละเดือนเพื่อหลีกเลี่ยงปัญหา limit"""
        all_data = []
        current = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")
        
        while current < end:
            chunk_end = min(current + timedelta(days=31), end)
            chunk_start = current.strftime("%Y-%m-%d")
            chunk_end_str = chunk_end.strftime("%Y-%m-%d")
            
            df = self.get_options_chain(
                symbol=symbol,
                start_date=chunk_start,
                end_date=chunk_end_str
            )
            if df is not None:
                all_data.append(df)
            
            current = chunk_end + timedelta(days=1)
            time.sleep(2)  # รอระหว่าง chunk
        
        if all_data:
            return pd.concat(all_data, ignore_index=True)
        return pd.DataFrame()


ตัวอย่างการใช้งาน

if __name__ == "__main__": from dotenv import load_dotenv load_dotenv() import os TARDIS_KEY = os.getenv("TARDIS_API_KEY") if not TARDIS_KEY: logger.error("กรุณาตั้งค่า TARDIS_API_KEY ในไฟล์ .env") exit(1) fetcher = TardisDataFetcher(TARDIS_KEY) df = fetcher.get_options_chain( symbol="BTC-USD", start_date="2024-01-01", end_date="2024-01-03" ) if df is not None: print(df.head()) print(f"\nColumns: {df.columns.tolist()}")

การคำนวณ Implied Volatility และ Historical Volatility

หลังจากได้ข้อมูลมาแล้ว ขั้นตอนสำคัญคือการคำนวณความผันผวน 2 แบบ:
from scipy.stats import norm
from scipy.optimize import brentq, newton
from scipy import sparse
import warnings
warnings.filterwarnings('ignore')

class VolatilityAnalyzer:
    """คลาสสำหรับวิเคราะห์ความผันผวนของ OKX ออปชัน"""
    
    def __init__(self, risk_free_rate: float = 0.05):
        """
        Args:
            risk_free_rate: อัตราดอกเบี้ยปลอดภัย (ต่อปี)
        """
        self.r = risk_free_rate
    
    def calculate_historical_volatility(
        self, 
        prices: np.ndarray, 
        window: int = 20
    ) -> np.ndarray:
        """
        คำนวณ Historical Volatility โดยใช้ rolling standard deviation
        
        Args:
            prices: ราคาสินทรัพย์
            window: จำนวนวันสำหรับคำนวณ (default: 20 วัน)
        
        Returns:
            Array ของ historical volatility (annualized)
        """
        # คำนวณ log returns
        log_returns = np.diff(np.log(prices))
        
        # คำนวณ rolling standard deviation
        hv = np.zeros(len(log_returns) - window + 1)
        for i in range(len(hv)):
            hv[i] = np.std(log_returns[i:i+window])
        
        # Annualize (ใช้ 252 วันทำการ)
        hv_annualized = hv * np.sqrt(252)
        
        return hv_annualized
    
    def black_scholes_price(
        self, 
        S: float,  # Spot price
        K: float,  # Strike price
        T: float,  # Time to expiry (years)
        r: float,  # Risk-free rate
        sigma: float,  # Volatility
        option_type: str = "call"  # "call" หรือ "put"
    ) -> float:
        """
        คำนวณราคาออปชันตาม Black-Scholes Model
        
        Args:
            S: ราคา spot ปัจจุบัน
            K: Strike price
            T: เวลาถึง expiry (ปี)
            r: อัตราดอกเบี้ยปลอดภัย
            sigma: ความผันผวน
            option_type: "call" หรือ "put"
        
        Returns:
            ราคาออปชันตามทฤษฎี
        """
        if T <= 0 or sigma <= 0:
            return max(0, S - K) if option_type == "call" else max(0, K - S)
        
        d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
        d2 = d1 - sigma * np.sqrt(T)
        
        if option_type.lower() == "call":
            price = S * norm.cdf(d1) - K * np.exp(-r * T) * norm.cdf(d2)
        else:  # put
            price = K * np.exp(-r * T) * norm.cdf(-d2) - S * norm.cdf(-d1)
        
        return price
    
    def calculate_implied_volatility(
        self, 
        market_price: float,
        S: float,
        K: float,
        T: float,
        r: float,
        option_type: str = "call",
        precision: float = 1e-6,
        max_iterations: int = 100
    ) -> Optional[float]:
        """
        คำนวณ Implied Volatility โดยใช้ Newton-Raphson Method
        
        Args:
            market_price: ราคาตลาดจริง
            S, K, T, r: ดู docstring ของ black_scholes_price
            option_type: "call" หรือ "put"
            precision: ความแม่นยำที่ต้องการ
            max_iterations: จำนวนรอบสูงสุด
        
        Returns:
            Implied volatility หรือ None ถ้าคำนวณไม่ได้
        """
        def objective(sigma):
            return self.black_scholes_price(S, K, T, r, sigma, option_type) - market_price
        
        # ตรวจสอบ intrinsic value
        intrinsic = max(0, S - K) if option_type == "call" else max(0, K - S)
        if market_price < intrinsic:
            return None  # ราคาต่ำกว่า intrinsic value
        
        try:
            # ลอง Brent's method
            iv = brentq(
                objective, 
                0.001,  # sigma ต่ำสุด
                5.0,    # sigma สูงสุด (500%)
                xtol=precision
            )
            return iv
        except ValueError:
            # ถ้า Brent ไม่ได้ ลอง Newton-Raphson
            sigma = 0.3  # initial guess
            for _ in range(max_iterations):
                price = self.black_scholes_price(S, K, T, r, sigma, option_type)
                vega = self._calculate_vega(S, K, T, r, sigma, option_type)
                
                if abs(vega) < 1e-10:
                    break
                
                diff = price - market_price
                if abs(diff) < precision:
                    return sigma
                
                sigma = sigma - diff / vega
                sigma = max(0.001, min(sigma, 5.0))  # bound sigma
            
            return sigma if sigma < 5.0 else None
    
    def _calculate_vega(
        self, S, K, T, r, sigma, option_type
    ) -> float:
        """คำนวณ Vega สำหรับ Newton-Raphson"""
        if T <= 0 or sigma <= 0:
            return 0
        
        d1 = (np.log(S / K) + (r + 0.5 * sigma ** 2) * T) / (sigma * np.sqrt(T))
        return S * np.sqrt(T) * norm.pdf(d1)
    
    def build_volatility_smile(
        self, 
        df: pd.DataFrame,
        expiry_date: str,
        spot_price: float
    ) -> pd.DataFrame:
        """
        สร้าง Volatility Smile/Skew สำหรับ expiry เดียว
        
        Args:
            df: DataFrame จาก Tardis (ต้องมี columns: strike, price, type, expiry)
            expiry_date: วันที่ expiry ที่ต้องการ
            spot_price: ราคา spot ปัจจุบัน
        
        Returns:
            DataFrame ที่มี strike, iv, moneyness
        """
        # กรองข้อมูลตาม expiry
        expiry_df = df[df['expiry'] == expiry_date].copy()
        
        # คำนวณ IV สำหรับแต่ละ strike
        results = []
        for _, row in expiry_df.iterrows():
            K = row['strike']
            market_price = row['price']
            option_type = row['type']  # 'call' หรือ 'put'
            T = row.get('days_to_expiry', 30) / 365
            
            iv = self.calculate_implied_volatility(
                market_price, spot_price, K, T, self.r, option_type
            )
            
            if iv is not None:
                results.append({
                    'strike': K,
                    'iv': iv,
                    'moneyness': K / spot_price,  # K/S
                    'type': option_type,
                    'market_price': market_price
                })
        
        return pd.DataFrame(results)


ตัวอย่างการใช้งาน

if __name__ == "__main__": analyzer = VolatilityAnalyzer(risk_free_rate=0.05) # ตัวอย่าง: คำนวณ IV จากราคาตลาด spot = 45000 strike = 46000 T = 30 / 365 # 30 วัน market_price = 2500 # USD iv = analyzer.calculate_implied_volatility( market_price=market_price, S=spot, K=strike, T=T, r=0.05, option_type="call" ) print(f"Implied Volatility: {iv * 100:.2f}%") # ตรวจสอบด้วย Black-Scholes theoretical_price = analyzer.black_scholes_price( spot, strike, T, 0.05, iv, "call" ) print(f"Theoretical Price: ${theoretical_price:.2f}") print(f"Market Price: ${market_price:.2f}")

การสร้าง Volatility Surface และการวิเคราะห์

เมื่อได้ IV สำหรับหลายๆ strike และ expiry แล้ว สามารถสร้าง 3D surface เพื่อเห็นภาพรวมของตลาด:
import matplotlib.pyplot as plt
from mpl_toolkits.mplot3d import Axes3D

class VolatilitySurfacePlotter:
    """คลาสสำหรับสร้างกราฟ Volatility Surface"""
    
    def __init__(self, analyzer: VolatilityAnalyzer):
        self.analyzer = analyzer
    
    def plot_volatility_smile(
        self, 
        vol_data: pd.DataFrame, 
        title: str = "Volatility Smile",
        save_path: Optional[str] = None
    ):
        """สร้างกราฟ Volatility Smile"""
        fig, ax = plt.subplots(figsize=(12, 7))
        
        for opt_type in vol_data['type'].unique():
            subset = vol_data[vol_data['type'] == opt_type]
            ax.plot(
                subset['moneyness'], 
                subset['iv'] * 100,
                'o-',
                label=f'{opt_type.upper()}',
                markersize=8,
                linewidth=2
            )
        
        ax.axvline(x=1.0, color='gray', linestyle='--', alpha=0.5, label='ATM')
        ax.set_xlabel('Moneyness (K/S)', fontsize=12)
        ax.set_ylabel('Implied Volatility (%)', fontsize=12)
        ax.set_title(title, fontsize=14, fontweight='bold')
        ax.legend()
        ax.grid(True, alpha=0.3)
        
        plt.tight_layout()
        if save_path:
            plt.savefig(save_path, dpi=150, bbox_inches='tight')
        plt.show()
    
    def plot_volatility_surface(
        self,
        strikes: np.ndarray,
        expiries: np.ndarray,
        iv_matrix: np.ndarray,
        title: str = "OKX Options Volatility Surface",
        save_path: Optional[str] = None
    ):
        """สร้าง 3D Volatility Surface"""
        fig = plt.figure(figsize=(14, 10))
        ax = fig.add_subplot(111, projection='3d')
        
        # สร้าง meshgrid
        X, Y = np.meshgrid(strikes, expiries)
        
        # วาด surface
        surf = ax.plot_surface(
            X, Y, iv_matrix * 100,
            cmap='viridis',
            edgecolor='none',
            alpha=0.8
        )
        
        ax.set_xlabel('Strike Price', fontsize=11)
        ax.set_ylabel('Days to Expiry', fontsize=11)
        ax.set_zlabel('Implied Volatility (%)', fontsize=11)
        ax.set_title(title, fontsize=14, fontweight='bold')
        
        # เพิ่ม colorbar
        fig.colorbar(surf, shrink=0.5, aspect=10, label='IV (%)')
        
        # ปรับมุมมอง
        ax.view_init(elev=25, azim=45)
        
        plt.tight_layout()
        if save_path