diff --git a/CHANGELOG.md b/CHANGELOG.md index 4014c7a..dc09832 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,14 +5,17 @@ All notable changes to **oipd** will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [1.0.0] - 2025-09-18 +## [Unreleased] ### Added +- **Cryptocurrency Options Support**: Added Bybit vendor integration for crypto options (BTC, ETH, etc.) +- New `crypto` installation option: `pip install oipd[crypto]` +- Bybit API integration via `pybit` library +- Crypto-specific examples and documentation - overhauled user API interface. Now, users interface using the RND() class, and input arguments using MarketInputs and ModelParams - integrated plotting functionality - integrated convenient functions to access results - handles dividend yield and schedule - integrated yfinance support for automated options data pulling, and retrieval of dividend info -- refactored folder structure - seperated RND from options-pricing functionality - integrated put-call parity -> finds market-implied forward price, and implied dividend yield, replaced ITM call options with OTM puts converted to synthetic calls to reduce noise - integrated Black-76 pricing model compatible with forwards - removed KDE and fit_kde() argument diff --git a/README.md b/README.md index 9ef0488..9969c89 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,15 @@ est = RND.from_ticker("AAPL", market) # 3 ─ access results and plots est.prob_at_or_above(120) # P(price >= $120) est.prob_below(100) # P(price < $100) -est.plot() # plot probability and cumulative distribution functions +est.plot() # plot probability and cumulative distribution functions + +# For cryptocurrency options (Bybit) +crypto_market = MarketInputs( + valuation_date=date.today(), + expiry_date=date(2025, 12, 26), + risk_free_rate=0.04, +) +crypto_est = RND.from_ticker("BTC", crypto_market, vendor="bybit") ``` OIPD also **supports manual CSV or DataFrame uploads**. @@ -85,7 +93,8 @@ Join the [Discord community](https://discord.gg/pWVrmQWk) to share ideas, discus # Current Roadmap Convenience features: -- integrate other data vendors (Alpaca, Deribit) for automatic stock and crypto options data fetching +- ✅ integrate crypto options data fetching (Bybit implemented) +- integrate additional data vendors (Alpaca, Deribit) for more coverage Algorithmic improvements: - implement no-arbitrage checks diff --git a/TECHNICAL_README.md b/TECHNICAL_README.md index 438c4d9..0a961c3 100644 --- a/TECHNICAL_README.md +++ b/TECHNICAL_README.md @@ -10,11 +10,12 @@ This document complements the high-level `README.md`. Here you’ll find: ## 1. Installation flavours -| Use-case | Command | -| --------------------------------- | --------------------------- | -| Full installation (with yfinance) | `pip install oipd` | -| Core maths only (no data vendors) | `pip install oipd[minimal]` | -| Contributor install | `pip install oipd[dev]` | +| Use-case | Command | +| ----------------------------------- | --------------------------- | +| Full installation (all vendors) | `pip install oipd` | +| Core maths only (no data vendors) | `pip install oipd[minimal]` | +| Crypto markets only (Bybit) | `pip install oipd[crypto]` | +| Contributor install | `pip install oipd[dev]` | Requires at minimum **Python 3.10+**. @@ -32,7 +33,8 @@ The `RND` class is the high-level facade that users interact with. It has three 1. **CSV files**: `RND.from_csv(path, market)` 2. **DataFrames**: `RND.from_dataframe(df, market)` -3. **Live data**: `RND.from_ticker("AAPL", market)` +3. **Live data**: `RND.from_ticker("AAPL", market)` for equity options (Yahoo Finance) +4. **Crypto options**: `RND.from_ticker("BTC", market, vendor="bybit")` for cryptocurrency options `RND` takes the mandatory MarketInputs parameter, and an optional ModelParams parameter. @@ -85,9 +87,23 @@ Main class that fits risk-neutral density models from options data and provides ```python RND.from_csv(path, market, model, column_mapping={"YourHeader": "oipd_field"}) # Maps CSV headers to OIPD fields RND.from_dataframe(df, market, model, column_mapping={"YourHeader": "oipd_field"}) # Maps DataFrame columns to OIPD fields -RND.from_ticker("AAPL", market, vendor="yfinance", ...) # (auto-fetches from vendors, only YFinance currently integrated) +RND.from_ticker("AAPL", market, vendor="yfinance", ...) # Equity options via Yahoo Finance +RND.from_ticker("BTC", market, vendor="bybit", ...) # Crypto options via Bybit API ``` +### 2.5 Supported Data Vendors + +| Vendor | Market Type | Supported Assets | Installation | +|-----------|------------------|----------------------|----------------------| +| yfinance | Equity Options | US stocks, ETFs | Default | +| bybit | Crypto Options | BTC, ETH, SOL, etc. | Requires `pybit` | + +**Crypto Options Notes:** +- Cryptocurrency options don't have dividends (dividend_yield defaults to 0) +- Uses spot price from crypto exchanges as underlying price +- Risk-free rate should reflect the funding cost in crypto markets +- Expiry dates follow exchange-specific formats (Bybit: "30DEC22") + #### Expected Data Format OIPD expects the following columns in your data: diff --git a/examples/crypto_options_example.py b/examples/crypto_options_example.py new file mode 100644 index 0000000..6580b3a --- /dev/null +++ b/examples/crypto_options_example.py @@ -0,0 +1,129 @@ +""" +Example: Analyzing Bitcoin Options Implied Probability Distribution using Bybit + +This example demonstrates how to use OIPD with cryptocurrency options data +from Bybit to extract risk-neutral probability distributions. +""" + +from datetime import date, timedelta +import matplotlib.pyplot as plt + +from oipd import RND, MarketInputs + +def main(): + """Main example function.""" + + print("🚀 OIPD Crypto Options Example - Bitcoin via Bybit") + print("=" * 60) + + # Step 1: Define market parameters for Bitcoin options + # Note: For crypto, risk-free rate should reflect funding costs in crypto markets + market = MarketInputs( + valuation_date=date.today(), + expiry_date=date.today() + timedelta(days=30), # 30 days from now + risk_free_rate=0.05, # 5% annual rate (adjust based on crypto funding rates) + ) + + print(f"📅 Analysis Date: {market.valuation_date}") + print(f"📅 Expiry Date: {market.expiry_date}") + print(f"💰 Risk-free Rate: {market.risk_free_rate:.1%}") + print() + + try: + # Step 2: List available expiry dates for Bitcoin options + print("📋 Available Bitcoin option expiry dates:") + expiry_dates = RND.list_expiry_dates("BTCUSDT", vendor="bybit") + for i, expiry in enumerate(expiry_dates[:10]): # Show first 10 + print(f" {i+1:2d}. {expiry}") + if len(expiry_dates) > 10: + print(f" ... and {len(expiry_dates) - 10} more") + print() + + # Step 3: Use the first available expiry date + if expiry_dates: + target_expiry = expiry_dates[0] + market = MarketInputs( + valuation_date=date.today(), + expiry_date=date.fromisoformat(target_expiry), + risk_free_rate=0.05, + ) + + print(f"🎯 Analyzing Bitcoin options expiring on {target_expiry}") + print("⏳ Fetching data from Bybit...") + + # Step 4: Fetch Bitcoin options data and estimate RND + est = RND.from_ticker("BTC", market, vendor="bybit") + + print("✅ Data fetched successfully!") + print() + + # Step 5: Display summary + print("📊 Market Summary:") + print(est.summary()) + print() + + # Step 6: Calculate key probabilities + current_price = est.market.underlying_price + + # Calculate probability ranges + prob_above_current = est.prob_at_or_above(current_price * 1.1) # +10% + prob_below_current = est.prob_below(current_price * 0.9) # -10% + prob_stable = 1 - prob_above_current - prob_below_current # ±10% + + print("🎲 Probability Analysis:") + print(f" P(BTC ≥ ${current_price * 1.1:,.0f}) = {prob_above_current:.1%}") + print(f" P(BTC ≤ ${current_price * 0.9:,.0f}) = {prob_below_current:.1%}") + print(f" P(${current_price * 0.9:,.0f} < BTC < ${current_price * 1.1:,.0f}) = {prob_stable:.1%}") + print() + + # Step 7: Generate probability distribution plot + print("📈 Generating probability distribution plot...") + + fig = est.plot( + kind="both", + figsize=(12, 6), + title=f"Bitcoin Options Implied Probability Distribution\nExpiry: {target_expiry}", + source="Data: Bybit API via OIPD", + show_current_price=True + ) + + # Save the plot + plt.tight_layout() + plt.savefig("bitcoin_probability_distribution.png", dpi=300, bbox_inches='tight') + print("💾 Plot saved as 'bitcoin_probability_distribution.png'") + + # Step 8: Export results to CSV + est.to_csv("bitcoin_rnd_results.csv") + print("💾 Results exported to 'bitcoin_rnd_results.csv'") + print() + + # Step 9: Advanced analysis - quartiles + df_results = est.to_frame() + + # Find quartile prices + q25_price = est.prices[est.cdf >= 0.25][0] if any(est.cdf >= 0.25) else est.prices[-1] + q50_price = est.prices[est.cdf >= 0.50][0] if any(est.cdf >= 0.50) else est.prices[-1] + q75_price = est.prices[est.cdf >= 0.75][0] if any(est.cdf >= 0.75) else est.prices[-1] + + print("📊 Price Quartiles (Market Expectations):") + print(f" 25th percentile: ${q25_price:,.0f}") + print(f" 50th percentile: ${q50_price:,.0f} (median)") + print(f" 75th percentile: ${q75_price:,.0f}") + print() + + print("✨ Analysis complete! Check the generated files for detailed results.") + + else: + print("❌ No Bitcoin options expiry dates found on Bybit") + + except Exception as e: + print(f"❌ Error occurred: {e}") + print("\n💡 Troubleshooting tips:") + print(" 1. Ensure you have pybit installed: pip install pybit") + print(" 2. Check your internet connection") + print(" 3. Verify Bybit API is accessible") + print(" 4. Try a different expiry date") + + +if __name__ == "__main__": + main() diff --git a/main.py b/main.py new file mode 100644 index 0000000..e9e6443 --- /dev/null +++ b/main.py @@ -0,0 +1,17 @@ +from oipd import RND, MarketInputs +from datetime import date + +# 1 ─ point to a ticker and provide market info +crypto_market = MarketInputs( + valuation_date=date.today(), + expiry_date=date(2025, 12, 26), + risk_free_rate=0.04, +) +crypto_est = RND.from_ticker("ETH", crypto_market, vendor="bybit") +# 2 - run estimator, auto fetching data from Yahoo Finance +# est = RND.from_ticker("AAPL", market) + +# 3 ─ access results and plots +est.prob_at_or_above(4500) # P(price >= $120) +est.prob_below(4700) # P(price < $100) +est.plot() # plot probability and cumulative distribution functions diff --git a/oipd/__init__.py b/oipd/__init__.py index 370f414..4b19439 100644 --- a/oipd/__init__.py +++ b/oipd/__init__.py @@ -20,7 +20,7 @@ FillMode, resolve_market ) -__version__ = "0.1.0" +__version__ = "1.0.3" __all__ = [ # Core functions diff --git a/oipd/vendor/__init__.py b/oipd/vendor/__init__.py index 06ab3aa..ebf24b2 100644 --- a/oipd/vendor/__init__.py +++ b/oipd/vendor/__init__.py @@ -33,3 +33,4 @@ def get_reader(name: str): # Pre-register built-in vendors # ------------------------------------------------------------------ register("yfinance", "oipd.vendor.yfinance.reader") +register("bybit", "oipd.vendor.bybit.reader") diff --git a/oipd/vendor/bybit/__init__.py b/oipd/vendor/bybit/__init__.py new file mode 100644 index 0000000..c941447 --- /dev/null +++ b/oipd/vendor/bybit/__init__.py @@ -0,0 +1,3 @@ +"""Bybit vendor module for OIPD.""" + +__all__ = ["Reader", "BybitError"] diff --git a/oipd/vendor/bybit/reader.py b/oipd/vendor/bybit/reader.py new file mode 100644 index 0000000..7d52b71 --- /dev/null +++ b/oipd/vendor/bybit/reader.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +"""Bybit data connector for *oipd*. + +Implements :class:`Reader`, compatible with the generic *vendor* registry. +""" + +import pickle +import time +from datetime import datetime, timedelta, date +from pathlib import Path +from typing import Optional, Tuple, Dict, List + +import pandas as pd +import numpy as np +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +from oipd.io.reader import AbstractReader + +# ----------------------------------------------------------------------------- +# Exceptions +# ----------------------------------------------------------------------------- + + +class BybitError(Exception): + """Exception raised when Bybit operations fail""" + + pass + + +# ----------------------------------------------------------------------------- +# Simple file-based cache +# ----------------------------------------------------------------------------- + + +class _BybitCache: + def __init__(self, cache_dir: str = ".bybit_cache", ttl_minutes: int = 15): + self.cache_dir = Path(cache_dir) + self.cache_dir.mkdir(exist_ok=True) + self.ttl = timedelta(minutes=ttl_minutes) + + def _path(self, ticker: str, expiry: str) -> Path: + return self.cache_dir / f"{ticker}_{expiry}.pkl" + + def get(self, ticker: str, expiry: str) -> Optional[Tuple[pd.DataFrame, float]]: + p = self._path(ticker, expiry) + if not p.exists(): + return None + try: + with open(p, "rb") as f: + data = pickle.load(f) + if datetime.now() - data["timestamp"] < self.ttl: + return data["options_data"], data["underlying_price"] + except Exception: + p.unlink(missing_ok=True) + return None + + def set(self, ticker: str, expiry: str, df: pd.DataFrame, price: float): + p = self._path(ticker, expiry) + try: + with open(p, "wb") as f: + pickle.dump( + { + "timestamp": datetime.now(), + "options_data": df, + "underlying_price": price, + }, + f, + ) + except Exception: + pass + + def clear(self): + for fp in self.cache_dir.glob("*.pkl"): + fp.unlink(missing_ok=True) + + +# ----------------------------------------------------------------------------- +# Reader implementation +# ----------------------------------------------------------------------------- + + +class Reader(AbstractReader): + """Fetch crypto options data from *Bybit* and return a validated DataFrame.""" + + def __init__(self, cache_enabled: bool = True, cache_ttl_minutes: int = 15): + self._cache = ( + _BybitCache(ttl_minutes=cache_ttl_minutes) if cache_enabled else None + ) + self._session = None + + # ---------- helper ------------------------------------------------------- + def _get_session(self): + """Get HTTP session for API calls with retry logic""" + if self._session is None: + self._session = requests.Session() + + # Configure retry strategy + retry_strategy = Retry( + total=5, # Total number of retries + status_forcelist=[429, 500, 502, 503, 504], # HTTP status codes to retry on + backoff_factor=1, # Wait time between retries + raise_on_status=False + ) + + adapter = HTTPAdapter(max_retries=retry_strategy) + self._session.mount("http://", adapter) + self._session.mount("https://", adapter) + + # Set headers + self._session.headers.update({ + 'Content-Type': 'application/json', + 'User-Agent': 'OIPD/1.0' + }) + + return self._session + + def _make_api_request(self, endpoint: str, params: Dict = None) -> Dict: + """Make API request to Bybit demo API with error handling and retries""" + base_url = "https://api.bybit.com" # Using live API (not testnet) + url = f"{base_url}{endpoint}" + + session = self._get_session() + + for attempt in range(3): # Additional retry logic on top of requests retry + try: + response = session.get(url, params=params, timeout=30) + + if response.status_code == 200: + data = response.json() + if data.get("retCode") == 0: + return data + else: + raise BybitError(f"API error: {data.get('retMsg', 'Unknown error')}") + else: + raise BybitError(f"HTTP error {response.status_code}: {response.text}") + + except requests.exceptions.ConnectionError as e: + if attempt < 2: # Only retry on connection errors + print(f"Connection attempt {attempt + 1} failed, retrying in {2 ** attempt} seconds...") + time.sleep(2 ** attempt) + continue + raise BybitError(f"Connection failed after {attempt + 1} attempts: {e}") + except requests.exceptions.Timeout as e: + if attempt < 2: + print(f"Timeout on attempt {attempt + 1}, retrying...") + time.sleep(2 ** attempt) + continue + raise BybitError(f"Request timeout after {attempt + 1} attempts: {e}") + except Exception as e: + raise BybitError(f"Unexpected error: {e}") + + raise BybitError("Max retries exceeded") + + def get_tickers(self, category: str, symbol: str = None, baseCoin: str = None) -> Dict: + """Get ticker information""" + params = {"category": category} + if symbol: + params["symbol"] = symbol + if baseCoin: + params["baseCoin"] = baseCoin + + return self._make_api_request("/v5/market/tickers", params) + + def get_instruments_info(self, category: str, baseCoin: str = None, limit: int = 1000) -> Dict: + """Get instruments information""" + params = {"category": category, "limit": limit} + if baseCoin: + params["baseCoin"] = baseCoin + + return self._make_api_request("/v5/market/instruments-info", params) + + def get_kline(self, category: str, symbol: str, interval: str, start: int = None, end: int = None, limit: int = 200) -> Dict: + """Get kline/candlestick data""" + params = { + "category": category, + "symbol": symbol, + "interval": interval, + "limit": limit + } + if start: + params["start"] = start + if end: + params["end"] = end + + return self._make_api_request("/v5/market/kline", params) + + def _ingest_data(self, ticker_expiry: str) -> pd.DataFrame: + """Download options for *ticker:expiry* or serve from cache.""" + try: + ticker, expiry = ticker_expiry.split(":", 1) + except ValueError as exc: + raise ValueError("Expect 'TICKER:YYYY-MM-DD'") from exc + + # Check cache first + if self._cache: + hit = self._cache.get(ticker, expiry) + if hit is not None: + df, price = hit + df.attrs["underlying_price"] = price + return df + + try: + # Convert ticker format (e.g., BTC -> BTC for Bybit) + base_coin = ticker.upper() + + # Get underlying price from spot ticker + spot_ticker = f"{base_coin}USDT" + try: + spot_data = self.get_tickers(category="spot", symbol=spot_ticker) + underlying_price = float(spot_data["result"]["list"][0]["lastPrice"]) + except (KeyError, IndexError, ValueError) as e: + raise BybitError(f"Could not parse underlying price for {spot_ticker}: {e}") + + # Get options instruments for the expiry date + # Bybit options use format like "BTC-30DEC22-18000-C" + instruments_data = self.get_instruments_info( + category="option", + baseCoin=base_coin, + limit=1000 + ) + + if instruments_data["retCode"] != 0: + raise BybitError(f"Failed to get instruments: {instruments_data['retMsg']}") + + # Filter instruments by expiry date + expiry_date = datetime.strptime(expiry, "%Y-%m-%d").date() + options_symbols = [] + + for instrument in instruments_data["result"]["list"]: + symbol = instrument["symbol"] + try: + # Parse Bybit option symbol format: BTC-30DEC22-18000-C + parts = symbol.split("-") + if len(parts) >= 4 and parts[0] == base_coin: + # Parse expiry from symbol (e.g., 30DEC22) + expiry_str = parts[1] + instrument_expiry = self._parse_bybit_expiry(expiry_str) + if instrument_expiry == expiry_date: + options_symbols.append(symbol) + except Exception: + # Skip malformed symbols + continue + + if not options_symbols: + raise BybitError(f"No options found for {ticker} expiring on {expiry}") + + # Get ticker data for all relevant options + options_data = [] + + # Process in batches to avoid rate limits + batch_size = 50 + for i in range(0, len(options_symbols), batch_size): + batch_symbols = options_symbols[i:i + batch_size] + + for symbol in batch_symbols: + try: + ticker_data = self.get_tickers(category="option", symbol=symbol) + + if ticker_data["retCode"] == 0 and ticker_data["result"]["list"]: + option_info = ticker_data["result"]["list"][0] + + # Parse symbol to get strike and option type + try: + parts = symbol.split("-") + strike = float(parts[2]) + option_type = "C" if parts[3] == "C" else "P" + except (IndexError, ValueError): + continue # Skip malformed symbols + + # Extract relevant data + last_price = float(option_info.get("lastPrice", 0)) + bid_price = float(option_info.get("bid1Price", 0)) + ask_price = float(option_info.get("ask1Price", 0)) + + # Skip if no meaningful price data + if last_price <= 0 and bid_price <= 0 and ask_price <= 0: + continue + + options_data.append({ + "strike": strike, + "last_price": last_price, + "option_type": option_type, + "bid": bid_price if bid_price > 0 else np.nan, + "ask": ask_price if ask_price > 0 else np.nan, + "symbol": symbol, + "volume": float(option_info.get("volume24h", 0)), + "open_interest": float(option_info.get("openInterest", 0)), + }) + except Exception as e: + # Log error but continue with other symbols + print(f"Warning: Failed to get data for {symbol}: {e}") + continue + + if not options_data: + raise BybitError(f"No valid option data found for {ticker} on {expiry}") + + # Create DataFrame + df = pd.DataFrame(options_data) + + # Set metadata + df.attrs["underlying_price"] = underlying_price + df.attrs["dividend_yield"] = 0.0 # Crypto doesn't have dividends + df.attrs["dividend_schedule"] = None + + # Cache the result + if self._cache: + self._cache.set(ticker, expiry, df.copy(), underlying_price) + + return df + + except Exception as exc: + if isinstance(exc, BybitError): + raise + raise BybitError(f"Failed to fetch data from Bybit: {exc}") from exc + + def _parse_bybit_expiry(self, expiry_str: str) -> date: + """Parse Bybit expiry format like '30DEC22' to date object""" + try: + # Handle formats like "30DEC22" or "30DEC2022" + if len(expiry_str) == 7: # "30DEC22" + day = int(expiry_str[:2]) + month_str = expiry_str[2:5] + year = int("20" + expiry_str[5:]) + elif len(expiry_str) == 9: # "30DEC2022" + day = int(expiry_str[:2]) + month_str = expiry_str[2:5] + year = int(expiry_str[5:]) + else: + raise ValueError(f"Unexpected expiry format: {expiry_str}") + + month_map = { + "JAN": 1, "FEB": 2, "MAR": 3, "APR": 4, "MAY": 5, "JUN": 6, + "JUL": 7, "AUG": 8, "SEP": 9, "OCT": 10, "NOV": 11, "DEC": 12 + } + + month = month_map[month_str] + return date(year, month, day) + + except (ValueError, KeyError) as e: + raise ValueError(f"Could not parse expiry '{expiry_str}': {e}") + + def _clean_data(self, df: pd.DataFrame) -> pd.DataFrame: + """Clean and filter Bybit options data""" + if df.empty: + raise BybitError("No options data available") + + # Remove rows with no price data + df = df[ + (df["last_price"] > 0) | + (df["bid"].notna() & (df["bid"] > 0)) | + (df["ask"].notna() & (df["ask"] > 0)) + ].copy() + + if df.empty: + raise BybitError("No options with valid price data") + + return df + + def _validate_data(self, df: pd.DataFrame) -> pd.DataFrame: + """Validate the cleaned data""" + # Check required columns exist + required_cols = ["strike", "last_price", "option_type"] + missing_cols = [col for col in required_cols if col not in df.columns] + if missing_cols: + raise BybitError(f"Missing required columns: {missing_cols}") + + # Validate option types + valid_types = {"C", "P"} + invalid_types = set(df["option_type"].unique()) - valid_types + if invalid_types: + raise BybitError(f"Invalid option types found: {invalid_types}") + + # Check for negative strikes or prices + if (df["strike"] <= 0).any(): + raise BybitError("Found negative or zero strike prices") + + if (df["last_price"] < 0).any(): + raise BybitError("Found negative option prices") + + return df + + def _transform_data(self, df: pd.DataFrame) -> pd.DataFrame: + """Apply Bybit-specific transformations""" + # Ensure we have enough data points for meaningful analysis + if len(df) < 5: + raise BybitError("Need at least 5 strikes for a meaningful smile") + + # Sort by strike and option type for consistent processing + df = df.sort_values(["strike", "option_type"]).reset_index(drop=True) + + return df + + def load(self) -> pd.DataFrame: # pragma: no cover + raise NotImplementedError("Use read(ticker_expiry, …) via DataSource wrapper") + + @classmethod + def list_expiry_dates(cls, ticker: str) -> List[str]: + """ + List available expiry dates for a given crypto ticker. + + Parameters + ---------- + ticker : str + Crypto ticker symbol (e.g., "BTC", "ETH") + + Returns + ------- + List[str] + List of available expiry dates in YYYY-MM-DD format + """ + reader = cls() + + try: + base_coin = ticker.upper() + + # Get all option instruments for this base coin + instruments_data = reader.get_instruments_info( + category="option", + baseCoin=base_coin, + limit=1000 + ) + + if instruments_data["retCode"] != 0: + raise BybitError(f"Failed to get instruments: {instruments_data['retMsg']}") + + expiry_dates = set() + + for instrument in instruments_data["result"]["list"]: + symbol = instrument["symbol"] + try: + # Parse Bybit option symbol format: BTC-30DEC22-18000-C + parts = symbol.split("-") + if len(parts) >= 4 and parts[0] == base_coin: + expiry_str = parts[1] + expiry_date = reader._parse_bybit_expiry(expiry_str) + expiry_dates.add(expiry_date.strftime("%Y-%m-%d")) + except Exception: + # Skip malformed symbols + continue + + return sorted(list(expiry_dates)) + + except Exception as exc: + if isinstance(exc, BybitError): + raise + raise BybitError(f"Failed to list expiry dates for {ticker}: {exc}") from exc + + +__all__ = ["Reader", "BybitError"] diff --git a/pyproject.toml b/pyproject.toml index 5e8349e..9953f81 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "oipd" -version = "1.0.2" +version = "1.0.3" readme = "README.md" authors = [ { name="Henry Tian", email="tyrneh@gmail.com" }, @@ -38,6 +38,7 @@ dependencies = [ "matplotlib-label-lines>=0.6.0", "traitlets>=5.12.0", "yfinance>=0.2.60", + "pybit>=5.8.0", ] [project.optional-dependencies] @@ -51,6 +52,17 @@ minimal = [ "traitlets>=5.12.0", ] +# Crypto-only installation - for cryptocurrency options data +crypto = [ + "numpy>=1.26.0", + "pandas>=2.1.0", + "scipy>=1.11.0", + "matplotlib>=3.8.0", + "matplotlib-label-lines>=0.6.0", + "traitlets>=5.12.0", + "pybit>=5.8.0", +] + # Developer tools (formatting, linting, testing, build) dev = [ "black>=24.3.0", diff --git a/test_kline.py b/test_kline.py new file mode 100644 index 0000000..9bcbc85 --- /dev/null +++ b/test_kline.py @@ -0,0 +1,9 @@ +from pybit.unified_trading import HTTP + +# Create a session for testnet or live environment (set testnet=True for demo) +session = HTTP(testnet=False) + +# Fetch recent public trade history for BTCUSDT spot category, limiting to 5 records +response = session.get_public_trade_history(category="spot", symbol="BTCUSDT", limit=5) + +print(response) diff --git a/tests/vendor/__init__.py b/tests/vendor/__init__.py new file mode 100644 index 0000000..a9ec011 --- /dev/null +++ b/tests/vendor/__init__.py @@ -0,0 +1 @@ +# Vendor tests module diff --git a/tests/vendor/test_bybit.py b/tests/vendor/test_bybit.py new file mode 100644 index 0000000..73af0bb --- /dev/null +++ b/tests/vendor/test_bybit.py @@ -0,0 +1,254 @@ +"""Tests for Bybit vendor integration.""" + +import pytest +from unittest.mock import Mock, patch +import pandas as pd +from datetime import date + +from oipd.vendor.bybit.reader import Reader, BybitError + + +class TestBybitReader: + """Test suite for Bybit options data reader.""" + + def test_init(self): + """Test reader initialization.""" + reader = Reader() + assert reader._cache is not None + assert reader._session is None + + # Test cache disabled + reader_no_cache = Reader(cache_enabled=False) + assert reader_no_cache._cache is None + + def test_parse_bybit_expiry(self): + """Test parsing Bybit expiry format.""" + reader = Reader() + + # Test short format + result = reader._parse_bybit_expiry("30DEC22") + expected = date(2022, 12, 30) + assert result == expected + + # Test long format + result = reader._parse_bybit_expiry("30DEC2022") + expected = date(2022, 12, 30) + assert result == expected + + # Test different months + result = reader._parse_bybit_expiry("15JAN23") + expected = date(2023, 1, 15) + assert result == expected + + # Test invalid format + with pytest.raises(ValueError): + reader._parse_bybit_expiry("INVALID") + + @patch('pybit.unified_trading.HTTP') + def test_ingest_data_no_options(self, mock_http): + """Test handling when no options are available.""" + reader = Reader(cache_enabled=False) + + # Mock API responses + mock_session = Mock() + mock_http.return_value = mock_session + + # Mock spot ticker response + mock_session.get_tickers.return_value = { + "retCode": 0, + "result": {"list": [{"lastPrice": "50000.0"}]} + } + + # Mock empty instruments response + mock_session.get_instruments_info.return_value = { + "retCode": 0, + "result": {"list": []} + } + + with pytest.raises(BybitError, match="No options found"): + reader._ingest_data("BTC:2025-12-26") + + @patch('pybit.unified_trading.HTTP') + def test_ingest_data_success(self, mock_http): + """Test successful data ingestion.""" + reader = Reader(cache_enabled=False) + + # Mock API responses + mock_session = Mock() + mock_http.return_value = mock_session + + # Mock spot ticker response + mock_session.get_tickers.side_effect = [ + # Spot price response + { + "retCode": 0, + "result": {"list": [{"lastPrice": "50000.0"}]} + }, + # Option ticker responses + { + "retCode": 0, + "result": {"list": [{ + "lastPrice": "1000.0", + "bid1Price": "950.0", + "ask1Price": "1050.0", + "volume24h": "10.5", + "openInterest": "100.0" + }]} + } + ] + + # Mock instruments response + mock_session.get_instruments_info.return_value = { + "retCode": 0, + "result": { + "list": [{ + "symbol": "BTC-26DEC25-50000-C" + }] + } + } + + result = reader._ingest_data("BTC:2025-12-26") + + assert isinstance(result, pd.DataFrame) + assert result.attrs["underlying_price"] == 50000.0 + assert result.attrs["dividend_yield"] == 0.0 + assert result.attrs["dividend_schedule"] is None + + # Check DataFrame structure + expected_columns = ["strike", "last_price", "option_type", "bid", "ask", "symbol", "volume", "open_interest"] + for col in expected_columns: + assert col in result.columns + + def test_clean_data_empty(self): + """Test cleaning empty data.""" + reader = Reader() + empty_df = pd.DataFrame() + + with pytest.raises(BybitError, match="No options data available"): + reader._clean_data(empty_df) + + def test_clean_data_no_prices(self): + """Test cleaning data with no valid prices.""" + reader = Reader() + df = pd.DataFrame({ + "strike": [100, 110], + "last_price": [0, 0], + "bid": [0, float('nan')], + "ask": [0, float('nan')], + "option_type": ["C", "C"] + }) + + with pytest.raises(BybitError, match="No options with valid price data"): + reader._clean_data(df) + + def test_validate_data_missing_columns(self): + """Test validation with missing required columns.""" + reader = Reader() + df = pd.DataFrame({ + "strike": [100], + "last_price": [10] + # Missing option_type + }) + + with pytest.raises(BybitError, match="Missing required columns"): + reader._validate_data(df) + + def test_validate_data_invalid_option_types(self): + """Test validation with invalid option types.""" + reader = Reader() + df = pd.DataFrame({ + "strike": [100], + "last_price": [10], + "option_type": ["X"] # Invalid + }) + + with pytest.raises(BybitError, match="Invalid option types found"): + reader._validate_data(df) + + def test_validate_data_negative_strikes(self): + """Test validation with negative strikes.""" + reader = Reader() + df = pd.DataFrame({ + "strike": [-100], + "last_price": [10], + "option_type": ["C"] + }) + + with pytest.raises(BybitError, match="Found negative or zero strike prices"): + reader._validate_data(df) + + def test_transform_data_insufficient_strikes(self): + """Test transformation with insufficient data points.""" + reader = Reader() + df = pd.DataFrame({ + "strike": [100, 110], + "last_price": [10, 15], + "option_type": ["C", "C"] + }) + + with pytest.raises(BybitError, match="Need at least 5 strikes"): + reader._transform_data(df) + + def test_transform_data_success(self): + """Test successful data transformation.""" + reader = Reader() + df = pd.DataFrame({ + "strike": [90, 100, 110, 120, 130], + "last_price": [15, 10, 6, 3, 1], + "option_type": ["C", "C", "C", "C", "C"] + }) + + result = reader._transform_data(df) + + assert len(result) == 5 + assert result.index.tolist() == [0, 1, 2, 3, 4] # Reset index + + @patch('pybit.unified_trading.HTTP') + def test_list_expiry_dates(self, mock_http): + """Test listing available expiry dates.""" + # Mock API response + mock_session = Mock() + mock_http.return_value = mock_session + + mock_session.get_instruments_info.return_value = { + "retCode": 0, + "result": { + "list": [ + {"symbol": "BTC-30DEC22-50000-C"}, + {"symbol": "BTC-30DEC22-55000-P"}, + {"symbol": "BTC-31JAN23-50000-C"}, + {"symbol": "ETH-30DEC22-3000-C"}, # Different base coin, should be filtered + {"symbol": "INVALID-FORMAT"}, # Invalid format, should be skipped + ] + } + } + + result = Reader.list_expiry_dates("BTC") + + expected = ["2022-12-30", "2023-01-31"] + assert result == expected + + # Verify the API was called correctly + mock_session.get_instruments_info.assert_called_once_with( + category="option", + baseCoin="BTC", + limit=1000 + ) + + @patch('pybit.unified_trading.HTTP') + def test_list_expiry_dates_api_error(self, mock_http): + """Test handling API errors when listing expiry dates.""" + mock_session = Mock() + mock_http.return_value = mock_session + + mock_session.get_instruments_info.return_value = { + "retCode": 10001, + "retMsg": "API error" + } + + with pytest.raises(BybitError, match="Failed to get instruments"): + Reader.list_expiry_dates("BTC") + + +if __name__ == "__main__": + pytest.main([__file__])