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

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