"""Layer-1 (research) evaluation of a portfolio's target weights. This is the WorldQuant-style research view: continuous target weights, no lot or trading constraints. Metrics are return / Sharpe / turnover / max-drawdown / **Fitness**. There is deliberately **no IC/IR** — consistent with the repo's convention that an alpha is a position weight, not a return predictor. Return convention (documented): the target weight formed from information at date ``t`` is assumed tradable at ``open[t+1]`` and held until ``open[t+2]``. This is a costless approximation of the next-open execution path: no lots, constraints, or costs, but no credit for an overnight gap that the new signal could not have owned. """ from __future__ import annotations import numpy as np import pandas as pd #: WorldQuant fitness floor on turnover (avoids dividing by ~0 turnover). _TURNOVER_FLOOR = 0.125 def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dict: """Evaluate target weights as a continuous research portfolio. Args: positions_df: POSITION_COLUMNS (uses ``target_weight``; zero-gross construction carry dates remain flat in this research view). data_df: DATA_COLUMNS (uses ``open`` for returns). Returns: Dict with ``cumulative_return, sharpe_annual, turnover_annual, max_drawdown, fitness, hit_rate, n_dates``. No IC key. """ open_ = data_df.pivot_table( index="date", columns="symbol_id", values="open", aggfunc="first" ).sort_index() fwd = open_.shift(-2).divide(open_.shift(-1)) - 1.0 weights = positions_df.pivot_table( index="date", columns="symbol_id", values="target_weight", aggfunc="first" ).sort_index() common = weights.index.intersection(open_.index) weights = weights.loc[common] # Compute forward returns on the full market calendar before selecting # signal dates. This preserves the next available open-to-open holding # interval when the signal grid is sparser than the data grid. fwd = fwd.reindex(common) empty = { "cumulative_return": 0.0, "sharpe_annual": 0.0, "turnover_annual": 0.0, "max_drawdown": 0.0, "fitness": 0.0, "hit_rate": 0.0, "n_dates": len(common), } if len(common) < 1: empty["n_dates"] = 0 return empty gross = weights.abs().sum(axis=1) # Weights at t earn the costless tradable interval open[t+1] -> open[t+2]. daily = ( (weights * fwd).sum(axis=1, min_count=1) / gross.replace(0.0, np.nan) ) daily = daily.dropna() if len(daily) < 2: empty["n_dates"] = int(len(daily)) return empty cumulative_return = float((1.0 + daily).prod() - 1.0) mu, sigma = daily.mean(), daily.std() sharpe_annual = float(np.sqrt(252) * mu / sigma) if sigma > 0 else 0.0 weight_change = weights.diff().abs().sum(axis=1) prev_gross = gross.shift(1) daily_turnover = (weight_change / prev_gross.replace(0.0, np.nan)).dropna() turnover_annual = float(daily_turnover.mean() * 252) equity = (1.0 + daily).cumprod() drawdown = (equity - equity.cummax()) / equity.cummax() max_drawdown = float(drawdown.min()) # Fitness = Sharpe · sqrt(|annualized return| / max(annual turnover, floor)). ann_return = float(mu * 252) denom = max(turnover_annual, _TURNOVER_FLOOR) fitness = float(sharpe_annual * np.sqrt(abs(ann_return) / denom)) if denom > 0 else 0.0 return { "cumulative_return": cumulative_return, "sharpe_annual": sharpe_annual, "turnover_annual": turnover_annual, "max_drawdown": max_drawdown, "fitness": fitness, "hit_rate": float((daily > 0).mean()), "n_dates": int(len(daily)), }