| | import torch |
| | import pandas as pd |
| | import numpy as np |
| | import matplotlib.pyplot as plt |
| | import matplotlib.dates as mdates |
| | import seaborn as sns |
| | import statsmodels.api as sm |
| | from tqdm import tqdm |
| |
|
| | |
| | eps = 1e-6 |
| |
|
| | |
| | def calculate_sortino( |
| | returns: torch.Tensor, |
| | min_acceptable_return: torch.Tensor |
| | ): |
| | """Calculates the Sortino ratio.""" |
| | if min_acceptable_return is not None: |
| | excess_returns = returns - min_acceptable_return |
| | else: |
| | |
| | excess_returns = returns |
| |
|
| | |
| | downside_returns = torch.where(excess_returns < 0, excess_returns, torch.tensor(0.0, device=returns.device)) |
| | downside_deviation = torch.std(downside_returns, dim=0) |
| | |
| | |
| | downside_deviation = torch.clamp(downside_deviation, min=eps) |
| | |
| | |
| | sortino = torch.mean(excess_returns, dim=0) / downside_deviation |
| | |
| | |
| | sortino = torch.clamp(sortino, min=-100.0, max=100.0) |
| | |
| | return sortino |
| |
|
| | def calculate_max_drawdown( |
| | returns: torch.Tensor |
| | ): |
| | """Calculates max drawdown for the duration of the returns passed. |
| | Max drawdown is defined to be positive, takes the range [0, \\infty). |
| | """ |
| | if returns.numel() == 0: |
| | return torch.tensor(0.0, device=returns.device) |
| | |
| | |
| | clean_returns = torch.nan_to_num(returns, nan=0.0) |
| | |
| | cum_returns = (clean_returns + 1).cumprod(dim=0) |
| | peak = torch.cummax(cum_returns, dim=0).values |
| | |
| | |
| | safe_peak = torch.clamp(peak, min=eps) |
| | |
| | drawdown = (peak - cum_returns) / safe_peak |
| | max_drawdown = torch.max(drawdown) |
| | |
| | |
| | max_drawdown = torch.clamp(max_drawdown, min=0.0, max=1.0) |
| | |
| | return max_drawdown |
| |
|
| | def calculate_turnover( |
| | new_weights: torch.Tensor, |
| | prev_weights: torch.Tensor |
| | ): |
| | """Turnover is defined as the sum of absolute differences |
| | between new and previous weights, divided by 2. |
| | Takes the range [0, \\infty). |
| | """ |
| | |
| | new_weights_safe = torch.nan_to_num(new_weights, nan=1.0/new_weights.size(0)) |
| | prev_weights_safe = torch.nan_to_num(prev_weights, nan=1.0/prev_weights.size(0)) |
| | |
| | turnover = torch.sum(torch.abs(new_weights_safe - prev_weights_safe)) / 2.0 |
| | |
| | |
| | turnover = torch.clamp(turnover, min=0.0, max=1.0) |
| | |
| | return turnover |
| |
|
| | def calculate_hhi(weights: torch.Tensor): |
| | """Calculate Herfindahl-Hirschman Index, a measure of concentration. |
| | Higher values indicate more concentration (less diversification). |
| | """ |
| | return torch.sum(weights ** 2) |
| |
|
| | def concentration_penalty( |
| | weights: torch.Tensor, |
| | enp_min: float = 5.0, |
| | enp_max: float = 20.0 |
| | ): |
| | """Calculate concentration penalty based on effective number of positions (ENP). |
| | ENP is the inverse of HHI. This encourages having between enp_min and enp_max |
| | effective positions. |
| | """ |
| | hhi = calculate_hhi(weights) |
| | enp = 1.0 / (hhi + eps) |
| | penalty = torch.relu(enp_min - enp) + torch.relu(enp - enp_max) |
| | return penalty |
| |
|
| | def calculate_objective_func( |
| | returns: torch.Tensor, |
| | risk_free_rate: torch.Tensor, |
| | new_weights: torch.Tensor, |
| | prev_weights: torch.Tensor, |
| | alphas = [1.0, 1.0, 0.1, 0.25], |
| | enp_min: float = 5.0, |
| | enp_max: float = 20.0 |
| | ): |
| | """Calculates the weighted objective function to be MINIMIZED. |
| | Note: Sortino is maximized, drawdown, turnover, and concentration are minimized. |
| | """ |
| | sortino = calculate_sortino(returns, risk_free_rate) |
| | max_drawdown = calculate_max_drawdown(returns) |
| | turnover = calculate_turnover(new_weights, prev_weights) |
| | conc_penalty = concentration_penalty(new_weights, enp_min, enp_max) |
| |
|
| | |
| | sortino_scaled = torch.clamp(sortino, min=-10.0, max=10.0) |
| | max_drawdown_scaled = torch.clamp(max_drawdown, min=0.0, max=1.0) |
| | turnover_scaled = torch.clamp(turnover, min=0.0, max=1.0) |
| | conc_penalty_scaled = torch.clamp(conc_penalty, min=0.0, max=10.0) |
| |
|
| | |
| | |
| | objective = ( |
| | -alphas[0] * sortino_scaled + |
| | alphas[1] * max_drawdown_scaled + |
| | alphas[2] * turnover_scaled + |
| | alphas[3] * conc_penalty_scaled |
| | ) |
| | |
| | |
| | if torch.isnan(objective): |
| | print("Warning: NaN objective detected, using default value") |
| | objective = torch.tensor(0.0, requires_grad=True) |
| | |
| | return objective |
| |
|
| | |
| | def run_ogd( |
| | data_df: pd.DataFrame, |
| | window_size: int = 20, |
| | learning_rate: float = 0.01, |
| | alphas: list[float] = [1.0, 1.0, 0.1, 0.25], |
| | enp_min: float = 5.0, |
| | enp_max: float = 20.0, |
| | use_tqdm: bool = True, |
| | factor_data: pd.DataFrame = None |
| | ): |
| | """Runs the Online Gradient Descent (OGD) portfolio optimization. |
| | |
| | Args: |
| | data_df (pd.DataFrame): DataFrame with dates as index, ticker returns as columns, |
| | and a final column named 'rf' for the risk-free rate. |
| | window_size (int): Lookback window for objective calculation. |
| | learning_rate (float): Learning rate for the SGD optimizer. |
| | alphas (list[float]): Weights for [Sortino, MaxDrawdown, Turnover, Concentration] in the objective. |
| | enp_min (float): Minimum effective number of positions target. |
| | enp_max (float): Maximum effective number of positions target. |
| | use_tqdm (bool): Whether to use tqdm progress bar. |
| | factor_data (pd.DataFrame, optional): DataFrame with factors for CAPM/FF3 analysis. |
| | |
| | Returns: |
| | tuple[pd.DataFrame, pd.DataFrame]: |
| | - weights_df: DataFrame of daily portfolio weights (dates index, tickers columns). |
| | - returns_series: Series of daily portfolio returns (dates index). |
| | """ |
| | if data_df.empty or len(data_df) <= window_size: |
| | print("Warning: Dataframe too small for OGD with the given window size.") |
| | return pd.DataFrame(), pd.Series(dtype=float) |
| |
|
| | |
| | |
| | num_nan_values = data_df.isna().sum().sum() |
| | if num_nan_values > 0: |
| | print(f"WARNING: Input data contains {num_nan_values} NaN values. Filling with 0.") |
| | data_df = data_df.fillna(0) |
| | |
| | |
| | print(f"Data shape: {data_df.shape}") |
| | print(f"Sample data (first few rows):") |
| | print(data_df.iloc[:3, :5]) |
| | |
| | |
| | zero_cols = (data_df == 0).all() |
| | if zero_cols.any(): |
| | zero_count = zero_cols.sum() |
| | print(f"WARNING: {zero_count} columns contain all zeros.") |
| |
|
| | |
| | returns = data_df.drop(columns=['rf']) |
| | rf = data_df['rf'] |
| | tickers = returns.columns.tolist() |
| | num_assets = len(tickers) |
| | num_days = len(data_df) |
| |
|
| | |
| | |
| | returns_tensor = torch.tensor(returns.fillna(0).values, dtype=torch.float32) |
| | rf_tensor = torch.tensor(rf.fillna(0).values, dtype=torch.float32) |
| | |
| | |
| | if torch.isnan(returns_tensor).any(): |
| | print("WARNING: returns_tensor contains NaN values after conversion. Replacing with zeros.") |
| | returns_tensor = torch.nan_to_num(returns_tensor, nan=0.0) |
| |
|
| | |
| | |
| | weights = torch.zeros((num_assets,), requires_grad=True) |
| |
|
| | |
| | optimizer = torch.optim.Adam([weights], lr=learning_rate) |
| |
|
| | |
| | weights_log = torch.zeros((num_days, num_assets), dtype=torch.float32) |
| | portfolio_returns_log = torch.zeros((num_days,), dtype=torch.float32) |
| | rolling_portfolio_returns = [] |
| |
|
| | print(f"Starting OGD optimization for {num_days} days, {num_assets} assets...") |
| | |
| | |
| | initial_weights = torch.full((num_assets,), 1.0/num_assets) |
| | |
| | |
| | day_iterator = tqdm(range(num_days)) if use_tqdm else range(num_days) |
| | |
| | for i in day_iterator: |
| | |
| | if torch.isnan(weights).any(): |
| | print(f"WARNING: NaN detected in weights at day {i}, resetting to uniform weights") |
| | with torch.no_grad(): |
| | weights.copy_(torch.zeros((num_assets,))) |
| | |
| | |
| | clamped_weights = torch.clamp(weights, min=-5, max=5) |
| | normalized_weights = torch.nn.functional.softmax(clamped_weights, dim=0) |
| | |
| | |
| | if torch.isnan(normalized_weights).any() or torch.sum(normalized_weights) < 0.99: |
| | print(f"WARNING: Invalid normalized weights at day {i}, using uniform weights") |
| | normalized_weights = initial_weights.clone() |
| |
|
| | |
| | daily_asset_returns = returns_tensor[i, :] |
| | if torch.isnan(daily_asset_returns).any(): |
| | print(f"WARNING: NaN detected in asset returns at day {i}, replacing with zeros") |
| | daily_asset_returns = torch.nan_to_num(daily_asset_returns, nan=0.0) |
| | |
| | |
| | daily_portfolio_return = torch.dot(normalized_weights, daily_asset_returns) |
| |
|
| | |
| | if torch.isnan(daily_portfolio_return): |
| | print(f"WARNING: NaN detected in portfolio return at day {i}, using zero") |
| | daily_portfolio_return = torch.tensor(0.0) |
| | |
| | |
| | if i < 5 or i % 50 == 0: |
| | print(f" Debug info for day {i}:") |
| | print(f" Sample weights: {normalized_weights[:5].tolist()}") |
| | print(f" Sample returns: {daily_asset_returns[:5].tolist()}") |
| | print(f" Sum of weights: {torch.sum(normalized_weights).item()}") |
| | nan_count = torch.isnan(daily_asset_returns).sum().item() |
| | print(f" NaN count in returns: {nan_count}/{len(daily_asset_returns)}") |
| |
|
| | |
| | weights_log[i, :] = normalized_weights.detach() |
| | portfolio_returns_log[i] = daily_portfolio_return.detach() |
| |
|
| | |
| | |
| | rolling_portfolio_returns.append(daily_portfolio_return.detach()) |
| |
|
| | |
| | |
| | if len(rolling_portfolio_returns) > window_size: |
| | rolling_portfolio_returns.pop(0) |
| |
|
| | |
| | all_zeros = all(r.item() == 0 for r in rolling_portfolio_returns) |
| | if all_zeros: |
| | print(f"WARNING: All portfolio returns are zero at day {i}, skipping optimization") |
| | continue |
| |
|
| | |
| | past_portfolio_returns = torch.stack(rolling_portfolio_returns[:-1] + [daily_portfolio_return]) |
| | |
| | |
| | start_idx = max(0, i - window_size + 1) |
| | past_rf = rf_tensor[start_idx : i + 1] |
| |
|
| | |
| | prev_weights = weights_log[i-1, :] if i > 0 else normalized_weights.detach() |
| |
|
| | |
| | optimizer.zero_grad() |
| | |
| | try: |
| | |
| | clamped_weights = torch.clamp(weights, min=-5, max=5) |
| | current_norm_weights = torch.nn.functional.softmax(clamped_weights, dim=0) |
| | |
| | |
| | current_return = torch.dot(current_norm_weights, daily_asset_returns) |
| | |
| | |
| | historical_returns = rolling_portfolio_returns[:-1] |
| | new_returns_list = historical_returns + [current_return] |
| | past_portfolio_returns = torch.stack(new_returns_list) |
| | |
| | |
| | objective = calculate_objective_func( |
| | past_portfolio_returns, |
| | past_rf, |
| | current_norm_weights, |
| | prev_weights, |
| | alphas, |
| | enp_min, |
| | enp_max |
| | ) |
| | |
| | |
| | if not torch.isnan(objective): |
| | |
| | if objective.item() != 0.0 or i % 50 == 0: |
| | |
| | objective.backward() |
| | |
| | |
| | log_interval = 50 |
| | if (i + 1) % log_interval == 0 or num_days - (i + 1) < 5: |
| | if not use_tqdm: |
| | print(f"\n--- Step {i+1}/{num_days} Log ---") |
| | print(f" Objective: {objective.item():.6f}") |
| | |
| | |
| | if weights.grad is not None: |
| | avg_grad = torch.mean(torch.abs(weights.grad)).item() |
| | print(f" Average Gradient Magnitude: {avg_grad:.6f}") |
| | |
| | |
| | weights_before = weights.detach().clone() |
| | |
| | |
| | optimizer.step() |
| | |
| | |
| | weights_after = weights.detach().clone() |
| | weight_change = torch.sum(torch.abs(weights_after - weights_before)).item() |
| | print(f" Weight Change (Sum Abs): {weight_change:.6f}") |
| | |
| | |
| | print(f" Sample Normalized Weights: {[f'{w:.4f}' for w in normalized_weights[:5].tolist()]}") |
| | else: |
| | |
| | optimizer.step() |
| | |
| | |
| | with torch.no_grad(): |
| | if weights.grad is not None and torch.isnan(weights.grad).any(): |
| | print(f" WARNING: NaN gradient detected at day {i}, zeroing gradients") |
| | weights.grad.zero_() |
| | else: |
| | if not use_tqdm: |
| | print(f" WARNING: Zero objective at day {i}, skipping gradient update") |
| | else: |
| | if not use_tqdm: |
| | print(f" WARNING: NaN objective at day {i}, skipping gradient update") |
| | |
| | except Exception as e: |
| | print(f" Optimization error at day {i}: {e}") |
| | |
| |
|
| | print("OGD optimization finished.") |
| | |
| | |
| | if torch.isnan(weights_log).any(): |
| | print("WARNING: Final weights contain NaN values") |
| | weights_log = torch.nan_to_num(weights_log, nan=1.0/num_assets) |
| | |
| | if torch.isnan(portfolio_returns_log).any(): |
| | print("WARNING: Final portfolio returns contain NaN values") |
| | portfolio_returns_log = torch.nan_to_num(portfolio_returns_log, nan=0.0) |
| |
|
| | |
| | weights_df = pd.DataFrame(weights_log.numpy(), index=data_df.index, columns=tickers) |
| | returns_series = pd.Series(portfolio_returns_log.numpy(), index=data_df.index, name="PortfolioReturn") |
| |
|
| | return weights_df, returns_series |
| |
|
| | |
| | def compute_sharpe(returns_series, rf_series, annualization_factor=252): |
| | """Compute annualized Sharpe ratio.""" |
| | excess = returns_series - rf_series |
| | annual_excess_return = np.mean(excess) * annualization_factor |
| | annual_volatility = np.std(excess) * np.sqrt(annualization_factor) |
| | return annual_excess_return / (annual_volatility + eps) |
| |
|
| | def compute_max_drawdown(returns_series): |
| | """Compute maximum drawdown.""" |
| | cr = np.cumprod(returns_series + 1) |
| | peak = np.maximum.accumulate(cr) |
| | return np.max((peak - cr) / (peak + eps)) |
| |
|
| | def compute_alpha(returns_series, rf_series, factor_data, model="CAPM"): |
| | """Compute alpha using either CAPM or Fama-French 3-factor model. |
| | |
| | Args: |
| | returns_series: Portfolio returns series |
| | rf_series: Risk-free rate series |
| | factor_data: DataFrame with factor returns (must include 'mktrf' for CAPM, |
| | and 'smb', 'hml' for FF3) |
| | model: 'CAPM' or 'FF3' |
| | |
| | Returns: |
| | tuple: (alpha, regression_result) |
| | """ |
| | y = np.asarray(returns_series - rf_series) |
| | |
| | if model == "CAPM": |
| | X = np.asarray(factor_data[["mktrf"]]) |
| | elif model == "FF3": |
| | X = np.asarray(factor_data[["mktrf", "smb", "hml"]]) |
| | else: |
| | raise ValueError("Model must be 'CAPM' or 'FF3'") |
| |
|
| | X = sm.add_constant(X) |
| | result = sm.OLS(y, X).fit() |
| | return result.params[0], result |
| |
|
| | |
| | def plot_optimization_results( |
| | opt_returns_series, |
| | weights_df, |
| | benchmark_returns=None, |
| | top_n=5, |
| | title_suffix="" |
| | ): |
| | """Plot optimization results with comparison to benchmarks. |
| | |
| | Args: |
| | opt_returns_series: Series of optimized portfolio returns |
| | weights_df: DataFrame of weights over time |
| | benchmark_returns: Dict of benchmark return series {name: series} |
| | top_n: Number of top assets to highlight in weights plot |
| | title_suffix: Additional text to add to plot titles |
| | """ |
| | |
| | dates = opt_returns_series.index |
| | opt_returns = opt_returns_series.values |
| | weights_np = weights_df.values |
| | |
| | |
| | fig, axes = plt.subplots(2, 1, figsize=(12, 10)) |
| | |
| | |
| | axes[0].hist(opt_returns, bins=50, alpha=0.5, label='Optimized', color='red') |
| | |
| | |
| | axes[1].plot(dates, np.cumprod(opt_returns + 1), label='Optimized', color='red') |
| |
|
| | |
| | if benchmark_returns: |
| | for name, b_returns in benchmark_returns.items(): |
| | axes[0].hist(b_returns, bins=50, alpha=0.5, label=name) |
| | axes[1].plot(dates, np.cumprod(b_returns + 1), label=name) |
| |
|
| | axes[0].set_title('Return Distribution') |
| | axes[0].legend() |
| | |
| | axes[1].set_title('Cumulative Returns') |
| | axes[1].legend() |
| | axes[1].xaxis.set_major_locator(mdates.YearLocator()) |
| | axes[1].xaxis.set_major_formatter(mdates.DateFormatter('%Y')) |
| | |
| | fig.suptitle(f"Performance Comparison {title_suffix}", fontsize=16) |
| | plt.tight_layout() |
| | plt.show() |
| | |
| | |
| | fig, axes = plt.subplots(2, 1, figsize=(12, 10)) |
| | |
| | |
| | top_assets_idx = np.argsort(weights_np[-1])[-top_n:] |
| | for i in range(weights_np.shape[1]): |
| | label = weights_df.columns[i] if i in top_assets_idx else None |
| | lw = 2 if i in top_assets_idx else 0.3 |
| | alpha = 0.8 if i in top_assets_idx else 0.3 |
| | axes[0].plot(dates, weights_np[:, i], label=label, linewidth=lw, alpha=alpha) |
| |
|
| | axes[0].xaxis.set_major_locator(mdates.YearLocator()) |
| | axes[0].xaxis.set_major_formatter(mdates.DateFormatter('%Y')) |
| | axes[0].set_title("Weights Over Time") |
| | axes[0].legend() |
| |
|
| | |
| | axes[1].hist(weights_np[-1], bins=100, log=True, color='blue', alpha=0.7) |
| | axes[1].set_title("Final Day Weight Distribution") |
| | |
| | plt.tight_layout() |
| | plt.show() |
| | |
| | |
| | enp_series = 1.0 / np.sum(weights_np ** 2, axis=1) |
| | fig, ax = plt.subplots(figsize=(12, 5)) |
| | ax.plot(dates, enp_series) |
| | ax.set_title("Effective Number of Positions Over Time") |
| | ax.set_ylabel("ENP") |
| | ax.xaxis.set_major_locator(mdates.YearLocator()) |
| | ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y')) |
| | plt.tight_layout() |
| | plt.show() |
| | |
| | return None |