diff --git a/pipeline/alpha/compute.py b/pipeline/alpha/compute.py index 18b3846..53048cf 100644 --- a/pipeline/alpha/compute.py +++ b/pipeline/alpha/compute.py @@ -83,7 +83,10 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict: 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]) / sum(abs(weight[s,t])) + 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. @@ -101,23 +104,40 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict: index="date", columns="symbol_id", values="weight", aggfunc="first" ).sort_index() - # Align dates + # 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] - returns = returns.loc[common_dates] + fwd_returns = returns.shift(-1).reindex(common_dates) - if len(common_dates) < 2: + 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": len(common_dates), + "n_dates": 0, } - # Daily portfolio return = sum(w * r) / sum(|w|) — normalized by gross exposure - daily_returns = (weights * returns).sum(axis=1) / weights.abs().sum(axis=1) + # 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) @@ -130,7 +150,7 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict: # 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 = weights.abs().sum(axis=1).shift(1) + gross_exposure = gross.shift(1) daily_turnover = weight_change / gross_exposure turnover_annual = float(daily_turnover.mean() * 252) @@ -149,5 +169,5 @@ def evaluate_alpha(alpha_df: pd.DataFrame, data_df: pd.DataFrame) -> dict: "turnover_annual": turnover_annual, "max_drawdown": max_drawdown, "hit_rate": hit_rate, - "n_dates": len(common_dates), + "n_dates": int(len(daily_returns)), } diff --git a/pipeline/alpha/library/momentum.py b/pipeline/alpha/library/momentum.py index b72ef67..aa04f67 100644 --- a/pipeline/alpha/library/momentum.py +++ b/pipeline/alpha/library/momentum.py @@ -15,4 +15,4 @@ class MomentumAlpha(BaseAlpha): self.lookback = lookback def signal(self, close: pd.DataFrame) -> pd.DataFrame: - return close.pct_change(self.lookback) + return close.pct_change(self.lookback, fill_method=None) diff --git a/pipeline/alpha/library/reversal.py b/pipeline/alpha/library/reversal.py index 08be14b..b2a006c 100644 --- a/pipeline/alpha/library/reversal.py +++ b/pipeline/alpha/library/reversal.py @@ -15,4 +15,4 @@ class ReversalAlpha(BaseAlpha): self.lookback = lookback def signal(self, close: pd.DataFrame) -> pd.DataFrame: - return -close.pct_change(self.lookback) + return -close.pct_change(self.lookback, fill_method=None) diff --git a/pipeline/alpha/library/reversal_vol.py b/pipeline/alpha/library/reversal_vol.py index 8cef7ba..fb0dac3 100644 --- a/pipeline/alpha/library/reversal_vol.py +++ b/pipeline/alpha/library/reversal_vol.py @@ -21,6 +21,6 @@ class ReversalVolAlpha(BaseAlpha): self.vol_window = vol_window def signal(self, close: pd.DataFrame) -> pd.DataFrame: - reversal = -close.pct_change(self.lookback) - vol = close.pct_change().rolling(self.vol_window).std() + reversal = -close.pct_change(self.lookback, fill_method=None) + vol = close.pct_change(fill_method=None).rolling(self.vol_window).std() return reversal / vol diff --git a/pipeline/portfolio/research.py b/pipeline/portfolio/research.py index bbe25df..b986844 100644 --- a/pipeline/portfolio/research.py +++ b/pipeline/portfolio/research.py @@ -35,7 +35,7 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic close = data_df.pivot_table( index="date", columns="symbol_id", values="close", aggfunc="first" ).sort_index() - returns = close.pct_change() + returns = close.pct_change(fill_method=None) weights = positions_df.pivot_table( index="date", columns="symbol_id", values="target_weight", aggfunc="first" @@ -43,22 +43,29 @@ def evaluate_portfolio(positions_df: pd.DataFrame, data_df: pd.DataFrame) -> dic common = weights.index.intersection(returns.index) weights = weights.loc[common] - returns = returns.loc[common] + # Compute forward returns on the full market calendar before selecting + # signal dates. This preserves next-period returns when the signal grid is + # sparser than the data grid. + fwd = returns.shift(-1).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) < 3: + if len(common) < 1: + empty["n_dates"] = 0 return empty gross = weights.abs().sum(axis=1) - # Weights at t earn the return from t to t+1: shift returns back by one. - fwd = returns.shift(-1) - daily = (weights * fwd).sum(axis=1) / gross.replace(0.0, np.nan) + # Weights at t earn the return from t to t+1. + 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) diff --git a/tests/test_alpha.py b/tests/test_alpha.py index 9da04ef..d7cb522 100644 --- a/tests/test_alpha.py +++ b/tests/test_alpha.py @@ -51,7 +51,7 @@ def test_reversal_sign_matches_negative_trailing_return(): data = _make_data() alpha = compute_alpha(data, "rev5", "reversal", lookback=5) close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index() - raw = -close.pct_change(5) + raw = -close.pct_change(5, fill_method=None) last = raw.index[-1] expected_top = raw.loc[last].idxmax() got = alpha[alpha["date"] == last].set_index("symbol_id")["weight"].idxmax() @@ -74,6 +74,83 @@ def test_evaluate_alpha_keys(): assert key in metrics +def test_evaluate_alpha_uses_next_period_returns(): + dates = pd.date_range("2024-01-01", periods=4) + data = pd.concat([ + pd.DataFrame({ + "symbol_id": "sh600000", + "symbol_name": "sh600000", + "date": dates, + "open": [100.0, 200.0, 200.0, 200.0], + "high": [100.0, 200.0, 200.0, 200.0], + "low": [100.0, 200.0, 200.0, 200.0], + "close": [100.0, 200.0, 200.0, 200.0], + "volume": 1_000.0, + "amount": 1_000.0, + }), + pd.DataFrame({ + "symbol_id": "sz000001", + "symbol_name": "sz000001", + "date": dates, + "open": [100.0, 100.0, 200.0, 200.0], + "high": [100.0, 100.0, 200.0, 200.0], + "low": [100.0, 100.0, 200.0, 200.0], + "close": [100.0, 100.0, 200.0, 200.0], + "volume": 1_000.0, + "amount": 1_000.0, + }), + ], ignore_index=True) + alpha = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"], + "date": [dates[1], dates[1], dates[2], dates[2]], + "alpha_name": ["toy"] * 4, + "weight": [-1.0, 1.0, 1.0, -1.0], + }) + + metrics = evaluate_alpha(alpha, data) + + assert metrics["n_dates"] == 2 + assert np.isclose(metrics["cumulative_return"], 0.5) + + +def test_evaluate_alpha_excludes_signal_without_forward_return(): + dates = pd.date_range("2024-01-01", periods=3) + data = pd.concat([ + pd.DataFrame({ + "symbol_id": "sh600000", + "symbol_name": "sh600000", + "date": dates, + "open": [100.0, 100.0, 200.0], + "high": [100.0, 100.0, 200.0], + "low": [100.0, 100.0, 200.0], + "close": [100.0, 100.0, 200.0], + "volume": 1_000.0, + "amount": 1_000.0, + }), + pd.DataFrame({ + "symbol_id": "sz000001", + "symbol_name": "sz000001", + "date": dates, + "open": [100.0, 100.0, 100.0], + "high": [100.0, 100.0, 100.0], + "low": [100.0, 100.0, 100.0], + "close": [100.0, 100.0, 100.0], + "volume": 1_000.0, + "amount": 1_000.0, + }), + ], ignore_index=True) + alpha = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"], + "date": [dates[1], dates[1], dates[2], dates[2]], + "alpha_name": ["toy"] * 4, + "weight": [1.0, -1.0, -1.0, 1.0], + }) + + metrics = evaluate_alpha(alpha, data) + + assert metrics["n_dates"] == 1 + + def test_equal_weight_is_mean_of_alphas(): data = _make_data() a = compute_alpha(data, "rev", "reversal", lookback=5) @@ -163,7 +240,7 @@ def test_load_external_alpha_module(tmp_path): self.span = span def signal(self, close: pd.DataFrame) -> pd.DataFrame: - return -close.pct_change(self.span) + return -close.pct_change(self.span, fill_method=None) ''')) load_alpha_module(str(module_path)) diff --git a/tests/test_portfolio.py b/tests/test_portfolio.py index af25725..64d2ddf 100644 --- a/tests/test_portfolio.py +++ b/tests/test_portfolio.py @@ -65,7 +65,7 @@ def _make_data(n_days: int = 40, symbols=_SYMBOLS, start="2024-01-01", def _make_weights(data: pd.DataFrame, name="combo") -> pd.DataFrame: """Demeaned per-date signed weights so the cross-section is dollar-neutral.""" close = data.pivot_table(index="date", columns="symbol_id", values="close").sort_index() - raw = -close.pct_change(5) + raw = -close.pct_change(5, fill_method=None) demeaned = raw.sub(raw.mean(axis=1), axis=0) long = demeaned.reset_index().melt(id_vars="date", var_name="symbol_id", value_name="weight").dropna() @@ -535,3 +535,27 @@ def test_evaluate_portfolio_keys_no_ic(): assert key in metrics assert "ic" not in metrics assert "rank_ic" not in metrics + + +def test_evaluate_portfolio_excludes_signal_without_forward_return(): + dates = pd.date_range("2024-01-01", periods=3) + data = pd.DataFrame([ + {"symbol_id": sym, "date": d, "close": price} + for d, prices in zip(dates, [(100.0, 100.0), (100.0, 100.0), (200.0, 100.0)]) + for sym, price in zip(("sh600000", "sz000001"), prices) + ]) + positions = pd.DataFrame({ + "symbol_id": ["sh600000", "sz000001", "sh600000", "sz000001"], + "date": [dates[1], dates[1], dates[2], dates[2]], + "portfolio_name": ["run1"] * 4, + "target_weight": [0.5, -0.5, -0.5, 0.5], + "target_value": [500.0, -500.0, -500.0, 500.0], + "target_shares": [5.0, -5.0, -2.5, 5.0], + "position_shares": [5, -5, -2, 5], + "position_value": [500.0, -500.0, -400.0, 500.0], + "price": [100.0, 100.0, 200.0, 100.0], + }) + + metrics = evaluate_portfolio(positions, data) + + assert metrics["n_dates"] == 1