320 lines
12 KiB
Python
320 lines
12 KiB
Python
"""
|
|
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
|
|
|