Portfolio Optimization + Walk-Forward Analysis¶
Runtime: ~15 minutes
Level: Advanced
This notebook demonstrates combining two powerful techniques:
- Portfolio Allocation Optimization - Finding optimal weights across multiple strategies
- Walk-Forward Analysis - Validating robustness across time windows
Why Combine These Techniques?¶
- Portfolio Optimization helps allocate capital across strategies efficiently
- Walk-Forward prevents overfitting and validates real-world performance
- Together they create robust, adaptive multi-strategy systems
Workflow¶
- Define multiple sub-strategies
- Walk-forward optimize each strategy's parameters
- Walk-forward optimize portfolio allocation weights
- Analyze out-of-sample performance
- Compare to naive equal-weight portfolio
📋 Notebook Information
- RustyBT Version: 0.1.2+
- Last Validated: 2025-11-07
- API Compatibility: Verified ✅
- Documentation: API Reference
from rustybt.optimization.search import GridSearchAlgorithm
# Setup
from rustybt.analytics import setup_notebook
setup_notebook()
import pandas as pd
import numpy as np
from datetime import datetime, timedelta
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from rustybt import TradingAlgorithm
from rustybt.optimization import (
Optimizer,
WalkForwardOptimizer,
ParameterSpace,
# GridSearch, # Should be GridSearchAlgorithm from search module
BayesianOptimizer,
)
from rustybt.optimization.objective import SharpeRatio, SortinoRatio, CalmarRatio
from rustybt.portfolio import (
PortfolioAllocator,
FixedAllocation,
DynamicAllocation,
RiskParityAllocation,
KellyCriterionAllocation,
)
from rustybt.pipeline import Pipeline
from rustybt.pipeline.factors import SimpleMovingAverage, RSI, Returns
from rustybt.analytics import (
plot_equity_curve,
plot_rolling_metrics,
plot_drawdown,
)
print("✓ Imports successful")
1. Define Sub-Strategies¶
Create multiple uncorrelated strategies to combine in a portfolio.
class MomentumStrategy(TradingAlgorithm):
"""
Momentum strategy: Buy high performers, sell low performers.
"""
# Parameters to optimize
params = {
'lookback': 60, # Momentum lookback period
'n_longs': 5, # Number of long positions
'n_shorts': 5, # Number of short positions
}
def initialize(self, context):
# Get parameters
context.lookback = self.params['lookback']
context.n_longs = self.params['n_longs']
context.n_shorts = self.params['n_shorts']
# Define universe
context.universe = [self.symbol(t) for t in ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'META']]
# Schedule rebalance
self.schedule_function(
self.rebalance,
date_rules=self.date_rules.month_start(),
time_rules=self.time_rules.market_open()
)
def rebalance(self, context, data):
# Calculate momentum scores
scores = {}
for asset in context.universe:
if data.can_trade(asset):
prices = data.history(asset, 'close', context.lookback, '1d')
if len(prices) == context.lookback:
# Momentum = cumulative return
momentum = (prices[-1] - prices[0]) / prices[0]
scores[asset] = momentum
if not scores:
return
# Sort by momentum
sorted_assets = sorted(scores.items(), key=lambda x: x[1], reverse=True)
# Long top performers
longs = [a for a, s in sorted_assets[:context.n_longs]]
# Short bottom performers
shorts = [a for a, s in sorted_assets[-context.n_shorts:]]
# Calculate weights
long_weight = 0.5 / max(len(longs), 1)
short_weight = -0.5 / max(len(shorts), 1)
# Execute trades
for asset in longs:
self.order_target_percent(asset, long_weight)
for asset in shorts:
self.order_target_percent(asset, short_weight)
# Close other positions
for asset in context.portfolio.positions:
if asset not in longs and asset not in shorts:
self.order_target(asset, 0)
class MeanReversionStrategy(TradingAlgorithm):
"""
Mean reversion: Buy oversold, sell overbought.
"""
params = {
'rsi_period': 14,
'oversold_threshold': 30,
'overbought_threshold': 70,
'exit_threshold': 50,
}
def initialize(self, context):
context.rsi_period = self.params['rsi_period']
context.oversold = self.params['oversold_threshold']
context.overbought = self.params['overbought_threshold']
context.exit_threshold = self.params['exit_threshold']
context.universe = [self.symbol(t) for t in ['AAPL', 'MSFT', 'GOOGL']]
self.schedule_function(
self.check_signals,
date_rules=self.date_rules.every_day(),
time_rules=self.time_rules.market_open()
)
def check_signals(self, context, data):
for asset in context.universe:
if not data.can_trade(asset):
continue
# Calculate RSI
prices = data.history(asset, 'close', context.rsi_period + 1, '1d')
if len(prices) < context.rsi_period + 1:
continue
rsi = self.calculate_rsi(prices, context.rsi_period)
position = context.portfolio.positions[asset]
# Entry signals
if position.amount == 0:
if rsi < context.oversold:
# Buy oversold
self.order_target_percent(asset, 0.33 / len(context.universe))
elif rsi > context.overbought:
# Sell overbought
self.order_target_percent(asset, -0.33 / len(context.universe))
# Exit signals
elif position.amount != 0:
if abs(rsi - context.exit_threshold) < 5:
self.order_target(asset, 0)
def calculate_rsi(self, prices, period):
"""Calculate RSI"""
deltas = np.diff(prices)
gains = np.where(deltas > 0, deltas, 0)
losses = np.where(deltas < 0, -deltas, 0)
avg_gain = np.mean(gains[-period:])
avg_loss = np.mean(losses[-period:])
if avg_loss == 0:
return 100
rs = avg_gain / avg_loss
rsi = 100 - (100 / (1 + rs))
return rsi
class TrendFollowingStrategy(TradingAlgorithm):
"""
Trend following: Moving average crossover.
"""
params = {
'fast_ma': 50,
'slow_ma': 200,
}
def initialize(self, context):
context.fast_ma = self.params['fast_ma']
context.slow_ma = self.params['slow_ma']
context.universe = [self.symbol(t) for t in ['SPY', 'QQQ', 'IWM']]
self.schedule_function(
self.check_trend,
date_rules=self.date_rules.week_start(),
time_rules=self.time_rules.market_open()
)
def check_trend(self, context, data):
for asset in context.universe:
if not data.can_trade(asset):
continue
prices = data.history(asset, 'close', context.slow_ma, '1d')
if len(prices) < context.slow_ma:
continue
fast_ma = prices[-context.fast_ma:].mean()
slow_ma = prices.mean()
position = context.portfolio.positions[asset]
# Golden cross: fast > slow
if fast_ma > slow_ma and position.amount == 0:
self.order_target_percent(asset, 0.33 / len(context.universe))
# Death cross: fast < slow
elif fast_ma < slow_ma and position.amount > 0:
self.order_target(asset, 0)
print("✓ Sub-strategies defined")
2. Individual Strategy Walk-Forward Optimization¶
First, optimize each strategy's parameters using walk-forward analysis.
from rustybt.optimization.search import GridSearchAlgorithm
# Define parameter spaces for each strategy
momentum_params = ParameterSpace({
'lookback': [30, 60, 90, 120],
'n_longs': [3, 5, 7],
'n_shorts': [3, 5, 7],
})
mean_reversion_params = ParameterSpace({
'rsi_period': [7, 14, 21],
'oversold_threshold': [20, 30, 40],
'overbought_threshold': [60, 70, 80],
})
trend_params = ParameterSpace({
'fast_ma': [20, 50, 100],
'slow_ma': [100, 200, 300],
})
# Create walk-forward optimizer
wf_optimizer = WalkForwardOptimizer(
in_sample_period=timedelta(days=365), # 1 year training
out_sample_period=timedelta(days=90), # 3 months testing
objective=ObjectiveFunction(metric="sharpe_ratio"),
search_algo=GridSearchAlgorithm(parameter_space),
)
print("✓ Parameter spaces and optimizer defined")
# Run walk-forward optimization for each strategy
# Note: This would take a long time in practice, so we'll show the pattern
def optimize_strategy(strategy_class, params, data_bundle, start, end):
"""
Run walk-forward optimization on a strategy.
"""
results = wf_optimizer.optimize(
strategy_class=strategy_class,
parameter_space=params,
data_bundle=data_bundle,
start_date=start,
end_date=end,
)
return results
# Example: Optimize momentum strategy
# momentum_wf_results = optimize_strategy(
# MomentumStrategy,
# momentum_params,
# 'my-bundle',
# datetime(2020, 1, 1),
# datetime(2024, 12, 31),
# )
print("✓ Walk-forward optimization pattern defined")
3. Portfolio Allocation Optimization¶
Once we have optimized sub-strategies, optimize the allocation weights.
class OptimizedPortfolio(TradingAlgorithm):
"""
Portfolio combining multiple optimized sub-strategies.
"""
# Parameters: allocation weights for each sub-strategy
params = {
'momentum_weight': 0.40,
'mean_reversion_weight': 0.30,
'trend_weight': 0.30,
# Also include best parameters from individual optimizations
'momentum_lookback': 60,
'momentum_n_longs': 5,
'momentum_n_shorts': 5,
'mr_rsi_period': 14,
'mr_oversold': 30,
'mr_overbought': 70,
'trend_fast_ma': 50,
'trend_slow_ma': 200,
}
def initialize(self, context):
# Get allocation weights
context.weights = {
'momentum': self.params['momentum_weight'],
'mean_reversion': self.params['mean_reversion_weight'],
'trend': self.params['trend_weight'],
}
# Initialize sub-strategies with optimized parameters
context.momentum = MomentumStrategy(
params={
'lookback': self.params['momentum_lookback'],
'n_longs': self.params['momentum_n_longs'],
'n_shorts': self.params['momentum_n_shorts'],
}
)
context.mean_reversion = MeanReversionStrategy(
params={
'rsi_period': self.params['mr_rsi_period'],
'oversold_threshold': self.params['mr_oversold'],
'overbought_threshold': self.params['mr_overbought'],
}
)
context.trend = TrendFollowingStrategy(
params={
'fast_ma': self.params['trend_fast_ma'],
'slow_ma': self.params['trend_slow_ma'],
}
)
# Use PortfolioAllocator
context.allocator = PortfolioAllocator(
strategies={
'momentum': context.momentum,
'mean_reversion': context.mean_reversion,
'trend': context.trend,
},
allocation=FixedAllocation(context.weights),
)
def handle_data(self, context, data):
# Let portfolio allocator handle everything
context.allocator.rebalance(context, data)
# Record portfolio metrics
self.record(
total_positions=len(context.portfolio.positions),
portfolio_value=context.portfolio.portfolio_value,
)
print("✓ Optimized portfolio strategy defined")
Optimize Allocation Weights with Walk-Forward¶
from rustybt.optimization.search import GridSearchAlgorithm
# Define weight parameter space
# Weights must sum to 1.0
allocation_params = ParameterSpace({
'momentum_weight': [0.2, 0.3, 0.4, 0.5],
'mean_reversion_weight': [0.2, 0.3, 0.4, 0.5],
# trend_weight is implicit: 1.0 - momentum - mean_reversion
})
# Add constraint: weights sum to 1.0
def weight_constraint(params):
trend_weight = 1.0 - params['momentum_weight'] - params['mean_reversion_weight']
if trend_weight < 0.1 or trend_weight > 0.7:
return False # Invalid
params['trend_weight'] = trend_weight
return True # Valid
allocation_params.add_constraint(weight_constraint)
# Walk-forward optimize portfolio weights
portfolio_wf_optimizer = WalkForwardOptimizer(
in_sample_period=timedelta(days=365),
out_sample_period=timedelta(days=90),
objective=ObjectiveFunction(metric="sharpe_ratio"),
search_algo=GridSearchAlgorithm(parameter_space),
)
# Run optimization
# portfolio_results = portfolio_wf_optimizer.optimize(
# strategy_class=OptimizedPortfolio,
# parameter_space=allocation_params,
# data_bundle='my-bundle',
# start_date=datetime(2020, 1, 1),
# end_date=datetime(2024, 12, 31),
# )
print("✓ Portfolio weight optimization defined")
4. Dynamic Allocation Strategies¶
Instead of fixed weights, use dynamic allocation based on recent performance.
class AdaptivePortfolio(TradingAlgorithm):
"""
Portfolio with adaptive allocation based on recent performance.
"""
params = {
'lookback_period': 60, # Days to evaluate performance
'allocation_method': 'risk_parity', # or 'kelly', 'dynamic_sharpe'
'rebalance_frequency': 'monthly',
}
def initialize(self, context):
# Initialize sub-strategies (with optimized params)
context.strategies = {
'momentum': MomentumStrategy(params={'lookback': 60, 'n_longs': 5, 'n_shorts': 5}),
'mean_reversion': MeanReversionStrategy(params={'rsi_period': 14, 'oversold_threshold': 30, 'overbought_threshold': 70}),
'trend': TrendFollowingStrategy(params={'fast_ma': 50, 'slow_ma': 200}),
}
# Choose allocation method
allocation_method = self.params['allocation_method']
if allocation_method == 'risk_parity':
allocation = RiskParityAllocation(
lookback=self.params['lookback_period']
)
elif allocation_method == 'kelly':
allocation = KellyCriterionAllocation(
lookback=self.params['lookback_period']
)
elif allocation_method == 'dynamic_sharpe':
allocation = DynamicAllocation(
lookback=self.params['lookback_period'],
metric='sharpe'
)
else:
raise ValueError(f"Unknown allocation method: {allocation_method}")
context.allocator = PortfolioAllocator(
strategies=context.strategies,
allocation=allocation,
)
# Schedule rebalancing
if self.params['rebalance_frequency'] == 'monthly':
date_rule = self.date_rules.month_start()
elif self.params['rebalance_frequency'] == 'weekly':
date_rule = self.date_rules.week_start()
else:
date_rule = self.date_rules.every_day()
self.schedule_function(
self.rebalance_portfolio,
date_rules=date_rule,
time_rules=self.time_rules.market_open()
)
def rebalance_portfolio(self, context, data):
# Calculate new allocation weights
weights = context.allocator.calculate_weights(context, data)
# Log weights
self.log.info(f"Current allocation: {weights}")
# Rebalance
context.allocator.rebalance(context, data)
# Record
self.record(
momentum_weight=weights.get('momentum', 0),
mean_reversion_weight=weights.get('mean_reversion', 0),
trend_weight=weights.get('trend', 0),
)
print("✓ Adaptive portfolio strategy defined")
5. Walk-Forward Optimize Adaptive Allocation¶
Even dynamic allocation has parameters to optimize.
from rustybt.optimization.search import GridSearchAlgorithm
# Parameter space for adaptive portfolio
adaptive_params = ParameterSpace({
'lookback_period': [30, 60, 90, 120],
'allocation_method': ['risk_parity', 'kelly', 'dynamic_sharpe'],
'rebalance_frequency': ['weekly', 'monthly'],
})
# Walk-forward optimize
adaptive_wf_optimizer = WalkForwardOptimizer(
in_sample_period=timedelta(days=365),
out_sample_period=timedelta(days=90),
objective=ObjectiveFunction(metric="sharpe_ratio"),
search_algo=GridSearchAlgorithm(parameter_space),
)
# Run optimization
# adaptive_results = adaptive_wf_optimizer.optimize(
# strategy_class=AdaptivePortfolio,
# parameter_space=adaptive_params,
# data_bundle='my-bundle',
# start_date=datetime(2020, 1, 1),
# end_date=datetime(2024, 12, 31),
# )
print("✓ Adaptive portfolio optimization defined")
6. Analyze Walk-Forward Results¶
Compare in-sample vs out-of-sample performance.
def analyze_walk_forward_results(wf_results):
"""
Analyze walk-forward optimization results.
"""
# Extract metrics
windows = wf_results.windows
in_sample_sharpes = [w.in_sample_metrics['sharpe'] for w in windows]
out_sample_sharpes = [w.out_sample_metrics['sharpe'] for w in windows]
# Calculate degradation
degradation = np.array(in_sample_sharpes) - np.array(out_sample_sharpes)
avg_degradation = np.mean(degradation)
print(f"Walk-Forward Analysis Results:")
print(f" Number of windows: {len(windows)}")
print(f" Average in-sample Sharpe: {np.mean(in_sample_sharpes):.3f}")
print(f" Average out-of-sample Sharpe: {np.mean(out_sample_sharpes):.3f}")
print(f" Average degradation: {avg_degradation:.3f}")
print(f" Degradation std: {np.std(degradation):.3f}")
# Plot
fig = make_subplots(
rows=2, cols=1,
subplot_titles=('Sharpe Ratio: In-Sample vs Out-of-Sample', 'Performance Degradation'),
vertical_spacing=0.15
)
# Sharpe comparison
fig.add_trace(
go.Scatter(y=in_sample_sharpes, name='In-Sample', mode='lines+markers'),
row=1, col=1
)
fig.add_trace(
go.Scatter(y=out_sample_sharpes, name='Out-of-Sample', mode='lines+markers'),
row=1, col=1
)
# Degradation
fig.add_trace(
go.Bar(y=degradation, name='Degradation', marker_color='red'),
row=2, col=1
)
fig.update_xaxes(title_text="Window", row=2, col=1)
fig.update_yaxes(title_text="Sharpe Ratio", row=1, col=1)
fig.update_yaxes(title_text="Degradation", row=2, col=1)
fig.update_layout(height=800, title_text="Walk-Forward Analysis")
return fig
# Example usage:
# fig = analyze_walk_forward_results(portfolio_results)
# fig.show()
print("✓ Analysis function defined")
7. Compare Portfolio Strategies¶
Benchmark against simpler alternatives.
def compare_portfolio_strategies(results_dict):
"""
Compare multiple portfolio strategies.
Args:
results_dict: {'strategy_name': backtest_results}
"""
metrics = []
for name, results in results_dict.items():
metrics.append({
'Strategy': name,
'Total Return': results['total_return'],
'CAGR': results['cagr'],
'Sharpe': results['sharpe_ratio'],
'Sortino': results['sortino_ratio'],
'Calmar': results['calmar_ratio'],
'Max Drawdown': results['max_drawdown'],
'Volatility': results['volatility'],
})
df = pd.DataFrame(metrics)
# Color code best values
print("\n=== Portfolio Strategy Comparison ===")
print(df.to_string(index=False))
# Plot equity curves
fig = go.Figure()
for name, results in results_dict.items():
fig.add_trace(go.Scatter(
x=results['dates'],
y=results['portfolio_value'],
name=name,
mode='lines'
))
fig.update_layout(
title="Portfolio Strategy Comparison",
xaxis_title="Date",
yaxis_title="Portfolio Value",
hovermode='x unified'
)
return fig
# Example comparison:
# results = {
# 'Equal Weight': equal_weight_results,
# 'Optimized Fixed': optimized_fixed_results,
# 'Risk Parity': risk_parity_results,
# 'Kelly': kelly_results,
# 'Dynamic Sharpe': dynamic_sharpe_results,
# }
# fig = compare_portfolio_strategies(results)
# fig.show()
print("✓ Comparison function defined")
Summary¶
Workflow Recap¶
- Define Sub-Strategies - Create multiple uncorrelated strategies
- Walk-Forward Optimize Each - Find robust parameters for each strategy
- Combine in Portfolio - Use PortfolioAllocator to manage capital allocation
- Optimize Allocation - Walk-forward optimize portfolio weights
- Consider Dynamic Allocation - Use adaptive allocation based on recent performance
- Analyze Results - Compare in-sample vs out-of-sample performance
- Benchmark - Compare to simpler alternatives (equal weight, 60/40, etc.)
Key Insights¶
✅ Walk-forward prevents overfitting at both strategy and portfolio levels
✅ Dynamic allocation adapts to changing market conditions
✅ Risk parity often outperforms equal weight allocation
✅ Kelly criterion maximizes long-term growth but can be aggressive
✅ Performance degradation is normal; <20% is acceptable
✅ Regular rebalancing maintains desired risk profile
✅ Diversification across strategies reduces portfolio volatility
Best Practices¶
- Use uncorrelated sub-strategies for diversification
- Walk-forward optimize with sufficient data (3+ years minimum)
- Monitor out-of-sample performance degradation
- Rebalance regularly but not too frequently (monthly is often good)
- Consider transaction costs in allocation decisions
- Use multiple allocation methods and compare results
- Always benchmark against simple baselines
Next Steps¶
- Notebook 14: Multi-Timeframe Strategies
- Notebook 15: Custom Indicator Development
- Notebook 16: Monte Carlo Simulation