""" Intent Classification Service Replaces naive direction (BULL/BEAR) with nuanced volatility and hedging intent classification """ import pandas as pd import numpy as np from typing import Optional from enum import Enum from utils.logger import logger from utils.error_handler import safe_divide class VolatilityIntent(Enum): """Volatility and hedging intent classification""" LONG_VOL = "LONG_VOL" # Buying volatility (call/put buying, expecting large moves) SHORT_VOL = "SHORT_VOL" # Selling volatility (premium collection, expecting low vol) DIRECTIONAL = "DIRECTIONAL" # Directional positioning (directional bias) HEDGE_UNWIND = "HEDGE_UNWIND" # Hedging or unwinding existing positions class IntentClassifier: """ Classifies options flow intent beyond simple BULL/BEAR direction. Identifies volatility trading, hedging, and directional positioning. """ def __init__(self): # Configuration thresholds self.otm_threshold_pct = 5.0 # Strikes >5% OTM considered OTM self.long_vol_premium_threshold = 100000 # Minimum premium for long vol signal self.short_vol_premium_threshold = 200000 # Minimum premium for short vol signal def estimate_delta(self, row: pd.Series) -> float: """ Estimate option delta from moneyness. Returns delta estimate (0-1 for calls, -1-0 for puts, use absolute value). """ spot_num = row.get('spot_num', 0) or 0 strike_num = row.get('strike_num', 0) or 0 cp_norm = row.get('cp_norm', '') if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num) or pd.isna(cp_norm): return 0.5 # Default to ATM moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0 if cp_norm == 'CALL': if strike_num <= spot_num: # ITM call: delta 0.5 to 1.0 delta = 0.5 + (1.0 - moneyness_ratio) * 0.5 else: # OTM call: delta 0.5 to 0.0 delta = max(0.0, 0.5 * (1.5 - moneyness_ratio) / 0.5) else: # PUT if strike_num >= spot_num: # ITM put: delta -0.5 to -1.0 (return absolute) delta = abs(0.5 + (moneyness_ratio - 1.0) * 0.5) else: # OTM put: delta -0.5 to 0.0 (return absolute) delta = max(0.0, 0.5 * (1.0 - moneyness_ratio) / 0.5) return float(delta) def estimate_gamma(self, row: pd.Series, delta: float) -> float: """ Estimate option gamma (sensitivity of delta to price changes). Higher near ATM, lower ITM/OTM. Returns gamma estimate (positive value). """ spot_num = row.get('spot_num', 0) or 0 strike_num = row.get('strike_num', 0) or 0 if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num): return 0.0 # Gamma is highest at-the-money moneyness_ratio = strike_num / spot_num if spot_num > 0 else 1.0 # Simple approximation: gamma peaks at 1.0 (ATM) and decays away # Use normal distribution approximation distance_from_atm = abs(moneyness_ratio - 1.0) # Gamma ≈ exp(-distance^2 / (2*sigma^2)) where sigma ≈ 0.1 (10% moneyness) gamma = np.exp(-(distance_from_atm ** 2) / (2 * 0.01)) return float(gamma) def calculate_delta_exposure(self, row: pd.Series) -> float: """ Calculate delta exposure: contracts * delta * 100 * spot_price. Positive = long delta (bullish), Negative = short delta (bearish). """ premium_num = row.get('premium_num', 0) or 0 spot_num = row.get('spot_num', 0) or 0 vol_num = row.get('vol_num', 0) or 0 cp_norm = row.get('cp_norm', '') side_norm = row.get('side_norm', '') if pd.isna(spot_num) or spot_num == 0: return 0.0 # Estimate delta delta = self.estimate_delta(row) # Determine sign based on call/put and buy/sell if cp_norm == 'CALL': if side_norm == 'BUY': delta_sign = 1.0 # Long calls = positive delta else: # SELL delta_sign = -1.0 # Short calls = negative delta else: # PUT if side_norm == 'BUY': delta_sign = -1.0 # Long puts = negative delta else: # SELL delta_sign = 1.0 # Short puts = positive delta # Delta exposure = contracts * delta * 100 * spot # Use volume as proxy for contracts contracts = vol_num if not pd.isna(vol_num) else 0 delta_exposure = contracts * delta * delta_sign * 100 * spot_num return float(delta_exposure) def calculate_gamma_exposure(self, row: pd.Series) -> float: """ Calculate gamma exposure: contracts * gamma * 100 * spot_price^2. Positive = long gamma (volatility long), Negative = short gamma (volatility short). """ premium_num = row.get('premium_num', 0) or 0 spot_num = row.get('spot_num', 0) or 0 vol_num = row.get('vol_num', 0) or 0 cp_norm = row.get('cp_norm', '') side_norm = row.get('side_norm', '') if pd.isna(spot_num) or spot_num == 0: return 0.0 # Estimate delta and gamma delta = self.estimate_delta(row) gamma = self.estimate_gamma(row, delta) # Determine sign: buying options = long gamma, selling = short gamma if side_norm == 'BUY': gamma_sign = 1.0 # Long gamma else: # SELL gamma_sign = -1.0 # Short gamma # Gamma exposure = contracts * gamma * 100 * spot^2 contracts = vol_num if not pd.isna(vol_num) else 0 gamma_exposure = contracts * gamma * gamma_sign * 100 * (spot_num ** 2) return float(gamma_exposure) def classify_volatility_intent(self, row: pd.Series) -> str: """ Classify volatility intent based on trade characteristics. Returns VolatilityIntent enum value as string. """ premium_num = row.get('premium_num', 0) or 0 spot_num = row.get('spot_num', 0) or 0 strike_num = row.get('strike_num', 0) or 0 cp_norm = row.get('cp_norm', '') side_norm = row.get('side_norm', '') vol_num = row.get('vol_num', 0) or 0 oi_num = row.get('oi_num', 0) or 0 # Calculate moneyness if pd.isna(spot_num) or spot_num == 0 or pd.isna(strike_num): mny_pct = 0.0 else: if cp_norm == 'CALL': mny_pct = ((strike_num - spot_num) / spot_num * 100.0) if spot_num > 0 else 0 else: # PUT mny_pct = ((spot_num - strike_num) / spot_num * 100.0) if spot_num > 0 else 0 is_otm = abs(mny_pct) > self.otm_threshold_pct # Long volatility: buying OTM options (calls or puts) # High premium, OTM strikes, buying side if (side_norm == 'BUY' and is_otm and premium_num >= self.long_vol_premium_threshold): return VolatilityIntent.LONG_VOL.value # Short volatility: selling options, collecting premium # High premium, selling side, vol > OI (opening new short positions) if (side_norm == 'SELL' and premium_num >= self.short_vol_premium_threshold and vol_num > oi_num): return VolatilityIntent.SHORT_VOL.value # Directional: ITM options, buying side, strong directional flow if (side_norm == 'BUY' and not is_otm and premium_num >= 50000): return VolatilityIntent.DIRECTIONAL.value # Hedge/Unwind: Selling existing positions (vol < OI) # Or buying protective puts/calls if (side_norm == 'SELL' and vol_num < oi_num) or \ (side_norm == 'BUY' and is_otm and premium_num < self.long_vol_premium_threshold): return VolatilityIntent.HEDGE_UNWIND.value # Default: directional (fallback) return VolatilityIntent.DIRECTIONAL.value def enrich_with_intent_classification(self, df: pd.DataFrame) -> pd.DataFrame: """ Add intent classification metrics to DataFrame. Adds: delta_exposure, gamma_exposure, volatility_intent """ if df.empty: return df df = df.copy() # Initialize columns df['delta_exposure'] = 0.0 df['gamma_exposure'] = 0.0 df['volatility_intent'] = VolatilityIntent.DIRECTIONAL.value # Calculate metrics df['delta_exposure'] = df.apply(self.calculate_delta_exposure, axis=1) df['gamma_exposure'] = df.apply(self.calculate_gamma_exposure, axis=1) df['volatility_intent'] = df.apply(self.classify_volatility_intent, axis=1) # Log distribution intent_counts = df['volatility_intent'].value_counts() logger.info(f"Intent classification complete. Distribution: {intent_counts.to_dict()}") return df