99 lines
3.7 KiB
Python
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)),
|
|
}
|