""" Options Flow Processor Converts the complex SQL logic from optionflowrockerscorer.sql to Python/pandas """ import pandas as pd import numpy as np from datetime import datetime, timedelta from typing import Dict, List, Optional, Tuple import pytz import re from utils.logger import logger from utils.error_handler import handle_processing_error, validate_dataframe, safe_divide class OptionsFlowProcessor: """Process options flow data with complex analytics""" def __init__(self, tol_pct: float = 0.20): self.tol_pct = tol_pct self.ct_tz = pytz.timezone('America/Chicago') self.utc_tz = pytz.UTC def normalize_call_put(self, value: str) -> Optional[str]: """Normalize CallPut field""" if pd.isna(value): return None val = str(value).upper().strip() if val in ('C', 'CALL', 'CALLS', 'CE'): return 'CALL' elif val in ('P', 'PUT', 'PUTS', 'PE'): return 'PUT' return None def normalize_side(self, value: str) -> Optional[str]: """Normalize Side field""" if pd.isna(value): return None val = str(value).upper().strip() if val in ('A', 'AA', 'ASK', 'BUY', 'BOT', 'BTO', 'AT_ASK'): return 'BUY' elif val in ('B', 'BB', 'BID', 'SELL', 'SLD', 'STO', 'AT_BID'): return 'SELL' return None def clean_numeric(self, value) -> Optional[float]: """Clean and convert numeric field""" if pd.isna(value): return None # Convert to string, remove whitespace, $, commas text = str(value).strip() text = re.sub(r'[\s$,]', '', text) if not text: return None try: return float(text) except (ValueError, TypeError): return None def parse_date(self, date_str) -> Optional[datetime]: """Parse date string (supports YYYY-MM-DD or MM/DD/YYYY)""" if pd.isna(date_str): return None date_str = str(date_str).strip() # Try YYYY-MM-DD if re.match(r'^\d{4}-\d{2}-\d{2}$', date_str): try: return datetime.strptime(date_str, '%Y-%m-%d') except ValueError: pass # Try MM/DD/YYYY if re.match(r'^\d{1,2}/\d{1,2}/\d{2,4}$', date_str): try: return datetime.strptime(date_str, '%m/%d/%Y') except ValueError: try: return datetime.strptime(date_str, '%m/%d/%y') except ValueError: pass return None def parse_timestamp(self, date_str, time_str) -> Optional[datetime]: """Parse timestamp from date and time strings""" if pd.isna(date_str) or pd.isna(time_str): return None date_str = str(date_str).strip() time_str = str(time_str).strip() # Try various formats formats = [ ('%Y-%m-%d', '%I:%M:%S %p'), ('%Y-%m-%d', '%I:%M %p'), ('%Y-%m-%d', '%H:%M:%S'), ('%m/%d/%Y', '%I:%M:%S %p'), ('%m/%d/%Y', '%I:%M %p'), ('%m/%d/%Y', '%H:%M:%S'), ] for date_fmt, time_fmt in formats: try: date_obj = datetime.strptime(date_str, date_fmt) time_obj = datetime.strptime(time_str, time_fmt).time() return datetime.combine(date_obj.date(), time_obj) except (ValueError, TypeError): continue return None def process_base(self, df: pd.DataFrame) -> pd.DataFrame: """Step 1: Normalize fields and clean numerics""" df = df.copy() # Filter initial data df = df[ df['Premium'].notna() & (df['Premium'].astype(str).str.strip() != '') & (df['StockEtf'] == 'STOCK') & (~df['Symbol'].isin(['TSLA', 'NVDA'])) ].copy() # Add row ID (using index as stable identifier) df['rid'] = df.index # Normalize symbol df['symbol_norm'] = df['Symbol'].astype(str).str.upper().str.strip() # Normalize CallPut df['cp_norm'] = df['CallPut'].apply(self.normalize_call_put) # Normalize Side df['side_norm'] = df['Side'].apply(self.normalize_side) # Clean numerics df['strike_num'] = df['Strike'].apply(self.clean_numeric) df['spot_num'] = df['Spot'].apply(self.clean_numeric) df['premium_num'] = df['Premium'].apply(self.clean_numeric) df['vol_num'] = df['Volume'].apply(self.clean_numeric) df['oi_num'] = df['OI'].apply(self.clean_numeric) # Parse expiration date df['exp_date'] = df['ExpirationDate'].apply(self.parse_date) # Parse flow timestamp df['flow_ts_local'] = df.apply( lambda row: self.parse_timestamp(row['CreatedDate'], row['CreatedTime']), axis=1 ) # Debug: Check how many timestamps were parsed successfully parsed_count = df['flow_ts_local'].notna().sum() logger.info(f"Parsed timestamps: {parsed_count} out of {len(df)} rows") if parsed_count > 0: # Show date range of parsed data min_date = df['flow_ts_local'].min() max_date = df['flow_ts_local'].max() logger.info(f"Parsed date range: {min_date} to {max_date}") if parsed_count == 0 and len(df) > 0: # Show sample of what we're trying to parse sample_row = df.iloc[0] logger.warning(f"Failed to parse timestamps. Sample row:") logger.warning(f" CreatedDate: {sample_row.get('CreatedDate')} (type: {type(sample_row.get('CreatedDate'))})") logger.warning(f" CreatedTime: {sample_row.get('CreatedTime')} (type: {type(sample_row.get('CreatedTime'))})") # Show unique date values to help debug unique_dates = df['CreatedDate'].unique()[:10] logger.warning(f" Sample CreatedDate values: {unique_dates.tolist()}") return df def process_flow(self, df: pd.DataFrame, start_day: datetime, end_day: datetime) -> pd.DataFrame: """Step 2: Filter by date window and compute UTC""" df = df.copy() if df.empty or 'flow_ts_local' not in df.columns: logger.warning("Empty DataFrame or missing flow_ts_local column") return df # Debug: Check flow_ts_local values null_timestamps = df['flow_ts_local'].isna().sum() logger.info(f"flow_ts_local: {len(df)} total rows, {null_timestamps} null timestamps") if null_timestamps < len(df): sample_ts = df['flow_ts_local'].dropna().iloc[0] if len(df['flow_ts_local'].dropna()) > 0 else None logger.info(f"Sample flow_ts_local value: {sample_ts} (type: {type(sample_ts)})") # Filter by date window # Handle null timestamps gracefully df['flow_date_cst'] = pd.to_datetime(df['flow_ts_local'], errors='coerce').dt.date # Filter rows within date range start_date = start_day.date() if isinstance(start_day, datetime) else start_day end_date = end_day.date() if isinstance(end_day, datetime) else end_day logger.info(f"Filtering by date range: {start_date} to {end_date}") # Only filter if we have valid dates valid_dates = df['flow_date_cst'].notna() valid_count = valid_dates.sum() logger.info(f"Valid dates: {valid_count} out of {len(df)} rows") if valid_dates.any(): # Show sample of dates we're comparing if valid_count > 0: sample_dates = df[valid_dates]['flow_date_cst'].head(5).tolist() logger.info(f"Sample flow_date_cst values: {sample_dates}") date_mask = valid_dates & (df['flow_date_cst'] >= start_date) & (df['flow_date_cst'] <= end_date) matching_count = date_mask.sum() logger.info(f"Rows matching date range: {matching_count} out of {valid_count} valid dates") df = df[date_mask].copy() else: # No valid dates, return empty logger.warning(f"No valid dates found in flow_ts_local. Date range: {start_date} to {end_date}") logger.warning(f"First few flow_ts_local values: {df['flow_ts_local'].head(10).tolist()}") df = df.iloc[0:0].copy() return df if df.empty: logger.warning(f"No data in date range {start_date} to {end_date}") logger.warning(f"Date range might not match data. Check if data exists for this date.") return df # Convert to UTC (assuming flow_ts_local is in CST) def convert_to_utc(ts_local): if pd.isna(ts_local) or ts_local is None: return None if isinstance(ts_local, datetime): if ts_local.tzinfo is None: # Naive datetime, assume CST return self.ct_tz.localize(ts_local).astimezone(self.utc_tz) else: # Already timezone-aware, convert to UTC return ts_local.astimezone(self.utc_tz) return None df['flow_ts_utc'] = df['flow_ts_local'].apply(convert_to_utc) return df def process_moneyness(self, df: pd.DataFrame) -> pd.DataFrame: """Step 3: Calculate direction and moneyness""" df = df.copy() # Moneyness def calc_moneyness(row): if pd.isna(row['cp_norm']) or pd.isna(row['strike_num']) or pd.isna(row['spot_num']): return None if row['cp_norm'] == 'CALL': return 'OTM' if row['strike_num'] > row['spot_num'] else 'ITM' else: # PUT return 'OTM' if row['strike_num'] < row['spot_num'] else 'ITM' df['moneyness'] = df.apply(calc_moneyness, axis=1) # Direction def calc_direction(row): if pd.isna(row['cp_norm']) or pd.isna(row['side_norm']): return None if (row['cp_norm'] == 'CALL' and row['side_norm'] == 'BUY') or \ (row['cp_norm'] == 'PUT' and row['side_norm'] == 'SELL'): return 'BULL' elif (row['cp_norm'] == 'PUT' and row['side_norm'] == 'BUY') or \ (row['cp_norm'] == 'CALL' and row['side_norm'] == 'SELL'): return 'BEAR' return None df['direction'] = df.apply(calc_direction, axis=1) # Moneyness percentage def calc_mny_pct(row): spot = row.get('spot_num') strike = row.get('strike_num') cp = row.get('cp_norm') if pd.isna(spot) or spot == 0 or pd.isna(strike) or pd.isna(cp): return None if cp == 'CALL': return safe_divide((spot - strike) * 100.0, spot) else: # PUT return safe_divide((strike - spot) * 100.0, spot) df['mny_pct'] = df.apply(calc_mny_pct, axis=1) return df def process_aggregations(self, df: pd.DataFrame) -> pd.DataFrame: """Step 4: Calculate running sums and aggregations (complete window functions)""" df = df.copy() # Sort for window functions df = df.sort_values(['exp_date', 'symbol_norm', 'flow_ts_utc', 'rid']).reset_index(drop=True) # Fill NaN values for calculations df['premium_num'] = df['premium_num'].fillna(0) df['vol_num'] = df['vol_num'].fillna(0) df['oi_num'] = df['oi_num'].fillna(0) # Group by exp_date and symbol_norm for window functions groups = df.groupby(['exp_date', 'symbol_norm'], group_keys=False) # Premium aggregations - all 8 combinations def calc_prem_value(row, cp, side, moneyness): if (row['cp_norm'] == cp and row['side_norm'] == side and row['moneyness'] == moneyness): return row['premium_num'] return 0.0 # CALL BUY OTM df['prem_cb_otm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'CALL', 'BUY', 'OTM'), axis=1).cumsum() ).values # CALL BUY ITM df['prem_cb_itm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'CALL', 'BUY', 'ITM'), axis=1).cumsum() ).values # CALL SELL OTM df['prem_cs_otm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'CALL', 'SELL', 'OTM'), axis=1).cumsum() ).values # CALL SELL ITM df['prem_cs_itm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'CALL', 'SELL', 'ITM'), axis=1).cumsum() ).values # PUT BUY OTM df['prem_pb_otm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'PUT', 'BUY', 'OTM'), axis=1).cumsum() ).values # PUT BUY ITM df['prem_pb_itm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'PUT', 'BUY', 'ITM'), axis=1).cumsum() ).values # PUT SELL OTM df['prem_ps_otm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'PUT', 'SELL', 'OTM'), axis=1).cumsum() ).values # PUT SELL ITM df['prem_ps_itm'] = groups.apply( lambda g: g.apply(lambda row: calc_prem_value(row, 'PUT', 'SELL', 'ITM'), axis=1).cumsum() ).values # Volume and OI aggregations df['vol_all'] = groups['vol_num'].transform(lambda x: x.cumsum()) df['oi_all'] = groups['oi_num'].transform(lambda x: x.cumsum()) # Bull/Bear volume def calc_bull_vol(row): if ((row['cp_norm'] == 'CALL' and row['side_norm'] == 'BUY') or (row['cp_norm'] == 'PUT' and row['side_norm'] == 'SELL')): return row['vol_num'] return 0.0 df['bull_vol'] = groups.apply( lambda g: g.apply(calc_bull_vol, axis=1).cumsum() ).values def calc_bear_vol(row): if ((row['cp_norm'] == 'PUT' and row['side_norm'] == 'BUY') or (row['cp_norm'] == 'CALL' and row['side_norm'] == 'SELL')): return row['vol_num'] return 0.0 df['bear_vol'] = groups.apply( lambda g: g.apply(calc_bear_vol, axis=1).cumsum() ).values # Bull/Bear OI def calc_bull_oi(row): if ((row['cp_norm'] == 'CALL' and row['side_norm'] == 'BUY') or (row['cp_norm'] == 'PUT' and row['side_norm'] == 'SELL')): return row['oi_num'] return 0.0 df['bull_oi'] = groups.apply( lambda g: g.apply(calc_bull_oi, axis=1).cumsum() ).values def calc_bear_oi(row): if ((row['cp_norm'] == 'PUT' and row['side_norm'] == 'BUY') or (row['cp_norm'] == 'CALL' and row['side_norm'] == 'SELL')): return row['oi_num'] return 0.0 df['bear_oi'] = groups.apply( lambda g: g.apply(calc_bear_oi, axis=1).cumsum() ).values # OI for badges (CALL BUY OTM and PUT BUY OTM) def calc_oi_cb_otm(row): if row['cp_norm'] == 'CALL' and row['side_norm'] == 'BUY' and row['moneyness'] == 'OTM': return row['oi_num'] return 0.0 df['oi_cb_otm'] = groups.apply( lambda g: g.apply(calc_oi_cb_otm, axis=1).cumsum() ).values def calc_oi_pb_otm(row): if row['cp_norm'] == 'PUT' and row['side_norm'] == 'BUY' and row['moneyness'] == 'OTM': return row['oi_num'] return 0.0 df['oi_pb_otm'] = groups.apply( lambda g: g.apply(calc_oi_pb_otm, axis=1).cumsum() ).values # Direction count (within exp_date, symbol_norm, direction) # Need to group by exp_date, symbol_norm, and direction for this count # Filter out rows with None direction to avoid grouping issues valid_direction = df['direction'].notna() if valid_direction.any(): dir_groups = df[valid_direction].groupby(['exp_date', 'symbol_norm', 'direction'], group_keys=False) df.loc[valid_direction, 'dir_count'] = dir_groups.cumcount() + 1 df.loc[~valid_direction, 'dir_count'] = 0 else: df['dir_count'] = 0 return df def process_badges(self, df: pd.DataFrame) -> pd.DataFrame: """Step 5: Calculate badges and decorations (complete implementation)""" df = df.copy() # Fill NaN values df['prem_cb_itm'] = df['prem_cb_itm'].fillna(0) df['prem_ps_itm'] = df['prem_ps_itm'].fillna(0) df['prem_cs_itm'] = df['prem_cs_itm'].fillna(0) df['prem_pb_itm'] = df['prem_pb_itm'].fillna(0) df['prem_cb_otm'] = df['prem_cb_otm'].fillna(0) df['prem_ps_otm'] = df['prem_ps_otm'].fillna(0) df['prem_pb_otm'] = df['prem_pb_otm'].fillna(0) df['prem_cs_otm'] = df['prem_cs_otm'].fillna(0) df['bull_vol'] = df['bull_vol'].fillna(0) df['bear_vol'] = df['bear_vol'].fillna(0) df['bull_oi'] = df['bull_oi'].fillna(0) df['bear_oi'] = df['bear_oi'].fillna(0) df['oi_cb_otm'] = df['oi_cb_otm'].fillna(0) df['oi_pb_otm'] = df['oi_pb_otm'].fillna(0) # Calculate net values df['net_vol_raw'] = df['bull_vol'] - df['bear_vol'] df['net_oi_raw'] = df['bull_oi'] - df['bear_oi'] # Calculate premium totals df['bull_prem_itm'] = df['prem_cb_itm'] + df['prem_ps_itm'] df['bear_prem_itm'] = df['prem_cs_itm'] + df['prem_pb_itm'] df['otm_bull_raw'] = df['prem_cb_otm'] + df['prem_ps_otm'] df['otm_bear_raw'] = df['prem_pb_otm'] + df['prem_cs_otm'] df['bull_total'] = df['bull_prem_itm'] + df['otm_bull_raw'] df['bear_total'] = df['bear_prem_itm'] + df['otm_bear_raw'] # Badge round df['badge_round'] = df.apply( lambda row: '🟢' if row['bull_prem_itm'] > row['bear_prem_itm'] else '🔴' if row['bull_prem_itm'] < row['bear_prem_itm'] else '', axis=1 ) # Badge more (complete implementation) def calc_badge_more(row): badge_parts = [] bull_prem_itm = row['bull_prem_itm'] bear_prem_itm = row['bear_prem_itm'] # Diamond badge if bull_prem_itm > bear_prem_itm and row['prem_cb_itm'] > row['prem_ps_itm']: badge_parts.append('💎') elif bull_prem_itm < bear_prem_itm and row['prem_pb_itm'] > row['prem_cs_itm']: badge_parts.append('💎') # Star badge if bull_prem_itm > bear_prem_itm and (row['otm_bull_raw'] - row['otm_bear_raw']) > 10000: badge_parts.append('⭐') elif bull_prem_itm < bear_prem_itm and (row['otm_bear_raw'] - row['otm_bull_raw']) > 10000: badge_parts.append('⭐') # Money badge if bull_prem_itm > bear_prem_itm and row['oi_cb_otm'] > 100000: badge_parts.append('💰') elif bull_prem_itm < bear_prem_itm and row['oi_pb_otm'] > 100000: badge_parts.append('💰') # Check badge (vol > oi) vol_num = row.get('vol_num', 0) or 0 oi_num = row.get('oi_num', 0) or 0 if vol_num > oi_num: badge_parts.append('✔') return ''.join(badge_parts) df['badge_more'] = df.apply(calc_badge_more, axis=1) # Flash badge (complete logic) def calc_flash(row): premium_num = row.get('premium_num', 0) or 0 if premium_num <= 10000: return '' bull_prem_itm = row['bull_prem_itm'] bear_prem_itm = row['bear_prem_itm'] cp_norm = row.get('cp_norm', '') side = str(row.get('Side', '')).upper() # Check for AA/BB in Side field has_aa = 'AA' in side has_bb = 'BB' in side if bull_prem_itm > bear_prem_itm: if (cp_norm == 'CALL' and has_aa) or (cp_norm == 'PUT' and has_bb): return '⚡' elif bull_prem_itm < bear_prem_itm: if (cp_norm == 'CALL' and has_bb) or (cp_norm == 'PUT' and has_aa): return '⚡' return '' df['flash'] = df.apply(calc_flash, axis=1) # Rocket badge - matches SQL logic exactly def calc_rocket(row): vol_num = row.get('vol_num', 0) or 0 oi_num = row.get('oi_num', 0) or 0 premium_num = row.get('premium_num', 0) or 0 badge_round = row.get('badge_round', '') badge_more = row.get('badge_more', '') flash = row.get('flash', '') has_diamond = '💎' in badge_more has_star = '⭐' in badge_more has_money = '💰' in badge_more # Check vol > oi directly (matches SQL: COALESCE(d.vol_num,0) > COALESCE(d.oi_num,0)) vol_gt_oi = vol_num > oi_num # Full rocket conditions (matches SQL exactly) if (badge_round == '🟢' and has_diamond and has_star and has_money and flash == '⚡' and premium_num > 1000000): return '🚀' # Triple rocket (matches SQL: vol > oi AND flash AND money AND premium > 500K) if (vol_gt_oi and flash == '⚡' and has_money and premium_num > 500000): return '🚀🚀🚀' # Double rocket (matches SQL: vol > oi AND flash AND money) if (vol_gt_oi and flash == '⚡' and has_money): return '🚀🚀' # Single rocket (matches SQL: vol > oi AND (flash OR premium > 500K)) if (vol_gt_oi and (flash == '⚡' or premium_num > 500000)): return '🚀' return '' df['rocket'] = df.apply(calc_rocket, axis=1) return df def process_rocket_score(self, df: pd.DataFrame) -> pd.DataFrame: """Calculate rocket score and final rocket label""" df = df.copy() def calc_score(row): score = 0.0 # Premium tier - ensure it's a number prem = row.get('premium_num') if prem is None or pd.isna(prem): prem = 0.0 else: prem = float(prem) if prem else 0.0 if prem >= 2000000: score += 3.0 elif prem >= 800000: score += 2.0 elif prem >= 200000: score += 1.0 # Net premium imbalance - ensure values are numbers bull = row.get('bull_total') bear = row.get('bear_total') bull = float(bull) if bull is not None and not pd.isna(bull) else 0.0 bear = float(bear) if bear is not None and not pd.isna(bear) else 0.0 total = bull + bear if total > 0: net_diff = bull - bear score += 1.5 * safe_divide(net_diff, total) # Vol > OI - ensure values are numbers vol_num = row.get('vol_num') oi_num = row.get('oi_num') vol_num = float(vol_num) if vol_num is not None and not pd.isna(vol_num) else 0.0 oi_num = float(oi_num) if oi_num is not None and not pd.isna(oi_num) else 0.0 if vol_num > oi_num: score += 1.2 # Session weight session = row.get('session_bucket', '') if session == 'RTH': score += 1.0 elif session == 'POST': score += 0.5 elif session == 'PRE': score += 0.3 # Catalyst flag if row.get('near_alert_type'): score += 1.0 # OTM bias - ensure values are numbers cp_norm = row.get('cp_norm', '') strike_num = row.get('strike_num') spot_num = row.get('spot_num') strike_num = float(strike_num) if strike_num is not None and not pd.isna(strike_num) else 0.0 spot_num = float(spot_num) if spot_num is not None and not pd.isna(spot_num) else 0.0 if cp_norm == 'CALL' and strike_num > spot_num: score += 0.8 elif cp_norm == 'PUT' and strike_num < spot_num: score += 0.8 # Tape alignment tape_align = row.get('tape_alignment') if tape_align == 1 or tape_align is True: score += 0.5 # Ensure score is a number before rounding if score is None or pd.isna(score): score = 0.0 return round(float(score), 2) df['rocket_score'] = df.apply(calc_score, axis=1) # Calculate final rocket label with moneyness def calc_rocket_with_mny(row): score = row.get('rocket_score', 0) mny_pct = row.get('mny_pct') rocket = row.get('rocket', '') # Determine rocket base from score if score >= 5.0: rocket_base = '🚀🚀🚀' elif score >= 3.5: rocket_base = '🚀🚀' elif score >= 2.0: rocket_base = '🚀' else: rocket_base = rocket if rocket else '' # Append moneyness if available if mny_pct is not None and not pd.isna(mny_pct): mny_label = 'ITM' if mny_pct >= 0 else 'OTM' mny_str = f"{abs(mny_pct):.1f}" return f"{rocket_base} [{mny_label} {mny_str}%]" return rocket_base df['Rocket_with_mny'] = df.apply(calc_rocket_with_mny, axis=1) return df def process(self, df: pd.DataFrame, start_day: datetime, end_day: datetime) -> pd.DataFrame: """Main processing pipeline""" try: logger.info(f"Starting options flow processing: {len(df)} rows, {start_day} to {end_day}") if df.empty: logger.warning("Input DataFrame is empty") return df # Step 1: Base normalization logger.debug("Step 1: Base normalization") df = self.process_base(df) logger.debug(f"After base normalization: {len(df)} rows") if df.empty: logger.warning("No data after base normalization") return df # Step 2: Flow filtering logger.debug("Step 2: Flow filtering") df = self.process_flow(df, start_day, end_day) logger.debug(f"After flow filtering: {len(df)} rows") if df.empty: logger.warning("No data after flow filtering - date range may not match data") # Return empty DataFrame with required columns to avoid KeyError return pd.DataFrame(columns=[ 'rid', 'symbol_norm', 'cp_norm', 'side_norm', 'strike_num', 'spot_num', 'premium_num', 'vol_num', 'oi_num', 'exp_date', 'flow_ts_local', 'flow_ts_utc', 'flow_date_cst', 'moneyness', 'direction', 'mny_pct', 'badge_round', 'badge_more', 'flash', 'rocket', 'rocket_score', 'Rocket_with_mny' ]) # Step 3: Moneyness logger.debug("Step 3: Moneyness calculation") df = self.process_moneyness(df) # Step 4: Aggregations logger.debug("Step 4: Aggregations") df = self.process_aggregations(df) # Step 5: Badges logger.debug("Step 5: Badge calculation") df = self.process_badges(df) # Step 6: Rocket score logger.debug("Step 6: Rocket score calculation") df = self.process_rocket_score(df) logger.info(f"Processing complete: {len(df)} rows processed") return df except Exception as e: handle_processing_error( e, context={'start_day': start_day, 'end_day': end_day, 'input_rows': len(df)}, raise_error=True )