"""Intraday volume profile features from 5-minute bars. Computes institutional conviction signals from reaction-day intraday data: - first_half_volume_pct: fraction of volume in 9:30-12:00 - volume_front_loading_ratio: first_half / second_half volume - vwap_premium_pct: (close - vwap) / vwap - institutional_conviction_score: composite [0, 1] """ from __future__ import annotations from typing import Any from libs.common.logging import get_logger logger = get_logger(__name__) # NYSE session: 9:30 ET - 16:00 ET; midpoint at 12:00 ET _MIDPOINT_HOUR = 12 _MIDPOINT_MINUTE = 0 def compute_intraday_features(bars: list[dict[str, Any]]) -> dict[str, Any] | None: """Compute intraday volume profile features from 5-minute bars. Args: bars: list of dicts with keys: timestamp, open, high, low, close, volume. timestamp format: "2024-01-15T09:30:00-05:00" or similar. Returns: Feature dict or None if insufficient data. """ if not bars or len(bars) < 10: return None first_half_vol = 0 second_half_vol = 0 total_volume = 0 vwap_numerator = 0.0 for bar in bars: vol = int(bar.get("volume", 0)) if vol <= 0: continue ts = bar.get("timestamp", "") hour, minute = _extract_hour_minute(ts) if hour is None: continue total_volume += vol typical_price = (float(bar["high"]) + float(bar["low"]) + float(bar["close"])) / 3.0 vwap_numerator += typical_price * vol if hour < _MIDPOINT_HOUR or (hour == _MIDPOINT_HOUR and minute == 0): first_half_vol += vol else: second_half_vol += vol if total_volume < 100: return None first_half_pct = first_half_vol / total_volume if total_volume > 0 else 0.0 front_loading = first_half_vol / max(1, second_half_vol) vwap = vwap_numerator / total_volume if total_volume > 0 else 0.0 last_close = float(bars[-1].get("close", 0.0)) vwap_premium = (last_close - vwap) / vwap if vwap > 0 else 0.0 conviction = _compute_conviction_score(first_half_pct, front_loading, vwap_premium) return { "first_half_volume_pct": round(first_half_pct, 4), "volume_front_loading_ratio": round(front_loading, 4), "vwap_premium_pct": round(vwap_premium, 6), "institutional_conviction_score": round(conviction, 4), } def _compute_conviction_score( first_half_pct: float, front_loading: float, vwap_premium: float, ) -> float: """Composite conviction score [0, 1]. High conviction = volume front-loaded (institutions acting early) + closing above VWAP (sustained buying pressure). Components (equal weight): - Front-loading: 1.4-2.0x first/second half ratio -> 0.5-1.0 - VWAP premium: 0%-2%+ close above VWAP -> 0.5-1.0 - Volume concentration: 55-70% in first half -> 0.5-1.0 """ # Front-loading ratio score if front_loading < 1.0: fl_score = 0.2 elif front_loading < 1.4: fl_score = 0.2 + (front_loading - 1.0) / 0.4 * 0.3 elif front_loading <= 2.0: fl_score = 0.5 + (front_loading - 1.4) / 0.6 * 0.5 else: fl_score = 1.0 # VWAP premium score if vwap_premium < 0: vwap_score = max(0.0, 0.3 + vwap_premium * 10) elif vwap_premium < 0.01: vwap_score = 0.3 + vwap_premium / 0.01 * 0.4 elif vwap_premium <= 0.02: vwap_score = 0.7 + (vwap_premium - 0.01) / 0.01 * 0.3 else: vwap_score = 1.0 # Volume concentration score if first_half_pct < 0.45: conc_score = 0.1 elif first_half_pct < 0.55: conc_score = 0.1 + (first_half_pct - 0.45) / 0.10 * 0.4 elif first_half_pct <= 0.70: conc_score = 0.5 + (first_half_pct - 0.55) / 0.15 * 0.5 else: conc_score = 1.0 return max(0.0, min(1.0, (fl_score + vwap_score + conc_score) / 3.0)) def _extract_hour_minute(timestamp_str: str) -> tuple[int | None, int | None]: """Extract hour and minute from an ISO timestamp string.""" try: # Handle formats like "2024-01-15T09:30:00-05:00" or "2024-01-15 09:30:00" time_part = timestamp_str.split("T")[-1] if "T" in timestamp_str else timestamp_str.split(" ")[-1] parts = time_part.split(":") return int(parts[0]), int(parts[1]) except (IndexError, ValueError): return None, None