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