| import pandas as pd |
| import numpy as np |
| from typing import List, Dict, Tuple, Optional |
|
|
|
|
| class PointInTimeUniverseFilter: |
| """ |
| Enforces Point-in-Time Data Hygiene and Universe Selection Rules: |
| 1. Min Price Threshold: Price > $5.00 |
| 2. Dollar Volume Liquidity Threshold: 20-day Average Dollar Volume (ADV20) > $50M |
| 3. Asset Age: Minimum 252 trading days history to avoid IPO / new ETF early distortions |
| 4. Unified Trading Calendar Alignment & Forward Fill (Zero Backfill) |
| """ |
|
|
| def __init__( |
| self, |
| min_price: float = 5.0, |
| min_adv20_usd: float = 50_000_000.0, |
| min_age_days: int = 252, |
| ): |
| self.min_price = min_price |
| self.min_adv20_usd = min_adv20_usd |
| self.min_age_days = min_age_days |
|
|
| def align_trading_calendar(self, df: pd.DataFrame) -> pd.DataFrame: |
| """ |
| Align all assets to a unified trading calendar. |
| Missing trading days are forward-filled (FFill) up to 5 days, NEVER backfilled. |
| """ |
| df = df.copy() |
| df["date"] = pd.to_datetime(df["date"]) |
| df = df.sort_values(["symbol", "date"]).reset_index(drop=True) |
|
|
| |
| all_dates = pd.date_range(df["date"].min(), df["date"].max(), freq="B") |
| symbols = df["symbol"].unique() |
|
|
| |
| full_idx = pd.MultiIndex.from_product([all_dates, symbols], names=["date", "symbol"]) |
| |
| df_indexed = df.set_index(["date", "symbol"]) |
| df_aligned = df_indexed.reindex(full_idx).groupby("symbol").ffill(limit=5).reset_index() |
|
|
| return df_aligned |
|
|
| def filter_universe(self, df: pd.DataFrame) -> Tuple[pd.DataFrame, Dict[str, List[str]]]: |
| """ |
| Filter df for each trading date t to select eligible tickers. |
| Returns cleaned DataFrame and a dictionary mapping `date_str -> list of eligible symbols`. |
| """ |
| df = self.align_trading_calendar(df) |
| df["dollar_volume"] = df["close"] * df["volume"] |
| |
| |
| df["adv20"] = ( |
| df.groupby("symbol")["dollar_volume"] |
| .transform(lambda s: s.rolling(20, min_periods=10).mean()) |
| ) |
|
|
| |
| df["trading_age"] = df.groupby("symbol")["date"].cumcount() + 1 |
|
|
| |
| eligible_mask = ( |
| (df["close"] >= self.min_price) & |
| (df["adv20"] >= self.min_adv20_usd) & |
| (df["trading_age"] >= self.min_age_days) |
| ) |
|
|
| df["is_eligible"] = eligible_mask |
|
|
| |
| universe_by_date = {} |
| for d, group in df[df["is_eligible"]].groupby("date"): |
| date_str = d.strftime("%Y-%m-%d") |
| universe_by_date[date_str] = group["symbol"].tolist() |
|
|
| return df, universe_by_date |
|
|
| @staticmethod |
| def validate_no_lookahead(df: pd.DataFrame, feature_date_col: str = "date", label_start_col: str = "label_start_date") -> bool: |
| """ |
| Validation assertion to prove zero lookahead leakage: |
| Every feature calculated at timestamp t must strictly satisfy t < label_start_date. |
| """ |
| if label_start_col not in df.columns: |
| return True |
| violating = df[df[feature_date_col] >= df[label_start_col]] |
| if len(violating) > 0: |
| raise ValueError(f"CRITICAL ERROR: Look-ahead bias detected in {len(violating)} rows!") |
| return True |
|
|