institutional-trader/backend/python_service/services/options_flow_processor.py

732 lines
29 KiB
Python

"""
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
)