Files
2026-07-13 13:26:28 +08:00

594 lines
22 KiB
Python

"""Shared allocation functions for Ch17 portfolio construction.
Each function takes (predictions, prices_df, top_k, ...) and returns
pl.DataFrame with columns [time_col, symbol, weight].
Predictions must have columns: [time_col, symbol, y_score].
Prices must have columns: [time_col, symbol, close] or [time_col, symbol, ret].
"""
from __future__ import annotations
import numpy as np
import polars as pl
from scipy.cluster.hierarchy import leaves_list, linkage
from scipy.spatial.distance import squareform
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _select_top_bottom(
predictions: pl.DataFrame,
top_k: int,
long_short: bool,
time_col: str = "timestamp",
score_col: str = "y_score",
) -> pl.DataFrame:
"""Rank cross-sectionally and select top-K (and bottom-K if long_short)."""
ranked = predictions.with_columns(
cs_rank=pl.col(score_col).rank(method="ordinal", descending=True).over(time_col),
n_assets=pl.col(score_col).count().over(time_col),
)
if long_short:
selected = ranked.filter(
(pl.col("cs_rank") <= top_k) | (pl.col("cs_rank") > pl.col("n_assets") - top_k)
).with_columns(
side=pl.when(pl.col("cs_rank") <= top_k).then(pl.lit("long")).otherwise(pl.lit("short"))
)
else:
selected = ranked.filter(pl.col("cs_rank") <= top_k).with_columns(side=pl.lit("long"))
return selected
def _filter_prices_to_prediction_assets(
prices_df: pl.DataFrame,
predictions: pl.DataFrame,
asset_col: str = "symbol",
) -> pl.DataFrame:
"""Pre-filter prices to only assets in predictions (performance optimization)."""
pred_assets = predictions[asset_col].unique()
return prices_df.filter(pl.col(asset_col).is_in(pred_assets))
def _returns_from_prices(
prices_df: pl.DataFrame,
time_col: str = "timestamp",
asset_col: str = "symbol",
) -> pl.DataFrame:
"""Extract returns from prices: use 'ret' if available, else pct_change('close')."""
if "ret" in prices_df.columns:
return prices_df.select([time_col, asset_col, "ret"])
return (
prices_df.sort(time_col, asset_col)
.with_columns(ret=pl.col("close").pct_change().over(asset_col))
.select([time_col, asset_col, "ret"])
)
def _compute_rolling_vol(
prices_df: pl.DataFrame,
vol_window: int = 63,
time_col: str = "timestamp",
asset_col: str = "symbol",
target_dtype: pl.DataType | None = None,
) -> pl.DataFrame:
"""Compute rolling volatility from daily returns."""
returns = _returns_from_prices(prices_df, time_col, asset_col)
result = returns.with_columns(vol=pl.col("ret").rolling_std(vol_window).over(asset_col)).select(
[time_col, asset_col, "vol"]
)
if target_dtype is not None and result[time_col].dtype != target_dtype:
result = result.cast({time_col: target_dtype})
return result
def _normalize_within_sides(
selected: pl.DataFrame,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""Normalize inverse-vol weights within long/short sides separately."""
selected = selected.with_columns(inv_vol=1.0 / pl.col("vol").clip(lower_bound=1e-6))
long_w = selected.filter(pl.col("side") == "long").with_columns(
weight=pl.col("inv_vol") / pl.col("inv_vol").sum().over(time_col)
)
short_w = selected.filter(pl.col("side") == "short").with_columns(
weight=-pl.col("inv_vol") / pl.col("inv_vol").sum().over(time_col)
)
parts = [long_w]
if short_w.height > 0:
parts.append(short_w)
return pl.concat(parts, how="diagonal_relaxed")
def _cap_weights(
df: pl.DataFrame,
max_weight: float,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""Cap per-asset weight and redistribute excess proportionally.
Iterates until no weight exceeds max_weight (handles cascading overflow).
Operates on long side only; short side is handled symmetrically.
"""
if max_weight >= 1.0:
return df
for _ in range(20): # convergence guard
over = df.filter(pl.col("weight").abs() > max_weight + 1e-9)
if over.is_empty():
break
df = df.with_columns(
clipped=pl.col("weight").clip(-max_weight, max_weight),
)
# Redistribute excess within each timestamp
excess_per_ts = df.group_by(time_col).agg(
excess=(pl.col("weight") - pl.col("clipped")).sum()
)
n_uncapped = (
df.filter(pl.col("weight").abs() <= max_weight).group_by(time_col).agg(n_free=pl.len())
)
adj = excess_per_ts.join(n_uncapped, on=time_col, how="left").with_columns(
bump=(pl.col("excess") / pl.col("n_free").fill_null(1)).fill_null(0.0)
)
df = df.join(adj.select([time_col, "bump"]), on=time_col, how="left")
df = df.with_columns(
weight=pl.when(pl.col("weight").abs() <= max_weight)
.then(pl.col("clipped") + pl.col("bump"))
.otherwise(pl.col("clipped"))
).drop(["clipped", "bump"])
return df
def _cluster_var(cov: np.ndarray, indices: list[int]) -> float:
"""Cluster variance: inverse-vol portfolio variance within cluster."""
sub_cov = cov[np.ix_(indices, indices)]
diag = np.clip(np.diag(sub_cov), 1e-10, None)
inv_vol = 1.0 / np.sqrt(diag)
w = inv_vol / inv_vol.sum()
return float(w @ sub_cov @ w)
def _hrp_weights(cov_matrix: np.ndarray, corr_matrix: np.ndarray) -> np.ndarray:
"""HRP weights via Lopez de Prado (2016): cluster, quasi-diag, bisect."""
n = cov_matrix.shape[0]
if n <= 1:
return np.ones(n)
# Correlation distance
dist = np.sqrt(0.5 * (1 - corr_matrix))
np.fill_diagonal(dist, 0)
dist = np.clip(dist, 0, None)
try:
condensed = squareform(dist, checks=False)
link = linkage(condensed, method="single")
except Exception:
return np.ones(n) / n
# Quasi-diagonalize
sort_ix = leaves_list(link).tolist()
# Recursive bisection
weights = np.ones(n)
cluster_items = [sort_ix]
while cluster_items:
new_clusters = []
for items in cluster_items:
if len(items) <= 1:
continue
mid = len(items) // 2
left, right = items[:mid], items[mid:]
left_var = _cluster_var(cov_matrix, left)
right_var = _cluster_var(cov_matrix, right)
alpha = 1 - left_var / (left_var + right_var) if (left_var + right_var) > 0 else 0.5
weights[left] *= alpha
weights[right] *= 1 - alpha
if len(left) > 1:
new_clusters.append(left)
if len(right) > 1:
new_clusters.append(right)
cluster_items = new_clusters
weights /= weights.sum()
return weights
# ---------------------------------------------------------------------------
# Public allocation functions
# ---------------------------------------------------------------------------
def compute_conformal_weights(
predictions: pl.DataFrame,
conformal_widths: pl.DataFrame,
top_k: int,
long_short: bool = False,
*,
floor_quantile: float = 0.01,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""Conformal inverse-width position sizing.
Selects top-K by ``y_score`` and weights each selected asset by 1/Δ_i,
normalized within each side (long/short) so the leg sums to ±1. Widths
come from ``case_studies.utils.conformal.compute_conformal_widths`` and
are joined on (timestamp, symbol). Assets without a calibrated width at
that timestamp are dropped from the leg (the inverse-width sum
renormalizes accordingly).
A small floor at ``floor_quantile`` of the in-sample width distribution
prevents 1/Δ blow-up when residuals happen to be identical.
"""
selected = _select_top_bottom(predictions, top_k, long_short, time_col)
widths = conformal_widths.select(time_col, "symbol", "width")
# Harmonize join dtypes to predictions/weights.
if widths[time_col].dtype != selected[time_col].dtype:
widths = widths.cast({time_col: selected[time_col].dtype})
if widths["symbol"].dtype != selected["symbol"].dtype:
widths = widths.cast({"symbol": selected["symbol"].dtype})
selected = selected.join(widths, on=[time_col, "symbol"], how="inner")
if selected.is_empty():
raise ValueError(
"conformal_weighted: empty join between selected top-K predictions "
"and conformal_widths. Likely cause: widths not computed for this "
"prediction_hash, or fold_id range mismatch. Run "
"compute_conformal_widths() before backtest."
)
floor = float(selected["width"].quantile(floor_quantile))
floor = max(floor, 1e-12)
selected = selected.with_columns(inv_w=1.0 / pl.max_horizontal(pl.col("width"), pl.lit(floor)))
long_w = selected.filter(pl.col("side") == "long").with_columns(
weight=pl.col("inv_w") / pl.col("inv_w").sum().over(time_col)
)
parts = [long_w]
if long_short:
short_w = selected.filter(pl.col("side") == "short").with_columns(
weight=-pl.col("inv_w") / pl.col("inv_w").sum().over(time_col)
)
if short_w.height > 0:
parts.append(short_w)
result = pl.concat(parts, how="diagonal_relaxed")
return result.select([time_col, "symbol", "weight"]).filter(pl.col("weight") != 0.0)
def compute_inverse_vol_weights(
predictions: pl.DataFrame,
prices_df: pl.DataFrame,
top_k: int,
vol_window: int = 63,
long_short: bool = False,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""Inverse-volatility weighting: select top-K by score, weight by 1/vol.
Normalizes weights within each side (long/short) separately.
"""
selected = _select_top_bottom(predictions, top_k, long_short, time_col)
_prices = _filter_prices_to_prediction_assets(prices_df, predictions)
vol = _compute_rolling_vol(
_prices, vol_window, time_col, target_dtype=predictions[time_col].dtype
)
selected = selected.join(vol, on=[time_col, "symbol"], how="left").with_columns(
pl.col("vol").fill_null(pl.col("vol").median())
)
result = _normalize_within_sides(selected, time_col)
return result.select([time_col, "symbol", "weight"]).filter(pl.col("weight") != 0.0)
def compute_risk_parity_weights(
predictions: pl.DataFrame,
prices_df: pl.DataFrame,
top_k: int,
vol_window: int = 63,
long_short: bool = False,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""Simplified risk-parity (approximate ERC) using vol^1.5 exponent.
Uses inverse-vol^1.5 as a proxy for equal risk contribution --- accounts
for the empirical relationship between volatility and correlation.
"""
selected = _select_top_bottom(predictions, top_k, long_short, time_col)
_prices = _filter_prices_to_prediction_assets(prices_df, predictions)
vol = _compute_rolling_vol(
_prices, vol_window, time_col, target_dtype=predictions[time_col].dtype
)
selected = selected.join(vol, on=[time_col, "symbol"], how="left").with_columns(
pl.col("vol").fill_null(pl.col("vol").median())
)
# Risk-parity approximation: w_i proportional to 1 / vol_i^1.5
selected = selected.with_columns(inv_vol=1.0 / (pl.col("vol").clip(lower_bound=1e-6) ** 1.5))
long_w = selected.filter(pl.col("side") == "long").with_columns(
weight=pl.col("inv_vol") / pl.col("inv_vol").sum().over(time_col)
)
parts = [long_w]
if long_short:
short_w = selected.filter(pl.col("side") == "short").with_columns(
weight=-pl.col("inv_vol") / pl.col("inv_vol").sum().over(time_col)
)
if short_w.height > 0:
parts.append(short_w)
result = pl.concat(parts, how="diagonal_relaxed")
return result.select([time_col, "symbol", "weight"]).filter(pl.col("weight") != 0.0)
def compute_mvo_weights(
predictions: pl.DataFrame,
prices_df: pl.DataFrame,
top_k: int,
lookback: int = 126,
max_weight: float = 0.15,
long_short: bool = False,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""MVO with Ledoit-Wolf shrinkage and position cap.
At each rebalance date:
1. Select top-K assets by score
2. Estimate covariance via LedoitWolf
3. Use ML z-scores as expected returns
4. Solve constrained QP: max Sharpe s.t. position caps
"""
from scipy.optimize import minimize
from sklearn.covariance import LedoitWolf
selected = _select_top_bottom(predictions, top_k, long_short, time_col)
# Pre-filter prices to prediction assets (performance: avoids pct_change on full universe)
_prices = _filter_prices_to_prediction_assets(prices_df, predictions)
# Cast prices time column to match predictions dtype
if _prices[time_col].dtype != predictions[time_col].dtype:
_prices = _prices.cast({time_col: predictions[time_col].dtype})
rets = _returns_from_prices(_prices, time_col)
all_timestamps = selected[time_col].unique().sort().to_list()
rows = []
for ts in all_timestamps:
ts_selected = selected.filter(pl.col(time_col) == ts)
assets = ts_selected["symbol"].to_list()
scores = ts_selected.select(["symbol", "y_score"])
side_map = dict(
zip(ts_selected["symbol"].to_list(), ts_selected["side"].to_list(), strict=False)
)
if len(assets) < 3:
continue
recent = rets.filter((pl.col(time_col) <= ts) & pl.col("symbol").is_in(assets)).sort(
time_col
)
recent_dates = recent[time_col].unique().sort()
if len(recent_dates) > lookback:
recent = recent.filter(pl.col(time_col).is_in(recent_dates.tail(lookback)))
window_rets = (
recent.pivot(on="symbol", index=time_col, values="ret").sort(time_col).drop(time_col)
)
if window_rets.height < lookback // 2:
if long_short:
long_assets = [a for a in assets if side_map.get(a) == "long"]
short_assets = [a for a in assets if side_map.get(a) == "short"]
if long_assets:
lw = 1.0 / len(long_assets)
for a in long_assets:
rows.append({time_col: ts, "symbol": a, "weight": lw})
if short_assets:
sw = -1.0 / len(short_assets)
for a in short_assets:
rows.append({time_col: ts, "symbol": a, "weight": sw})
else:
w = 1.0 / len(assets)
for a in assets:
rows.append({time_col: ts, "symbol": a, "weight": w})
continue
ret_matrix = window_rets.to_numpy()
valid_mask = ~np.all(np.isnan(ret_matrix), axis=0)
valid_assets = [a for a, v in zip(window_rets.columns, valid_mask, strict=False) if v]
ret_matrix = ret_matrix[:, valid_mask]
ret_matrix = ret_matrix[~np.any(np.isnan(ret_matrix), axis=1)]
min_obs = max(top_k, lookback // 2)
if ret_matrix.shape[0] < min_obs or ret_matrix.shape[1] < 3:
if long_short:
long_assets = [a for a in assets if side_map.get(a) == "long"]
short_assets = [a for a in assets if side_map.get(a) == "short"]
if long_assets:
lw = 1.0 / len(long_assets)
for a in long_assets:
rows.append({time_col: ts, "symbol": a, "weight": lw})
if short_assets:
sw = -1.0 / len(short_assets)
for a in short_assets:
rows.append({time_col: ts, "symbol": a, "weight": sw})
else:
w = 1.0 / len(assets)
for a in assets:
rows.append({time_col: ts, "symbol": a, "weight": w})
continue
cov = LedoitWolf().fit(ret_matrix).covariance_
score_map = dict(zip(scores["symbol"].to_list(), scores["y_score"].to_list(), strict=False))
mu = np.array([score_map.get(a, 0.0) for a in valid_assets])
mu_std = mu.std()
if mu_std > 0:
mu = (mu - mu.mean()) / mu_std
n = len(valid_assets)
def neg_sharpe(w, mu=mu, cov=cov):
port_ret = w @ mu
port_vol = np.sqrt(w @ cov @ w)
return -port_ret / max(port_vol, 1e-8)
if long_short:
bounds = [(-max_weight, max_weight)] * n
constraints = [{"type": "eq", "fun": lambda w: np.sum(w)}] # dollar neutral
w0 = mu / max(np.abs(mu).sum(), 1e-8)
else:
bounds = [(0.0, max_weight)] * n
constraints = [{"type": "eq", "fun": lambda w: np.sum(w) - 1.0}]
w0 = np.ones(n) / n
result = minimize(
neg_sharpe,
w0,
method="SLSQP",
bounds=bounds,
constraints=constraints,
options={"maxiter": 500, "ftol": 1e-10},
)
w_opt = result.x if result.success else w0
if long_short:
w_sum = np.abs(w_opt).sum()
if w_sum > 0:
w_opt = w_opt / w_sum
else:
w_opt = np.maximum(w_opt, 0)
w_opt /= w_opt.sum()
for a, w in zip(valid_assets, w_opt, strict=False):
if abs(w) > 1e-6:
rows.append({time_col: ts, "symbol": a, "weight": float(w)})
if not rows:
return pl.DataFrame(
schema={
time_col: predictions[time_col].dtype,
"symbol": pl.String,
"weight": pl.Float64,
}
)
return pl.DataFrame(rows).sort(time_col, "symbol")
def compute_hrp_weights(
predictions: pl.DataFrame,
prices_df: pl.DataFrame,
top_k: int,
vol_window: int = 63,
long_short: bool = False,
min_coverage: float = 0.5,
time_col: str = "timestamp",
) -> pl.DataFrame:
"""HRP allocation (Lopez de Prado, 2016).
Applies HRP separately to long and short legs using a rolling
correlation window. Falls back to equal-weight if insufficient history.
Bug fix vs original: drops assets with <min_coverage observations in the
rolling window instead of fill_null(0.0), which corrupted the covariance
matrix for sparse panels.
"""
selected = _select_top_bottom(predictions, top_k, long_short, time_col)
# Pre-filter prices to prediction assets (performance: avoids pct_change on full universe)
_prices = _filter_prices_to_prediction_assets(prices_df, predictions)
# Cast prices time column to match predictions dtype
if _prices[time_col].dtype != predictions[time_col].dtype:
_prices = _prices.cast({time_col: predictions[time_col].dtype})
returns = _returns_from_prices(_prices, time_col)
timestamps = sorted(selected[time_col].unique().to_list())
all_weights: list[dict] = []
for ts in timestamps:
for side_label, sign in [("long", 1.0), ("short", -1.0)]:
if side_label == "short" and not long_short:
continue
side_assets = selected.filter(
(pl.col(time_col) == ts) & (pl.col("side") == side_label)
)["symbol"].to_list()
if not side_assets:
continue
# Get recent returns for these assets
recent = returns.filter(
(pl.col(time_col) <= ts) & (pl.col("symbol").is_in(side_assets))
).sort(time_col)
recent_dates = recent[time_col].unique().sort()
if len(recent_dates) > vol_window:
recent = recent.filter(pl.col(time_col).is_in(recent_dates.tail(vol_window)))
# Pivot to wide format — drop assets with insufficient coverage
pivot = recent.pivot(on="symbol", index=time_col, values="ret").drop(time_col)
if pivot.shape[0] < 20 or pivot.shape[1] < 2:
w = 1.0 / len(side_assets)
for a in side_assets:
all_weights.append({time_col: ts, "symbol": a, "weight": sign * w})
continue
# Drop columns (assets) with <50% non-null coverage in the window
min_obs = int(pivot.shape[0] * min_coverage)
valid_cols = [c for c in pivot.columns if pivot[c].drop_nulls().len() >= min_obs]
if len(valid_cols) < 2:
w = 1.0 / len(side_assets)
for a in side_assets:
all_weights.append({time_col: ts, "symbol": a, "weight": sign * w})
continue
# Use only valid columns, drop rows with any remaining NaN
ret_matrix = pivot.select(valid_cols).drop_nulls().to_numpy()
if ret_matrix.shape[0] < 20 or ret_matrix.shape[1] < 2:
w = 1.0 / len(side_assets)
for a in side_assets:
all_weights.append({time_col: ts, "symbol": a, "weight": sign * w})
continue
cov = np.cov(ret_matrix.T)
std = np.sqrt(np.clip(np.diag(cov), 1e-16, None))
corr = cov / np.outer(std, std)
corr = np.clip(corr, -1, 1)
hrp_w = _hrp_weights(cov, corr)
# Assign HRP weights to valid assets; equal-weight the rest
hrp_asset_map = dict(zip(valid_cols, hrp_w, strict=False))
remaining = [a for a in side_assets if a not in hrp_asset_map]
# Rescale: HRP assets get their share, remaining get residual
if remaining:
hrp_total = sum(hrp_asset_map.values())
remain_w = (1.0 - hrp_total) / len(remaining) if hrp_total < 1.0 else 0.0
for a in remaining:
all_weights.append({time_col: ts, "symbol": a, "weight": sign * remain_w})
for a, w in hrp_asset_map.items():
all_weights.append({time_col: ts, "symbol": a, "weight": sign * w})
if not all_weights:
return pl.DataFrame(
schema={time_col: pl.Datetime, "symbol": pl.String, "weight": pl.Float64}
)
return pl.DataFrame(all_weights).sort(time_col, "symbol")