Files
chinese-equity-quant/pipeline/portfolio/research.py
T
2026-06-12 18:41:18 +08:00

99 lines
3.7 KiB
Python

"""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)),
}