742 lines
29 KiB
Python
742 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):
|
|
return None
|
|
|
|
date_str = str(date_str).strip()
|
|
|
|
# If time_str is None/NaN, parse just the date and use midnight as default
|
|
if pd.isna(time_str) or time_str is None:
|
|
# Try to parse just the date
|
|
date_obj = self.parse_date(date_str)
|
|
if date_obj:
|
|
# Use midnight (00:00:00) as default time
|
|
return datetime.combine(date_obj.date(), datetime.min.time())
|
|
return None
|
|
|
|
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
|
|
)
|
|
|