""" Time-Sequenced Flow Analysis Service Analyzes flow patterns over time to detect urgency, distribution, and continuation signals """ import pandas as pd import numpy as np from typing import Dict, Optional from datetime import timedelta from utils.logger import logger class TimeSequencedAnalyzer: """ Analyzes flow patterns over time to detect: - Flow acceleration (urgency building) - Distribution patterns (flow weakening) - Strike laddering (sequential accumulation) """ def __init__(self): # Configuration self.analysis_window_minutes = 60 # Look back window for flow analysis self.min_trades_for_analysis = 3 # Minimum trades needed for meaningful analysis def calculate_flow_acceleration( self, df: pd.DataFrame, row_idx: int ) -> Optional[float]: """ Calculate flow acceleration: change in premium per minute. Positive = accelerating (urgency building) Negative = decelerating (flow weakening) Returns: Δ premium / minute (premium rate change) """ if row_idx >= len(df): return None current_row = df.iloc[row_idx] symbol = current_row.get('symbol_norm') direction = current_row.get('direction') flow_ts_utc = current_row.get('flow_ts_utc') if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc): return None # Sort by timestamp df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True) # Look at recent trades for same symbol and direction window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes) mask = ( (df_sorted['symbol_norm'] == symbol) & (df_sorted['direction'] == direction) & (df_sorted['flow_ts_utc'] >= window_start) & (df_sorted['flow_ts_utc'] <= flow_ts_utc) ) recent_trades = df_sorted[mask] if len(recent_trades) < self.min_trades_for_analysis: return None # Calculate premium accumulation over time recent_trades = recent_trades.sort_values('flow_ts_utc') recent_trades = recent_trades.copy() recent_trades['cumulative_premium'] = recent_trades['premium_num'].cumsum() # Fit linear trend to cumulative premium vs time # Convert timestamps to minutes since window start recent_trades['minutes_since_start'] = ( recent_trades['flow_ts_utc'] - window_start ).dt.total_seconds() / 60.0 # Calculate slope (premium per minute) if len(recent_trades) >= 2: x = recent_trades['minutes_since_start'].values y = recent_trades['cumulative_premium'].values # Linear regression: y = mx + b, we want m (slope) # Calculate acceleration as change in slope (second derivative approximation) if len(recent_trades) >= 3: # Split into two halves mid_idx = len(recent_trades) // 2 first_half = recent_trades.iloc[:mid_idx] second_half = recent_trades.iloc[mid_idx:] if len(first_half) >= 2 and len(second_half) >= 2: # Calculate slopes for each half x1 = first_half['minutes_since_start'].values y1 = first_half['cumulative_premium'].values slope1 = np.polyfit(x1, y1, 1)[0] if len(x1) > 1 else 0 x2 = second_half['minutes_since_start'].values y2 = second_half['cumulative_premium'].values slope2 = np.polyfit(x2, y2, 1)[0] if len(x2) > 1 else 0 # Acceleration = change in slope acceleration = slope2 - slope1 return float(acceleration) return None def calculate_time_between_hits( self, df: pd.DataFrame, row_idx: int ) -> Optional[float]: """ Calculate average time between consecutive trades (in minutes). Lower = faster pace, higher urgency. """ if row_idx >= len(df): return None current_row = df.iloc[row_idx] symbol = current_row.get('symbol_norm') direction = current_row.get('direction') flow_ts_utc = current_row.get('flow_ts_utc') if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc): return None # Sort by timestamp df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True) # Look at recent trades window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes) mask = ( (df_sorted['symbol_norm'] == symbol) & (df_sorted['direction'] == direction) & (df_sorted['flow_ts_utc'] >= window_start) & (df_sorted['flow_ts_utc'] <= flow_ts_utc) ) recent_trades = df_sorted[mask].sort_values('flow_ts_utc') if len(recent_trades) < 2: return None # Calculate time gaps between consecutive trades time_gaps = [] prev_ts = None for _, trade in recent_trades.iterrows(): ts = trade.get('flow_ts_utc') if pd.notna(ts) and prev_ts: gap_minutes = (ts - prev_ts).total_seconds() / 60.0 if gap_minutes > 0: time_gaps.append(gap_minutes) prev_ts = ts if len(time_gaps) == 0: return None avg_time_between = np.mean(time_gaps) return float(avg_time_between) def calculate_follow_on_ratio( self, df: pd.DataFrame, row_idx: int ) -> Optional[float]: """ Calculate follow-on ratio: fraction of trades in same direction after initial trade. Higher = continuation, Lower = reversal/distribution. Returns: ratio of same-direction trades / total trades (0-1) """ if row_idx >= len(df): return None current_row = df.iloc[row_idx] symbol = current_row.get('symbol_norm') direction = current_row.get('direction') flow_ts_utc = current_row.get('flow_ts_utc') if pd.isna(symbol) or pd.isna(direction) or pd.isna(flow_ts_utc): return None # Sort by timestamp df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True) # Look at trades after this one (within window) window_end = flow_ts_utc + timedelta(minutes=self.analysis_window_minutes) mask = ( (df_sorted['symbol_norm'] == symbol) & (df_sorted['flow_ts_utc'] > flow_ts_utc) & (df_sorted['flow_ts_utc'] <= window_end) ) follow_on_trades = df_sorted[mask] if len(follow_on_trades) == 0: return None # Count same-direction vs opposite-direction same_direction = follow_on_trades[follow_on_trades['direction'] == direction] follow_on_ratio = len(same_direction) / len(follow_on_trades) if len(follow_on_trades) > 0 else 0.0 return float(follow_on_ratio) def detect_strike_laddering( self, df: pd.DataFrame, row_idx: int ) -> bool: """ Detect strike laddering: sequential strikes in same direction. Returns True if laddering pattern detected. """ if row_idx >= len(df): return False current_row = df.iloc[row_idx] symbol = current_row.get('symbol_norm') direction = current_row.get('direction') cp_norm = current_row.get('cp_norm') flow_ts_utc = current_row.get('flow_ts_utc') exp_date = current_row.get('exp_date') if pd.isna(symbol) or pd.isna(direction) or pd.isna(cp_norm): return False # Sort by timestamp df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True) # Look at recent trades window_start = flow_ts_utc - timedelta(minutes=self.analysis_window_minutes) mask = ( (df_sorted['symbol_norm'] == symbol) & (df_sorted['direction'] == direction) & (df_sorted['cp_norm'] == cp_norm) & (df_sorted['flow_ts_utc'] >= window_start) & (df_sorted['flow_ts_utc'] <= flow_ts_utc) ) if pd.notna(exp_date): mask = mask & (df_sorted['exp_date'] == exp_date) recent_trades = df_sorted[mask].sort_values('flow_ts_utc') if len(recent_trades) < 3: return False # Get unique strikes in order strikes = recent_trades['strike_num'].dropna().unique() strikes = sorted(strikes) if len(strikes) < 3: return False # Check if strikes are sequential (laddering) # Look for consistent spacing (e.g., 100, 105, 110 or 50, 55, 60) strike_diffs = [strikes[i+1] - strikes[i] for i in range(len(strikes)-1)] if len(strike_diffs) >= 2: # Check if differences are roughly equal (within 20% variance) avg_diff = np.mean(strike_diffs) if avg_diff > 0: std_diff = np.std(strike_diffs) cv = std_diff / avg_diff if avg_diff > 0 else float('inf') # Coefficient of variation # Low coefficient of variation = regular spacing = laddering if cv < 0.2: # Less than 20% variation return True return False def enrich_with_time_sequenced_metrics(self, df: pd.DataFrame) -> pd.DataFrame: """ Add time-sequenced flow metrics to DataFrame. Adds: flow_acceleration, time_between_hits, follow_on_ratio, strike_laddering_detected """ if df.empty: return df df = df.copy() # Initialize columns df['flow_acceleration'] = None df['time_between_hits'] = None df['follow_on_ratio'] = None df['strike_laddering_detected'] = False # Sort by timestamp for proper analysis df_sorted = df.sort_values('flow_ts_utc').reset_index(drop=True) # Calculate metrics for each row for idx in df_sorted.index: original_idx = df_sorted.index[idx] # Flow acceleration acceleration = self.calculate_flow_acceleration(df_sorted, idx) if acceleration is not None: df.at[original_idx, 'flow_acceleration'] = float(acceleration) # Time between hits time_between = self.calculate_time_between_hits(df_sorted, idx) if time_between is not None: df.at[original_idx, 'time_between_hits'] = float(time_between) # Follow-on ratio follow_on = self.calculate_follow_on_ratio(df_sorted, idx) if follow_on is not None: df.at[original_idx, 'follow_on_ratio'] = float(follow_on) # Strike laddering laddering = self.detect_strike_laddering(df_sorted, idx) df.at[original_idx, 'strike_laddering_detected'] = bool(laddering) logger.info(f"Time-sequenced analysis complete. Strike laddering detected: {df['strike_laddering_detected'].sum()}") return df