File size: 22,615 Bytes
283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 283fbc7 618fd42 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 | 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
# Small epsilon to avoid division by zero
eps = 1e-6
# --- Objective function components ---
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:
# If no MAR provided, treat 0 as the target
excess_returns = returns
# Calculate downside deviation only on returns below the target
downside_returns = torch.where(excess_returns < 0, excess_returns, torch.tensor(0.0, device=returns.device))
downside_deviation = torch.std(downside_returns, dim=0)
# More robust division - avoid division by very small numbers
downside_deviation = torch.clamp(downside_deviation, min=eps)
# Calculate Sortino ratio with better stability
sortino = torch.mean(excess_returns, dim=0) / downside_deviation
# Clip extreme values to prevent propagation of extreme gradients
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) # Handle empty tensor
# Handle NaN values in returns if any
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 # Use torch.cummax
# Prevent division by zero or very small peaks
safe_peak = torch.clamp(peak, min=eps)
drawdown = (peak - cum_returns) / safe_peak # Calculate drawdown relative to peak
max_drawdown = torch.max(drawdown)
# Clip extreme values
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).
"""
# Safe handling of NaN weights
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
# Clip to reasonable values
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], # Default alpha values [Sortino, MaxDrawdown, Turnover, Concentration]
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)
# Apply scaling to individual components
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: Maximize Sortino, Minimize MaxDrawdown, Minimize Turnover, Control Concentration
# We negate Sortino because the optimizer minimizes the objective.
objective = (
-alphas[0] * sortino_scaled +
alphas[1] * max_drawdown_scaled +
alphas[2] * turnover_scaled +
alphas[3] * conc_penalty_scaled
)
# Ensure objective is not NaN
if torch.isnan(objective):
print("Warning: NaN objective detected, using default value")
objective = torch.tensor(0.0, requires_grad=True)
return objective
# --- Main OGD Optimization Function ---
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], # Added concentration weight
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)
# --- Add data validation ---
# Check for NaN values in the input data
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 diagnostic info ---
print(f"Data shape: {data_df.shape}")
print(f"Sample data (first few rows):")
print(data_df.iloc[:3, :5]) # Show first 3 rows, first 5 columns
# Check for any columns with all zeros or NaNs
zero_cols = (data_df == 0).all()
if zero_cols.any():
zero_count = zero_cols.sum()
print(f"WARNING: {zero_count} columns contain all zeros.")
# Separate stock returns and risk-free rate
returns = data_df.drop(columns=['rf'])
rf = data_df['rf']
tickers = returns.columns.tolist()
num_assets = len(tickers)
num_days = len(data_df)
# Convert to PyTorch tensors with explicit handling of NaN values
# Replace NaN values with 0 during tensor conversion
returns_tensor = torch.tensor(returns.fillna(0).values, dtype=torch.float32)
rf_tensor = torch.tensor(rf.fillna(0).values, dtype=torch.float32)
# Check if returns_tensor contains any NaN values (after conversion)
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)
# Initialize weights as logits (will be converted to probabilities via softmax)
# Starting with zeros gives equal weights after softmax
weights = torch.zeros((num_assets,), requires_grad=True)
# Use Adam optimizer with reduced learning rate
optimizer = torch.optim.Adam([weights], lr=learning_rate)
# Logging structures
weights_log = torch.zeros((num_days, num_assets), dtype=torch.float32)
portfolio_returns_log = torch.zeros((num_days,), dtype=torch.float32)
rolling_portfolio_returns = [] # Store recent portfolio returns for objective calc
print(f"Starting OGD optimization for {num_days} days, {num_assets} assets...")
# Initial weights distribution - equal weights
initial_weights = torch.full((num_assets,), 1.0/num_assets)
# Use tqdm for progress tracking if requested
day_iterator = tqdm(range(num_days)) if use_tqdm else range(num_days)
for i in day_iterator:
# Check for NaN in weights and reset if needed
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,)))
# More restrictive clamping for numerical stability
clamped_weights = torch.clamp(weights, min=-5, max=5)
normalized_weights = torch.nn.functional.softmax(clamped_weights, dim=0)
# Verify normalized weights are valid probabilities
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()
# Get daily asset returns and check for NaN values
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)
# Calculate portfolio return for the current day
daily_portfolio_return = torch.dot(normalized_weights, daily_asset_returns)
# Check for NaN in portfolio return
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)
# Debug information - print sample weights and returns to diagnose the issue
if i < 5 or i % 50 == 0: # Print for first few days and then occasionally
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)}")
# Log weights and returns (use detach() to prevent tracking history)
weights_log[i, :] = normalized_weights.detach()
portfolio_returns_log[i] = daily_portfolio_return.detach()
# Add current return to rolling list for objective calculation
# Detach returns when storing to break gradient history
rolling_portfolio_returns.append(daily_portfolio_return.detach())
# --- Objective Calculation and Optimization Step ---
# Wait until we have enough data for the lookback window
if len(rolling_portfolio_returns) > window_size:
rolling_portfolio_returns.pop(0) # Remove oldest return
# Verify we don't have all zeros in our portfolio returns
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
# Prepare tensors for objective function
past_portfolio_returns = torch.stack(rolling_portfolio_returns[:-1] + [daily_portfolio_return])
# Get corresponding risk-free rates for the window
start_idx = max(0, i - window_size + 1)
past_rf = rf_tensor[start_idx : i + 1]
# Get previous day's weights for turnover calculation
prev_weights = weights_log[i-1, :] if i > 0 else normalized_weights.detach()
# Zero out gradients before computation
optimizer.zero_grad()
try:
# Recompute normalized weights for fresh gradient computation
clamped_weights = torch.clamp(weights, min=-5, max=5)
current_norm_weights = torch.nn.functional.softmax(clamped_weights, dim=0)
# Recalculate today's return for gradient computation
current_return = torch.dot(current_norm_weights, daily_asset_returns)
# Create list with detached historical returns + current gradient-connected return
historical_returns = rolling_portfolio_returns[:-1]
new_returns_list = historical_returns + [current_return]
past_portfolio_returns = torch.stack(new_returns_list)
# Calculate objective with robust error handling
objective = calculate_objective_func(
past_portfolio_returns,
past_rf,
current_norm_weights,
prev_weights,
alphas,
enp_min,
enp_max
)
# Check if objective computation produced valid result
if not torch.isnan(objective):
# Check objective is not just a default zero
if objective.item() != 0.0 or i % 50 == 0: # Allow some zeros through for logging
# Compute and apply gradients
objective.backward()
# --- Enhanced Logging ---
log_interval = 50
if (i + 1) % log_interval == 0 or num_days - (i + 1) < 5:
if not use_tqdm: # Don't print logs if using tqdm to avoid cluttering
print(f"\n--- Step {i+1}/{num_days} Log ---")
print(f" Objective: {objective.item():.6f}")
# Log average gradient magnitude rather than all gradients
if weights.grad is not None:
avg_grad = torch.mean(torch.abs(weights.grad)).item()
print(f" Average Gradient Magnitude: {avg_grad:.6f}")
# Record some sample weights before update
weights_before = weights.detach().clone()
# Apply gradient update
optimizer.step()
# Record weights after update
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}")
# Display a few normalized weights as a sample
print(f" Sample Normalized Weights: {[f'{w:.4f}' for w in normalized_weights[:5].tolist()]}")
else:
# Update weights without detailed logging
optimizer.step()
# Apply gradient clipping after 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}")
# Skip this day rather than propagating errors
print("OGD optimization finished.")
# Final check for validity of results
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)
# Convert logs back to pandas DataFrames/Series with original index
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
# --- Analysis Functions ---
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
# --- Visualization Functions ---
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
"""
# Convert to numpy for plotting
dates = opt_returns_series.index
opt_returns = opt_returns_series.values
weights_np = weights_df.values
# Create plot with return distribution and cumulative returns
fig, axes = plt.subplots(2, 1, figsize=(12, 10))
# Return distribution
axes[0].hist(opt_returns, bins=50, alpha=0.5, label='Optimized', color='red')
# Cumulative returns
axes[1].plot(dates, np.cumprod(opt_returns + 1), label='Optimized', color='red')
# Add benchmarks if provided
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()
# Create plot with weights evolution and distribution
fig, axes = plt.subplots(2, 1, figsize=(12, 10))
# Weight evolution
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()
# Weight distribution
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()
# Return effective number of positions over time
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 |