"""Alpha computation and evaluation. Alphas are position WEIGHTS — positive=long, negative=short. They are NOT predictors of future returns. Concrete alphas are classes that live in ``pipeline/alpha/library/`` (or any external module) and are resolved by name through :mod:`pipeline.alpha.registry`. """ import logging import numpy as np import pandas as pd from pipeline.alpha.registry import get_alpha from pipeline.common.schema import ALPHA_COLUMNS logger = logging.getLogger(__name__) def _pivot_close(df: pd.DataFrame) -> pd.DataFrame: """Pivot data to wide format: date index, columns = symbol_id, values = close.""" pivot = df.pivot_table( index="date", columns="symbol_id", values="close", aggfunc="first" ) return pivot.sort_index() def _daily_returns(close: pd.DataFrame) -> pd.DataFrame: """Compute daily returns from wide close DataFrame.""" return close.pct_change(fill_method=None) def investable_universe_mask( data: pd.DataFrame, template: pd.DataFrame, *, top_n: int = 1000, min_history: int = 60, require_tradable: bool = True, exclude_st: bool = True, ) -> pd.DataFrame: """Build a per-date investable-universe mask aligned to ``template``. A ``(date, symbol_id)`` cell is ``True`` when the name is, on that date, seasoned (at least ``min_history`` prior closes), currently tradable (``tradestatus == 1``), not flagged ST (``isST == 0``), and inside the ``top_n`` most liquid names by trailing 20-day mean ``amount``. The mask is applied to the *signal* (computed on full contiguous prices), so it restricts only what is *held*, never the price history used to form the signal — that keeps ``pct_change`` correct and look-ahead free. Args: data: Long DataFrame with at least ``symbol_id``, ``date``, ``close``, ``amount``, ``isST``, ``tradestatus``. template: Wide signal (date index × ``symbol_id`` columns) to align to. top_n: Keep this many most-liquid names per date. min_history: Minimum number of observed closes before a name is eligible. require_tradable: Require ``tradestatus == 1`` on the date. exclude_st: Drop names flagged ``isST == 1``. Returns: Boolean wide DataFrame aligned to ``template``. """ def _wide(col: str) -> pd.DataFrame: return ( data.pivot_table(index="date", columns="symbol_id", values=col, aggfunc="first") .sort_index() .reindex(index=template.index, columns=template.columns) ) close = _wide("close") mask = close.notna() seasoned = close.notna().cumsum() >= min_history mask &= seasoned if exclude_st and "isST" in data.columns: mask &= _wide("isST").fillna(1) == 0 if require_tradable and "tradestatus" in data.columns: mask &= _wide("tradestatus").fillna(0) == 1 amount = _wide("amount") amt_ma = amount.rolling(20, min_periods=10).mean() liquid_rank = amt_ma.rank(axis=1, ascending=False) mask &= liquid_rank <= top_n return mask.fillna(False) def compute_alpha( data: pd.DataFrame, alpha_name: str, alpha_type: str, universe: dict | None = None, **params, ) -> pd.DataFrame: """Compute alpha weights from raw data. Args: data: DataFrame with DATA_COLUMNS. alpha_name: Label stored in the ``alpha_name`` output column. alpha_type: Registry key of the alpha class (e.g. ``reversal``). universe: Optional investable-universe filter. When given, the alpha's raw signal is masked to the investable set (see :func:`investable_universe_mask`) *before* it is turned into weights, so unheld names get weight 0. Keys are forwarded as keyword arguments to :func:`investable_universe_mask`. **params: Constructor parameters for the alpha (e.g. ``lookback``, ``vol_window``). Only the params the alpha's ``__init__`` accepts are used; extras are ignored. Returns: DataFrame with ALPHA_COLUMNS. Raises: KeyError: If ``alpha_type`` is not registered. """ alpha = get_alpha(alpha_type, **params) close = _pivot_close(data) if universe is None: weights = alpha.weights(close) else: signal = alpha.signal(close) mask = investable_universe_mask(data, signal, **universe) weights = alpha.to_weights(signal.where(mask)) # Melt to long format weights_melted = weights.reset_index().melt( id_vars="date", var_name="symbol_id", value_name="weight" ) weights_melted["alpha_name"] = alpha_name weights_melted = weights_melted[ALPHA_COLUMNS] weights_melted = weights_melted.dropna(subset=["weight"]) weights_melted = weights_melted.sort_values(["symbol_id", "date"]).reset_index(drop=True) logger.info( "Alpha '%s' (%r): %d symbols × %d dates, weight range [%.4f, %.4f]", alpha_name, alpha, weights_melted["symbol_id"].nunique(), weights_melted["date"].nunique(), weights_melted["weight"].min(), weights_melted["weight"].max(), ) return weights_melted def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict: """Evaluate an alpha's performance as position weights. Computes return, annualized Sharpe, annualized turnover, max drawdown. Alpha is interpreted as POSITION WEIGHTS, not predictions. Return on date t = sum(weight[s,t] * realized_return[s,t+1]) / sum(abs(weight[s,t])). This matches the close-derived signal convention: weights formed with close[t] earn the next close-to-close return, avoiding look-ahead. Args: alpha_df: DataFrame with ALPHA_COLUMNS. data_df: DataFrame with DATA_COLUMNS (for price data). Returns: Dict with metrics: cumulative_return, sharpe_annual, turnover_annual, max_drawdown, hit_rate, n_dates. """ close = _pivot_close(data_df) returns = _daily_returns(close) # Pivot alpha weights to wide format weights = alpha_df.pivot_table( index="date", columns="symbol_id", values="weight", aggfunc="first" ).sort_index() # Align weights to signal dates that exist on the market calendar. Compute # forward returns on the full market calendar first, so sparse signal grids # still earn the next available data date instead of the next signal date. common_dates = weights.index.intersection(returns.index) weights = weights.loc[common_dates] fwd_returns = returns.shift(-1).reindex(common_dates) if len(common_dates) < 1: return { "cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0, "max_drawdown": 0.0, "hit_rate": 0.0, "n_dates": 0, } # Daily portfolio return = sum(w_t * r_{t+1}) / sum(|w_t|). # The last signal date has no next-period return and is dropped below. gross = weights.abs().sum(axis=1) daily_returns = ( (weights * fwd_returns).sum(axis=1, min_count=1) / gross.replace(0.0, np.nan) ) daily_returns = daily_returns.dropna() if len(daily_returns) < 2: return { "cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0, "max_drawdown": 0.0, "hit_rate": 0.0, "n_dates": int(len(daily_returns)), } # Cumulative return cumulative_return = float((1.0 + daily_returns).prod() - 1.0) # Annualized Sharpe (sqrt(252) * mean / std) mu = daily_returns.mean() sigma = daily_returns.std() sharpe_annual = float(np.sqrt(252) * mu / sigma) if sigma > 0 else 0.0 # Annualized turnover: avg daily turnover * 252 # Daily turnover = sum(|w_t - w_{t-1}|) / sum(|w_{t-1}|) weight_change = weights.diff().abs().sum(axis=1) gross_exposure = gross.shift(1) daily_turnover = weight_change / gross_exposure turnover_annual = float(daily_turnover.mean() * 252) # Max drawdown equity = (1.0 + daily_returns).cumprod() peak = equity.cummax() drawdown = (equity - peak) / peak max_drawdown = float(drawdown.min()) # Hit rate hit_rate = float((daily_returns > 0).mean()) return { "cumulative_return": cumulative_return, "sharpe_annual": sharpe_annual, "turnover_annual": turnover_annual, "max_drawdown": max_drawdown, "hit_rate": hit_rate, "n_dates": int(len(daily_returns)), }