| Overall Statistics |
|
Total Orders 113 Average Win 1.66% Average Loss -1.55% Compounding Annual Return 9.744% Drawdown 19.100% Expectancy 0.496 Start Equity 100000 End Equity 210109.49 Net Profit 110.109% Sharpe Ratio 0.445 Sortino Ratio 0.509 Probabilistic Sharpe Ratio 19.622% Loss Rate 28% Win Rate 72% Profit-Loss Ratio 1.07 Alpha 0.017 Beta 0.285 Annual Standard Deviation 0.092 Annual Variance 0.008 Information Ratio -0.294 Tracking Error 0.14 Treynor Ratio 0.143 Total Fees $127.55 Estimated Strategy Capacity $610000000.00 Lowest Capacity Asset XLE RGRPZX100F39 Portfolio Turnover 0.22% Drawdown Recovery 728 |
from AlgorithmImports import *
import numpy as np
import pandas as pd
from sklearn.covariance import LedoitWolf
from datetime import timedelta
class FactorMinVariancePortfolio(QCAlgorithm):
"""
Factor-based long-only minimum variance portfolio.
Trades 4 ETFs using factor exposures from SPY and BND.
Uses Ledoit-Wolf shrinkage for factor covariance estimation.
Rebalances roughly every 100 days using a 1-year lookback of daily data.
"""
def initialize(self):
self.set_start_date(2018, 1, 1)
self.set_cash(100000)
self.Settings.MinimumOrderMarginPortfolioPercentage = 0
self.universe_settings.resolution = Resolution.DAILY
# -----------------------------
# Add traded assets
# -----------------------------
asset_tickers = ["TLT", "GLD", "XLE", "QQQ"]
self.asset_symbols = [
self.add_equity(t, Resolution.DAILY).symbol
for t in asset_tickers
]
# -----------------------------
# Add factor proxies (not traded)
# -----------------------------
factor_tickers = ["SPY", "BND"]
self.factor_symbols = [
self.add_equity(t, Resolution.DAILY).symbol
for t in factor_tickers
]
# -----------------------------
# Parameters
# -----------------------------
self.lookback = 252 # Use 1 year of daily data
self.rebalance_interval = timedelta(days=100) # Rebalance roughly every 4 months
self.last_rebalance = datetime.min
self.epsilon = 1e-6 # Small ridge for numerical stability
self.factor_exposure_limit = 0.5 # Not enforced yet
def on_data(self, data: Slice):
# -----------------------------
# Rebalance check
# -----------------------------
if self.time - self.last_rebalance < self.rebalance_interval:
return
# -----------------------------
# Fetch historical data
# -----------------------------
symbols = self.asset_symbols + self.factor_symbols
history = self.history(symbols, self.lookback, Resolution.DAILY)
if history.empty:
return
prices = history.close.unstack(level=0)
returns = prices.pct_change().dropna()
if len(returns) < 100:
return
# -----------------------------
# Separate asset and factor returns
# -----------------------------
asset_returns = returns[self.asset_symbols].to_numpy()
factor_returns = returns[self.factor_symbols].to_numpy()
T, N = asset_returns.shape
K = factor_returns.shape[1]
# -----------------------------
# Estimate factor betas (B) via OLS and residuals
# -----------------------------
X = np.column_stack([np.ones(T), factor_returns])
B = np.zeros((N, K))
residuals = np.zeros((N, T))
for i in range(N):
y = asset_returns[:, i]
beta = np.linalg.lstsq(X, y, rcond=None)[0]
B[i, :] = beta[1:]
residuals[i, :] = y - X @ beta
# -----------------------------
# Factor covariance (Ledoit-Wolf shrinkage)
# -----------------------------
lw_f = LedoitWolf().fit(factor_returns)
Sigma_f = lw_f.covariance_
# -----------------------------
# Idiosyncratic covariance
# -----------------------------
idio_var = np.var(residuals, axis=1)
idio_var = np.maximum(idio_var, 1e-6)
Sigma_eps = np.diag(idio_var)
# -----------------------------
# Total covariance matrix
# -----------------------------
Sigma = B @ Sigma_f @ B.T + Sigma_eps
Sigma += self.epsilon * np.eye(N)
# -----------------------------
# Long-only minimum variance weights
# -----------------------------
w_closed = np.linalg.pinv(Sigma) @ np.ones(N)
w_closed = np.maximum(w_closed, 0)
w_closed /= w_closed.sum()
self.Debug(f"Closed-form min-var weights (long-only): {w_closed}")
# -----------------------------
# Execute trades
# -----------------------------
for i, symbol in enumerate(self.asset_symbols):
self.set_holdings(symbol, float(w_closed[i]))
# -----------------------------
# Post-trade holdings weights (on same date)
# -----------------------------
holdings_array = np.array([
self.portfolio[symbol].quantity * self.portfolio[symbol].price
for symbol in self.asset_symbols
])
holdings_weights = holdings_array / holdings_array.sum()
self.Debug(f"Post-trade holdings weights: {holdings_weights}")
# -----------------------------
# Update last rebalance AFTER trading
# -----------------------------
self.last_rebalance = self.time
# -----------------------------
# Diagnostics
# -----------------------------
asset_vols = np.sqrt(np.diag(Sigma))
self.Debug(f"Asset volatilities: {asset_vols}")
self.Debug(f"B matrix (factor exposures):\n{B}")