Robo-Advisers and Portfolio Optimisation

Author

Professor Barry Quinn

Published

May 8, 2026

Theme: Modern Investment & Financial Inclusion

NoteView Slides

1 Introduction: The Algorithmic Revolution in Investment Management

The democratisation of investment management through robo-advisers has emerged as one of the most visible applications of FinTech innovation. Its long-term significance will depend on sustained adoption, regulatory frameworks, and the ability of these models to deliver consistent value. By combining the data acquisition capabilities we developed in Week 2 with sophisticated algorithmic portfolio management, robo-advisers have made professional-quality investment advice accessible to investors who were previously underserved by traditional wealth management.

Recent research by Reher and Sokolinski (2024) provides compelling evidence that robo-advisers expand access to wealth management for middle-class investors by lowering account minimums, creating measurable welfare gains for previously underserved segments. Their quasi-experimental analysis demonstrates that when robo-advisers reduce minimum investment requirements, participation rates increase significantly among investors with moderate wealth levels.

These welfare effects are modest in size, concentrated among middle-class investors with sufficient investable wealth, and sensitive to both institutional design and behavioural responses.

This expansion of access reflects the broader theme we established in Week 1’s analysis of FinTech innovation. Just as Berg et al. (2020) showed how FinTech lenders use superior data processing to serve previously excluded borrowers, robo-advisers use algorithmic portfolio management to serve investors that traditional wealth managers found unprofitable.

But the story of robo-advisers is more than just democratisation. The underlying algorithms represent a practical application of the computational complexity insights we discussed in our Data Science Primer. As Gu, Kelly, and Xiu (2020) demonstrate in their foundational work on “Empirical Asset Pricing via Machine Learning,” sophisticated algorithms can capture patterns in financial data that simpler approaches miss, potentially improving investment outcomes for all types of investors.

1.0.1 From Characteristics to Embeddings: Learning Asset Relationships

Traditional portfolio construction relies on observable firm characteristics: size, book-to-market ratios, momentum, profitability. But what if we could learn asset relationships directly from how professional investors actually construct portfolios? Recent work by Gabaix et al. (2025) shows that portfolio holdings themselves encode rich information about which assets belong together.

The intuition mirrors how natural language processing learns word meanings. Words appearing in similar contexts (like “bank” and “river” both appearing near “water”) have related meanings. Similarly, assets appearing in similar portfolios (Apple and Microsoft both held by growth-focused technology funds) share investment characteristics. By analyzing thousands of institutional portfolios, embedding models learn compressed representations: high-dimensional vectors capturing each asset’s “investment profile”: without requiring hand-crafted features.

This approach offers three advantages for algorithmic investment management. First, embeddings automatically incorporate information that might be difficult to encode as explicit characteristics: industry relationships, supply chain connections, management quality signals reflected in professional investors’ revealed preferences. Second, embeddings update continuously as new holdings data arrives, adapting to evolving market structure without manual feature engineering. Third, embeddings enable recommendation-system approaches: suggesting portfolio additions based on similarity to existing holdings, the same technology powering Netflix recommendations or Amazon product suggestions.

The welfare implications connect to Reher and Sokolinski (2024)’s findings about robo-advisors expanding access. If embeddings enable better portfolio construction at lower marginal cost: learning from institutional behavior without requiring expensive fundamental research: they potentially extend sophisticated investment strategies to middle-class investors previously excluded from personalized portfolio management. Whether this theoretical potential translates to practice remains an empirical question, but the approach illustrates how machine learning techniques originally developed for consumer applications (recommendations, search) are being adapted to financial contexts with meaningful distributional consequences.

2

2.1 Learning objectives

By the end of this week, you should be able to:

  • Contrast traditional advisory and robo‑advisory models, including economic drivers of access and cost.
  • Implement and interpret simple analyses of cost structures and portfolio choices.
  • Explain welfare and inclusion implications using recent empirical evidence (Reher and Sokolinski (2024)) with appropriate caveats.
  • Identify key risks and governance considerations (suitability, disclosure, bias) for algorithmic advice.

3 Part I: The Economics of Robo-Advisory Services

3.1 Understanding the Traditional Wealth Management Model

Traditional wealth management operates on a high-touch, relationship-based model that requires significant human resources. Financial advisers typically charge fees of 1-1.5% of assets under management and often require minimum account sizes of $250,000 or more to ensure profitability.

This model creates natural barriers to access. As Hilpisch (2019) observes in his analysis of financial technology barriers, traditional advisory services require “investments of $25-36 million just for derivatives analytics libraries, along with teams of experts who understand both finance and technology.” Whilst this refers to sophisticated trading systems, the principle applies broadly: high-touch financial services require substantial fixed costs that must be amortised across large account balances.

The traditional model also faces scalability constraints. Human advisers can only manage a limited number of client relationships effectively, creating capacity constraints that push firms towardss serving higher-net-worth clients who generate more revenue per relationship.

4 Note: The following analysis uses stylised assumptions for teaching purposes. Actual advisory fee structures and minimums vary significantly across firms and jurisdictions.

Show Python code
# Analyzing the economics of traditional vs. robo-advisory services
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

def analyse_advisory_economics():
    """
    Compare the cost structures of traditional vs. robo-advisory services
    """
    print("Economics of Traditional vs. Robo-Advisory Services")
    print("=" * 55)
    
    # Define cost structures
    account_sizes = np.array([10000, 25000, 50000, 100000, 250000, 500000, 1000000])
    
    # Traditional advisor costs
    traditional_fee_rate = 0.015  # 1.5% AUM
    traditional_minimum_fee = 2500  # Minimum annual fee
    traditional_fees = np.maximum(account_sizes * traditional_fee_rate, traditional_minimum_fee)
    
    # Robo-advisor costs  
    robo_fee_rate = 0.0025  # 0.25% AUM
    robo_minimum_fee = 0  # No minimum
    robo_fees = account_sizes * robo_fee_rate
    
    # Calculate effective fee rates
    traditional_effective_rates = traditional_fees / account_sizes
    robo_effective_rates = robo_fees / account_sizes
    
    print("Cost Analysis by Account Size:")
    print("=" * 60)
    print(f"{'Account Size':<15} {'Traditional':<12} {'Robo':<12} {'Savings':<12} {'Rate Diff':<12}")
    print("-" * 60)
    
    for i, size in enumerate(account_sizes):
        traditional_cost = traditional_fees[i]
        robo_cost = robo_fees[i]
        savings = traditional_cost - robo_cost
        rate_diff = traditional_effective_rates[i] - robo_effective_rates[i]
        
        # Width before grouping option; left-align for the size column
        print(f"${size:<14,} ${traditional_cost:>8,.0f} ${robo_cost:>8,.0f} ${savings:>8,.0f} {rate_diff*100:>8.2f}%")
    
    # Identify break-even point for traditional advisers
    break_even = traditional_minimum_fee / traditional_fee_rate
    print(f"\nTraditional advisor break-even account size: ${break_even:,.0f}")
    
    # Visualization
    plt.figure(figsize=(15, 10))
    
    # Annual fees comparison
    plt.subplot(2, 3, 1)
    plt.plot(account_sizes/1000, traditional_fees, 'b-o', label='Traditional Advisor', linewidth=2)
    plt.plot(account_sizes/1000, robo_fees, 'r-o', label='Robo-Advisor', linewidth=2)
    plt.xlabel('Account Size ($000s)')
    plt.ylabel('Annual Fee ($)')
    plt.title('Annual Advisory Fees')
    plt.legend()
    plt.grid(True, alpha=0.3)
    plt.yscale('log')
    
    # Effective fee rates
    plt.subplot(2, 3, 2)
    plt.plot(account_sizes/1000, traditional_effective_rates*100, 'b-o', label='Traditional', linewidth=2)
    plt.plot(account_sizes/1000, robo_effective_rates*100, 'r-o', label='Robo-Advisor', linewidth=2)
    plt.xlabel('Account Size ($000s)')
    plt.ylabel('Effective Fee Rate (%)')
    plt.title('Effective Fee Rates by Account Size')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Cost savings
    plt.subplot(2, 3, 3)
    savings = traditional_fees - robo_fees
    plt.bar(range(len(account_sizes)), savings, alpha=0.7, color='green')
    plt.xlabel('Account Size Category')
    plt.ylabel('Annual Savings ($)')
    plt.title('Annual Cost Savings with Robo-Advisor')
    plt.xticks(range(len(account_sizes)), [f'${s/1000:.0f}K' for s in account_sizes], rotation=45)
    plt.grid(True, alpha=0.3)
    
    # Access analysis
    plt.subplot(2, 3, 4)
    # Simulate investor distribution (log-normal)
    np.random.seed(42)
    investor_wealth = np.random.lognormal(mean=10.5, sigma=1.2, size=10000)  # Wealth distribution
    investor_wealth = investor_wealth[investor_wealth >= 5000]  # Minimum for any service
    
    # Traditional access (minimum $250K effectively due to fees)
    traditional_accessible = investor_wealth[investor_wealth >= 250000]
    robo_accessible = investor_wealth[investor_wealth >= 1000]  # Much lower minimum
    
    print(f"\nAccess Analysis (Simulated Investor Population):")
    print(f"  Total investors (>$5K): {len(investor_wealth):,}")
    print(f"  Traditional advisor access: {len(traditional_accessible):,} ({len(traditional_accessible)/len(investor_wealth)*100:.1f}%)")
    print(f"  Robo-advisor access: {len(robo_accessible):,} ({len(robo_accessible)/len(investor_wealth)*100:.1f}%)")
    print(f"  Additional access from robo: {len(robo_accessible) - len(traditional_accessible):,} investors")
    
    plt.hist(investor_wealth/1000, bins=50, alpha=0.7, label='All Investors')
    plt.axvline(250, color='blue', linestyle='--', label='Traditional Minimum')
    plt.axvline(1, color='red', linestyle='--', label='Robo Minimum')
    plt.xlabel('Wealth ($000s)')
    plt.ylabel('Number of Investors')
    plt.title('Investor Wealth Distribution\nand Service Accessibility')
    plt.legend()
    plt.xlim(0, 1000)
    plt.grid(True, alpha=0.3)
    
    # Market opportunity
    plt.subplot(2, 3, 5)
    categories = ['Traditional\nOnly', 'Robo\nExpansion', 'Both\nServices']
    sizes = [
        len(traditional_accessible),
        len(robo_accessible) - len(traditional_accessible),
        len(investor_wealth) - len(robo_accessible)
    ]
    colors = ['blue', 'green', 'grey']
    
    plt.pie(sizes, labels=categories, colors=colors, autopct='%1.1f%%')
    plt.title('Market Segmentation\nby Service Access')
    
    # Key insights
    plt.subplot(2, 3, 6)
    plt.text(0.05, 0.9, 'Key Economic Insights:', fontweight='bold', fontsize=12)
    plt.text(0.05, 0.8, '• Robo-advisers reduce fixed costs', fontsize=10)
    plt.text(0.05, 0.75, '• Lower minimums expand access', fontsize=10)
    plt.text(0.05, 0.7, '• Fee compression benefits all investors', fontsize=10)
    plt.text(0.05, 0.65, '• Technology enables mass customisation', fontsize=10)
    
    plt.text(0.05, 0.5, 'Reher & Sokolinski (2024):', fontweight='bold', fontsize=12)
    plt.text(0.05, 0.4, '• Moderate welfare gains from access', fontsize=10)
    plt.text(0.05, 0.35, '• Especially for middle-age investors', fontsize=10)
    plt.text(0.05, 0.3, '• Quasi-experimental evidence', fontsize=10)
    
    plt.xlim(0, 1)
    plt.ylim(0, 1)
    plt.axis('off')
    
    plt.tight_layout()
    plt.show()
    
    return {
        'account_sizes': account_sizes,
        'traditional_fees': traditional_fees,
        'robo_fees': robo_fees,
        'access_expansion': len(robo_accessible) - len(traditional_accessible)
    }

# Run the analysis
advisory_economics = analyse_advisory_economics()
Economics of Traditional vs. Robo-Advisory Services
=======================================================
Cost Analysis by Account Size:
============================================================
Account Size    Traditional  Robo         Savings      Rate Diff   
------------------------------------------------------------
$10,000         $   2,500 $      25 $   2,475    24.75%
$25,000         $   2,500 $      62 $   2,438     9.75%
$50,000         $   2,500 $     125 $   2,375     4.75%
$100,000        $   2,500 $     250 $   2,250     2.25%
$250,000        $   3,750 $     625 $   3,125     1.25%
$500,000        $   7,500 $   1,250 $   6,250     1.25%
$1,000,000      $  15,000 $   2,500 $  12,500     1.25%

Traditional advisor break-even account size: $166,667

Access Analysis (Simulated Investor Population):
  Total investors (>$5K): 9,499
  Traditional advisor access: 546 (5.7%)
  Robo-advisor access: 9,499 (100.0%)
  Additional access from robo: 8,953 investors

This economic analysis demonstrates why robo-advisers represent more than just technological innovation: they fundamentally change the economics of investment advice delivery, enabling what Vives (2019) categorises as “product innovation” that serves previously underserved markets.

4.1 The Technology Behind Robo-Advisory Services

Robo-advisers succeed by automating the core functions of traditional investment management: portfolio construction, rebalancing, tax-loss harvesting, and performance monitoring. This automation is possible because many investment management decisions can be systematised using well-established financial theory and computational methods.

The technological foundation draws heavily on Modern Portfolio Theory, as implemented in Hilpisch (2019)’s Chapter 13 on portfolio analytics. However, modern robo-advisers extend these classical approaches with machine learning techniques for improved risk assessment, behavioural analysis for better client outcomes, and automated execution for efficient implementation.

4.1.1 Portfolio optimisation Algorithms

The core of most robo-advisory services is an automated portfolio optimisation engine that implements variations of Modern Portfolio Theory. These systems take client risk preferences, investment objectives, and constraints as inputs and produce optimal portfolio allocations as outputs.

WarningConnection to Data & Measurement (Week 2, §2.3)

Survivorship bias in equity data: When we calculate historical returns from equity indices (S&P 500, FTSE 100), we only observe survivors. Failed companies disappear from the index, creating upward bias in estimated returns.

Example: S&P 500 returns 1990-2020 exclude companies that went bankrupt, were acquired, or delisted. If we base portfolio optimization on these survivorship-biased returns, we’ll overestimate expected performance.

Mitigation: - Use point-in-time indices that reflect actual investable universe at each date - Bloomberg Terminal provides “backfilled” vs “point-in-time” data options - Adjust expected returns downward to account for survivor bias (typically -1% to -2% per year)

Recall our Week 2 lab where we quantified survivorship bias in UK banking crisis data: the same principle applies to equity portfolios.

TipConnection to Volatility Modeling (Week 3, §3.2)

Covariance matrices are not constant: Recall from Chapter 03 that volatility exhibits clustering (GARCH effects) and time-variation. The same applies to covariances.

Problem: We estimate covariance on historical data (e.g., 2015-2020), assuming it’s stable. But correlations are not constant: during crisis periods, correlations spike toward 1 as all assets fall together; during calm periods, correlations are lower and diversification works better; and regime shifts (such as the 2020 pandemic) can alter correlations permanently.

Implication: Optimal weights based on calm-period covariances will fail during crises when correlations surge. This is why “diversified” portfolios crash together during market stress.

Solutions: - Dynamic conditional correlation (DCC-GARCH) from Ch 03: model time-varying correlations - Stress-test portfolios using crisis-period covariances - Use rolling windows to adapt to changing correlations

5 Caution. This behavioural simulation is highly stylised. Real investor behaviour is shaped by cultural, institutional, and macroeconomic contexts, and robo-advisor effectiveness in mitigating biases depends on actual client engagement and platform design.

Show Python code
# Implementing robo-advisor portfolio optimisation
import pandas as pd
import numpy as np
import os
import matplotlib.pyplot as plt
from scipy.optimize import minimize

class RoboAdvisorEngine:
    """
    Basic robo-advisor portfolio optimisation engine
    Following Hilpisch Chapter 13 portfolio analytics approach
    """
    
    def __init__(self, symbols, risk_free_rate=0.02):
        self.symbols = symbols
        self.risk_free_rate = risk_free_rate
        self.returns_data = None
        self.expected_returns = None
        self.cov_matrix = None
        
    def fetch_market_data(self):
        """
        Load market data from Bloomberg database
        """
        print(f"Loading data for {len(self.symbols)} assets from Bloomberg database...")
        
        try:
            # Load from Bloomberg database
            bbg = load_bloomberg()
            
            price_data = {}
            for symbol in self.symbols:
                ticker_data = bbg[bbg['ticker'] == symbol].copy()
                if ticker_data.empty:
                    raise ValueError(f"Ticker {symbol} not found in database")
                ticker_data['date'] = pd.to_datetime(ticker_data['date'])
                ticker_data = ticker_data.set_index('date').sort_index()
                price_data[symbol] = ticker_data['PX_LAST']
            
            price_data = pd.DataFrame(price_data)
            
            if price_data.empty:
                raise ValueError("No price data available")
            
            # Calculate returns
            self.returns_data = price_data.pct_change().dropna()
            
            # Estimate expected returns (simple historical mean)
            self.expected_returns = self.returns_data.mean() * 252  # Annualized
            
            # Estimate covariance matrix
            self.cov_matrix = self.returns_data.cov() * 252  # Annualized
            
            # Note: These are ESTIMATES with substantial uncertainty
            # Standard errors for expected returns are typically 5-15% annualized
            # This uncertainty propagates to optimal weights (see sensitivity analysis below)
            
            print("Successfully prepared data:")
            print(f"   Trading days: {len(self.returns_data)}")
            print(f"   Assets: {len(self.symbols)}")
            print(f"   Expected returns range: {self.expected_returns.min()*100:.1f}% to {self.expected_returns.max()*100:.1f}%")
            
            return True
            
        except Exception as e:
            print(f"Error fetching market data: {e}")
            return False
    
    def optimize_portfolio(self, target_return=None, risk_aversion=None):
        """
        Optimize portfolio using Modern Portfolio Theory
        """
        if self.expected_returns is None or self.cov_matrix is None:
            print("No market data available. Run fetch_market_data() first.")
            return None
        
        n_assets = len(self.symbols)
        
        # Objective function for portfolio optimisation
        def portfolio_volatility(weights):
            """Calculate portfolio volatility"""
            return np.sqrt(np.dot(weights.T, np.dot(self.cov_matrix, weights)))
        
        def portfolio_return(weights):
            """Calculate expected portfolio return"""
            return np.dot(weights, self.expected_returns)
        
        def sharpe_ratio(weights):
            """Calculate negative Sharpe ratio (for minimization)"""
            port_return = portfolio_return(weights)
            port_vol = portfolio_volatility(weights)
            if port_vol == 0:
                return -np.inf
            return -(port_return - self.risk_free_rate) / port_vol
        
        # Constraints
        constraints = [
            {'type': 'eq', 'fun': lambda x: np.sum(x) - 1}  # Weights sum to 1
        ]
        
        if target_return is not None:
            constraints.append({
                'type': 'eq', 
                'fun': lambda x: portfolio_return(x) - target_return
            })
        
        # Bounds (no short selling, max 40% in any single asset)
        bounds = [(0, 0.4) for _ in range(n_assets)]
        
        # Initial guess (equal weights)
        initial_weights = np.array([1/n_assets] * n_assets)
        
        # Optimize
        if risk_aversion is not None:
            # Risk-averse optimisation (minimise variance for given return preference)
            def objective(weights):
                return portfolio_volatility(weights)**2 + risk_aversion * (-portfolio_return(weights))
        else:
            # Maximize Sharpe ratio
            objective = sharpe_ratio
        
        try:
            result = minimize(
                objective,
                initial_weights,
                method='SLSQP',
                bounds=bounds,
                constraints=constraints,
                options={'maxiter': 1000}
            )
            
            if result.success:
                optimal_weights = result.x
                opt_return = portfolio_return(optimal_weights)
                opt_vol = portfolio_volatility(optimal_weights)
                opt_sharpe = (opt_return - self.risk_free_rate) / opt_vol
                
                print("\nPortfolio optimisation successful:")
                print(f"   Expected return: {opt_return*100:.2f}%")
                print(f"   Volatility: {opt_vol*100:.2f}%")
                print(f"   Sharpe ratio: {opt_sharpe:.3f}")
                
                # Show allocation
                print(f"\n   Optimal Allocation:")
                for symbol, weight in zip(self.symbols, optimal_weights):
                    if weight > 0.01:  # Only show significant allocations
                        print(f"     {symbol}: {weight*100:.1f}%")
                
                return {
                    'weights': optimal_weights,
                    'expected_return': opt_return,
                    'volatility': opt_vol,
                    'sharpe_ratio': opt_sharpe
                }
            else:
                print(f"Optimisation failed: {result.message}")
                return None
                
        except Exception as e:
            print(f"Optimisation error: {e}")
            return None
    
    def generate_efficient_frontier(self, n_portfolios=50):
        """
        Generate efficient frontier for visualisation
        """
        if self.expected_returns is None:
            print("No market data available")
            return None
        
        print(f"Generating efficient frontier with {n_portfolios} portfolios...")
        
        # Range of target returns
        min_return = self.expected_returns.min()
        max_return = self.expected_returns.max()
        target_returns = np.linspace(min_return, max_return, n_portfolios)
        
        efficient_portfolios = []
        
        for target in target_returns:
            # Optimize for this target return
            result = self.optimize_portfolio(target_return=target)
            if result:
                efficient_portfolios.append({
                    'target_return': target,
                    'volatility': result['volatility'],
                    'sharpe_ratio': result['sharpe_ratio'],
                    'weights': result['weights']
                })
        
        if efficient_portfolios:
            print(f"Generated {len(efficient_portfolios)} efficient portfolios")
            return pd.DataFrame(efficient_portfolios)
        else:
            print("Failed to generate efficient frontier")
            return None

# Demonstrate robo-advisor optimisation
def demonstrate_robo_optimization():
    """
    Demonstrate robo-advisor portfolio optimisation capabilities
    """
    print("Robo-Advisor Portfolio optimisation Demonstration")
    print("=" * 55)
    
    # Define a diversified ETF portfolio (typical robo-advisor approach)
    etf_portfolio = {
        'VTI': 'Total Stock Market',
        'VTIAX': 'International Stocks', 
        'BND': 'Total Bond Market',
        'VNQ': 'Real Estate (REITs)',
        'VDE': 'Energy Sector',
        'VGT': 'Technology Sector'
    }
    
    symbols = list(etf_portfolio.keys())
    
    # Create robo-advisor engine
    robo = RoboAdvisorEngine(symbols)
    
    # Fetch data and optimise
    if robo.fetch_market_data():
        
        # Optimize for maximum Sharpe ratio
        optimal_portfolio = robo.optimize_portfolio()
        
        if optimal_portfolio:
            # Generate efficient frontier
            efficient_frontier = robo.generate_efficient_frontier(n_portfolios=30)
            
            if efficient_frontier is not None:
                # Visualization
                plt.figure(figsize=(12, 8))
                
                # Efficient frontier
                plt.subplot(2, 2, 1)
                plt.plot(efficient_frontier['volatility']*100, 
                        efficient_frontier['target_return']*100, 
                        'b-', linewidth=2, label='Efficient Frontier')
                
                # Highlight optimal portfolio
                plt.plot(optimal_portfolio['volatility']*100, 
                        optimal_portfolio['expected_return']*100, 
                        'ro', markersize=10, label='Optimal (Max Sharpe)')
                
                plt.xlabel('Volatility (%)')
                plt.ylabel('Expected Return (%)')
                plt.title('Efficient Frontier')
                plt.legend()
                plt.grid(True, alpha=0.3)
                
                # Portfolio allocation
                plt.subplot(2, 2, 2)
                weights = optimal_portfolio['weights']
                significant_weights = [(symbols[i], w) for i, w in enumerate(weights) if w > 0.01]
                
                if significant_weights:
                    labels, values = zip(*significant_weights)
                    plt.pie(values, labels=labels, autopct='%1.1f%%')
                    plt.title('Optimal Portfolio Allocation')
                
                # Sharpe ratio across frontier
                plt.subplot(2, 2, 3)
                plt.plot(efficient_frontier['volatility']*100, 
                        efficient_frontier['sharpe_ratio'], 
                        'g-', linewidth=2)
                plt.axhline(optimal_portfolio['sharpe_ratio'], color='red', linestyle='--', 
                           label=f'Max Sharpe: {optimal_portfolio["sharpe_ratio"]:.3f}')
                plt.xlabel('Volatility (%)')
                plt.ylabel('Sharpe Ratio')
                plt.title('Sharpe Ratio vs Risk')
                plt.legend()
                plt.grid(True, alpha=0.3)
                
                # Expected returns by asset
                plt.subplot(2, 2, 4)
                asset_returns = robo.expected_returns * 100
                colors = ['green' if r > 0 else 'red' for r in asset_returns]
                bars = plt.bar(range(len(symbols)), asset_returns, color=colors, alpha=0.7)
                plt.xlabel('Assets')
                plt.ylabel('Expected Return (%)')
                plt.title('Expected Returns by Asset')
                plt.xticks(range(len(symbols)), symbols, rotation=45)
                plt.grid(True, alpha=0.3)
                
                plt.tight_layout()
                plt.show()
                
                print("\nRobo-adviser insights:")
                print(f"  - Automated optimisation enables low-cost advice")
                print(f"  - Diversification reduces risk without sacrificing return")
                print(f"  - Systematic approach removes emotional biases")
                print(f"  - Scalable technology serves mass market")

# Run the demonstration
demonstrate_robo_optimization()
Robo-Advisor Portfolio optimisation Demonstration
=======================================================
Loading data for 6 assets from Bloomberg database...
Error fetching market data: Ticker VTI not found in database

This analysis demonstrates the core technological capability that enables robo-advisers to serve mass markets cost-effectively. By automating portfolio optimisation, these services can provide sophisticated investment advice at a fraction of traditional costs.

5.0.1 The Problem with Naive Optimization: Estimation Error

The portfolio optimization we just implemented has a critical flaw: it treats estimated parameters (expected returns, covariances) as if they were true values. This ignores estimation error and leads to unstable, poorly performing portfolios.

WarningConnection to Statistical Foundations (Week 1, §0.2)

Recall Gelman’s principle: “All parameters are estimated with uncertainty.” When we compute expected returns from historical data, we get estimates μ̂ with standard errors. A stock with 10% historical return might truly have 8% or 12% expected return.

The problem: Portfolio optimization is extremely sensitive to small changes in inputs. A 1% change in expected return can shift optimal weights by 20-30 percentage points. Estimation error in inputs → massive uncertainty in outputs.

Why this matters: In-sample optimization produces weights that look optimal for historical data, but out-of-sample reality reveals that performance collapses because the estimates were wrong, reflecting overfitting where the optimizer exploits noise in the data rather than real patterns.

This is the bias-variance tradeoff. Unconstrained optimization (low bias) overfits to noise (high variance). We need regularization.

Show Python code
# Demonstrate estimation error sensitivity
def sensitivity_analysis(engine, perturbation=0.01):
    """Show how small changes in expected returns affect optimal weights"""
    print(f"\n=== Sensitivity Analysis: Impact of Estimation Error ===\n")
    
    # Baseline optimization  
    baseline = engine.optimize_portfolio()
    if baseline is None:
        return
    
    baseline_weights = baseline['weights']
    original_returns = engine.expected_returns.copy()
    
    # Perturb returns by ±1% (within estimation error)
    engine.expected_returns = original_returns + perturbation
    perturbed_up = engine.optimize_portfolio()
    
    engine.expected_returns = original_returns - perturbation
    perturbed_down = engine.optimize_portfolio()
    
    engine.expected_returns = original_returns  # Restore
    
    if perturbed_up and perturbed_down:
        weight_change_up = perturbed_up['weights'] - baseline_weights
        weight_change_down = perturbed_down['weights'] - baseline_weights
        
        print(f"Perturbation: ±{perturbation*100:.1f}% to all expected returns")
        print(f"\nMaximum weight change:")
        print(f"  +{perturbation*100:.0f}% perturbation: {np.abs(weight_change_up).max()*100:.1f} pp")
        print(f"  -{perturbation*100:.0f}% perturbation: {np.abs(weight_change_down).max()*100:.1f} pp")
        print(f"\nNote: a {perturbation*100:.0f}% error in expected returns (well within estimation error)")
        print(f"  causes {np.abs(weight_change_up).max()*100:.0f}pp weight changes: portfolios are VERY unstable!")

# Run demonstration
symbols = ['SPY', 'TLT', 'GLD', 'VNQ']
engine = RoboAdvisorEngine(symbols)
if engine.fetch_market_data():
    sensitivity_analysis(engine, perturbation=0.01)
Loading data for 4 assets from Bloomberg database...
Successfully prepared data:
   Trading days: 2106
   Assets: 4
   Expected returns range: -3.1% to 17.1%

=== Sensitivity Analysis: Impact of Estimation Error ===


Portfolio optimisation successful:
   Expected return: 10.60%
   Volatility: 10.28%
   Sharpe ratio: 0.836

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.60%
   Volatility: 10.28%
   Sharpe ratio: 0.933

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.69%
   Volatility: 10.40%
   Sharpe ratio: 0.739

   Optimal Allocation:
     SPY: 40.0%
     TLT: 18.6%
     GLD: 40.0%
     VNQ: 1.4%
Perturbation: ±1.0% to all expected returns

Maximum weight change:
  +1% perturbation: 0.0 pp
  -1% perturbation: 1.4 pp

Note: a 1% error in expected returns (well within estimation error)
  causes 0pp weight changes: portfolios are VERY unstable!
TipConnection to Statistical Foundations (Week 1, §0.2 & Ch 02, §2.2)

This demonstrates parameter uncertainty propagation: Small uncertainty in inputs → large uncertainty in outputs.

Why optimization is so sensitive: - High dimensionality (with 50 assets, covariance matrix has 1,275 parameters) - Optimizer exploits tiny differences in expected returns - No shrinkage: treats noise as signal

Data quality matters (Ch 02): Historical returns suffer from survivorship bias (failed stocks excluded from indices), measurement error (returns calculated from noisy prices), and non-stationarity (past returns do not equal future returns).

The solution: Regularisation (constraints, shrinkage) or Bayesian methods (acknowledge uncertainty explicitly).

5.0.2 Solution 1: Bootstrap Confidence Intervals for Weights

To quantify uncertainty, we can bootstrap the return data and see how optimal weights vary across resampled datasets.

Show Python code
def bootstrap_portfolio_weights(engine, n_bootstrap=200):
    """
    Bootstrap confidence intervals for optimal portfolio weights
    """
    print(f"\n=== Bootstrap Analysis ({n_bootstrap} iterations) ===\n")
    
    if engine.returns_data is None:
        print("No data available")
        return
    
    returns = engine.returns_data
    n_obs, n_assets = returns.shape
    
    bootstrap_weights = []
    
    # Bootstrap resampling
    np.random.seed(42)
    for i in range(n_bootstrap):
        # Resample with replacement
        indices = np.random.choice(n_obs, size=n_obs, replace=True)
        resampled_returns = returns.iloc[indices]
        
        # Re-estimate parameters
        engine.expected_returns = resampled_returns.mean() * 252
        engine.cov_matrix = resampled_returns.cov() * 252
        
        # Re-optimize
        result = engine.optimize_portfolio()
        if result:
            bootstrap_weights.append(result['weights'])
    
    if not bootstrap_weights:
        print("Bootstrap failed")
        return
    
    bootstrap_weights = np.array(bootstrap_weights)
    
    # Calculate statistics
    mean_weights = bootstrap_weights.mean(axis=0)
    std_weights = bootstrap_weights.std(axis=0)
    ci_lower = np.percentile(bootstrap_weights, 2.5, axis=0)
    ci_upper = np.percentile(bootstrap_weights, 97.5, axis=0)
    
    print(f"Bootstrap Results ({n_bootstrap} iterations):\n")
    print(f"{'Asset':<8} {'Mean':<8} {'Std Dev':<10} {'95% CI':<20}")
    print("="*50)
    for i, symbol in enumerate(engine.symbols):
        print(f"{symbol:<8} {mean_weights[i]*100:>6.1f}%  {std_weights[i]*100:>7.1f}%   "
              f"[{ci_lower[i]*100:>5.1f}%, {ci_upper[i]*100:>5.1f}%]")
    
    print("\nInterpretation:")
    print(f"  Mean Std Dev: {std_weights.mean()*100:.1f} pp")
    if std_weights.max() > 0.10:
        print(f"  High uncertainty (max {std_weights.max()*100:.0f}pp std) → unstable weights")
    else:
        print("  Moderate uncertainty → relatively stable weights")
    
    return {'mean_weights': mean_weights, 'ci_lower': ci_lower, 'ci_upper': ci_upper}

# Run bootstrap
bootstrap_results = bootstrap_portfolio_weights(engine, n_bootstrap=200)

=== Bootstrap Analysis (200 iterations) ===


Portfolio optimisation successful:
   Expected return: 19.41%
   Volatility: 13.19%
   Sharpe ratio: 1.321

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 9.22%
   Volatility: 10.09%
   Sharpe ratio: 0.715

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.81%
   Volatility: 10.64%
   Sharpe ratio: 1.016

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.21%
   Volatility: 10.66%
   Sharpe ratio: 0.582

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.1%
     GLD: 40.0%
     VNQ: 6.9%

Portfolio optimisation successful:
   Expected return: 13.78%
   Volatility: 10.95%
   Sharpe ratio: 1.075

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 19.72%
   Volatility: 12.09%
   Sharpe ratio: 1.465

   Optimal Allocation:
     SPY: 40.0%
     TLT: 4.7%
     GLD: 40.0%
     VNQ: 15.3%

Portfolio optimisation successful:
   Expected return: 15.78%
   Volatility: 10.32%
   Sharpe ratio: 1.336

   Optimal Allocation:
     SPY: 29.9%
     TLT: 28.1%
     GLD: 40.0%
     VNQ: 1.9%

Portfolio optimisation successful:
   Expected return: 12.00%
   Volatility: 10.02%
   Sharpe ratio: 0.997

   Optimal Allocation:
     SPY: 28.0%
     TLT: 32.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 7.69%
   Volatility: 10.40%
   Sharpe ratio: 0.547

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 19.18%
   Volatility: 10.49%
   Sharpe ratio: 1.637

   Optimal Allocation:
     SPY: 33.7%
     TLT: 12.9%
     GLD: 40.0%
     VNQ: 13.4%

Portfolio optimisation successful:
   Expected return: 21.74%
   Volatility: 12.88%
   Sharpe ratio: 1.533

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 10.53%
   Volatility: 10.11%
   Sharpe ratio: 0.844

   Optimal Allocation:
     SPY: 29.7%
     TLT: 30.3%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.16%
   Volatility: 10.55%
   Sharpe ratio: 0.963

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.8%
     GLD: 40.0%
     VNQ: 6.2%

Portfolio optimisation successful:
   Expected return: 11.01%
   Volatility: 10.12%
   Sharpe ratio: 0.890

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 19.00%
   Volatility: 11.24%
   Sharpe ratio: 1.513

   Optimal Allocation:
     SPY: 40.0%
     TLT: 5.5%
     GLD: 40.0%
     VNQ: 14.5%

Portfolio optimisation successful:
   Expected return: 9.25%
   Volatility: 10.67%
   Sharpe ratio: 0.680

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 7.79%
   Volatility: 10.58%
   Sharpe ratio: 0.547

   Optimal Allocation:
     SPY: 39.8%
     TLT: 20.2%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.94%
   Volatility: 10.79%
   Sharpe ratio: 1.106

   Optimal Allocation:
     SPY: 40.0%
     TLT: 14.0%
     GLD: 40.0%
     VNQ: 6.0%

Portfolio optimisation successful:
   Expected return: 5.62%
   Volatility: 9.99%
   Sharpe ratio: 0.362

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.99%
   Volatility: 10.22%
   Sharpe ratio: 0.880

   Optimal Allocation:
     SPY: 40.0%
     TLT: 15.9%
     GLD: 40.0%
     VNQ: 4.1%

Portfolio optimisation successful:
   Expected return: 4.07%
   Volatility: 10.83%
   Sharpe ratio: 0.191

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.16%
   Volatility: 10.08%
   Sharpe ratio: 1.008

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 16.45%
   Volatility: 12.28%
   Sharpe ratio: 1.176

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 19.3%

Portfolio optimisation successful:
   Expected return: 17.41%
   Volatility: 10.30%
   Sharpe ratio: 1.496

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.08%
   Volatility: 10.05%
   Sharpe ratio: 1.102

   Optimal Allocation:
     SPY: 40.0%
     TLT: 16.1%
     GLD: 40.0%
     VNQ: 3.9%

Portfolio optimisation successful:
   Expected return: 11.65%
   Volatility: 12.43%
   Sharpe ratio: 0.776

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 9.92%
   Volatility: 11.95%
   Sharpe ratio: 0.663

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 11.23%
   Volatility: 10.16%
   Sharpe ratio: 0.908

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 18.81%
   Volatility: 10.42%
   Sharpe ratio: 1.614

   Optimal Allocation:
     SPY: 40.0%
     TLT: 17.6%
     GLD: 40.0%
     VNQ: 2.4%

Portfolio optimisation successful:
   Expected return: 15.66%
   Volatility: 10.07%
   Sharpe ratio: 1.357

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.20%
   Volatility: 9.59%
   Sharpe ratio: 0.647

   Optimal Allocation:
     SPY: 27.9%
     TLT: 31.5%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.95%
   Volatility: 9.62%
   Sharpe ratio: 1.241

   Optimal Allocation:
     SPY: 27.3%
     TLT: 32.7%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.42%
   Volatility: 9.66%
   Sharpe ratio: 1.182

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.42%
   Volatility: 11.46%
   Sharpe ratio: 0.996

   Optimal Allocation:
     SPY: 40.0%
     TLT: 7.9%
     GLD: 40.0%
     VNQ: 12.1%

Portfolio optimisation successful:
   Expected return: 7.95%
   Volatility: 11.21%
   Sharpe ratio: 0.531

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.5%
     GLD: 40.0%
     VNQ: 6.5%

Portfolio optimisation successful:
   Expected return: 14.30%
   Volatility: 12.90%
   Sharpe ratio: 0.953

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 22.30%
   Volatility: 12.52%
   Sharpe ratio: 1.621

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 16.98%
   Volatility: 10.60%
   Sharpe ratio: 1.413

   Optimal Allocation:
     SPY: 40.0%
     TLT: 18.9%
     GLD: 40.0%
     VNQ: 1.1%

Portfolio optimisation successful:
   Expected return: 7.61%
   Volatility: 13.64%
   Sharpe ratio: 0.411

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 14.03%
   Volatility: 10.10%
   Sharpe ratio: 1.191

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 19.40%
   Volatility: 12.30%
   Sharpe ratio: 1.415

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 5.45%
   Volatility: 12.28%
   Sharpe ratio: 0.281

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 12.96%
   Volatility: 9.77%
   Sharpe ratio: 1.121

   Optimal Allocation:
     SPY: 32.1%
     TLT: 27.9%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.83%
   Volatility: 9.68%
   Sharpe ratio: 0.706

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 4.90%
   Volatility: 9.64%
   Sharpe ratio: 0.301

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.71%
   Volatility: 9.71%
   Sharpe ratio: 1.001

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 14.57%
   Volatility: 12.14%
   Sharpe ratio: 1.036

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 19.6%

Portfolio optimisation successful:
   Expected return: 7.94%
   Volatility: 9.99%
   Sharpe ratio: 0.595

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.27%
   Volatility: 10.84%
   Sharpe ratio: 0.856

   Optimal Allocation:
     SPY: 40.0%
     TLT: 15.8%
     GLD: 40.0%
     VNQ: 4.2%

Portfolio optimisation successful:
   Expected return: 13.76%
   Volatility: 10.06%
   Sharpe ratio: 1.170

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 17.48%
   Volatility: 10.61%
   Sharpe ratio: 1.459

   Optimal Allocation:
     SPY: 40.0%
     TLT: 16.6%
     GLD: 40.0%
     VNQ: 3.4%

Portfolio optimisation successful:
   Expected return: 16.19%
   Volatility: 10.13%
   Sharpe ratio: 1.401

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.01%
   Volatility: 12.68%
   Sharpe ratio: 0.632

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.9%
     GLD: 40.0%
     VNQ: 13.1%

Portfolio optimisation successful:
   Expected return: 9.38%
   Volatility: 11.73%
   Sharpe ratio: 0.629

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.6%
     GLD: 40.0%
     VNQ: 13.4%

Portfolio optimisation successful:
   Expected return: 6.02%
   Volatility: 9.97%
   Sharpe ratio: 0.403

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.72%
   Volatility: 11.25%
   Sharpe ratio: 0.686

   Optimal Allocation:
     SPY: 40.0%
     TLT: 14.7%
     GLD: 40.0%
     VNQ: 5.3%

Portfolio optimisation successful:
   Expected return: 9.51%
   Volatility: 10.23%
   Sharpe ratio: 0.734

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 14.73%
   Volatility: 11.12%
   Sharpe ratio: 1.145

   Optimal Allocation:
     SPY: 40.0%
     TLT: 10.3%
     GLD: 40.0%
     VNQ: 9.7%

Portfolio optimisation successful:
   Expected return: 11.86%
   Volatility: 9.79%
   Sharpe ratio: 1.007

   Optimal Allocation:
     SPY: 21.5%
     TLT: 38.5%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.69%
   Volatility: 9.75%
   Sharpe ratio: 0.994

   Optimal Allocation:
     SPY: 25.2%
     TLT: 32.3%
     GLD: 40.0%
     VNQ: 2.5%

Portfolio optimisation successful:
   Expected return: 10.92%
   Volatility: 9.63%
   Sharpe ratio: 0.926

   Optimal Allocation:
     SPY: 34.3%
     TLT: 25.7%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 15.29%
   Volatility: 10.88%
   Sharpe ratio: 1.222

   Optimal Allocation:
     SPY: 40.0%
     TLT: 7.7%
     GLD: 40.0%
     VNQ: 12.3%

Portfolio optimisation successful:
   Expected return: 6.55%
   Volatility: 12.76%
   Sharpe ratio: 0.357

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 12.06%
   Volatility: 10.09%
   Sharpe ratio: 0.997

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 6.27%
   Volatility: 13.84%
   Sharpe ratio: 0.309

   Optimal Allocation:
     SPY: 20.0%
     GLD: 40.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 8.07%
   Volatility: 11.58%
   Sharpe ratio: 0.525

   Optimal Allocation:
     SPY: 40.0%
     TLT: 10.4%
     GLD: 40.0%
     VNQ: 9.6%

Portfolio optimisation successful:
   Expected return: 12.48%
   Volatility: 12.46%
   Sharpe ratio: 0.841

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 11.62%
   Volatility: 11.50%
   Sharpe ratio: 0.836

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.7%
     GLD: 40.0%
     VNQ: 13.3%

Portfolio optimisation successful:
   Expected return: 25.53%
   Volatility: 12.49%
   Sharpe ratio: 1.884

   Optimal Allocation:
     SPY: 40.0%
     TLT: 3.1%
     GLD: 40.0%
     VNQ: 16.9%

Portfolio optimisation successful:
   Expected return: 14.26%
   Volatility: 12.26%
   Sharpe ratio: 1.000

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 12.60%
   Volatility: 10.39%
   Sharpe ratio: 1.020

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.32%
   Volatility: 10.48%
   Sharpe ratio: 0.889

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.8%
     GLD: 40.0%
     VNQ: 6.2%

Portfolio optimisation successful:
   Expected return: 9.99%
   Volatility: 10.67%
   Sharpe ratio: 0.749

   Optimal Allocation:
     SPY: 32.1%
     TLT: 27.9%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 6.25%
   Volatility: 10.45%
   Sharpe ratio: 0.407

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 7.97%
   Volatility: 10.21%
   Sharpe ratio: 0.585

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 7.10%
   Volatility: 9.79%
   Sharpe ratio: 0.521

   Optimal Allocation:
     SPY: 34.4%
     TLT: 25.6%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 5.38%
   Volatility: 9.80%
   Sharpe ratio: 0.345

   Optimal Allocation:
     SPY: 30.2%
     TLT: 29.8%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.27%
   Volatility: 10.10%
   Sharpe ratio: 0.621

   Optimal Allocation:
     SPY: 40.0%
     TLT: 17.9%
     GLD: 40.0%
     VNQ: 2.1%

Portfolio optimisation successful:
   Expected return: 8.76%
   Volatility: 10.00%
   Sharpe ratio: 0.676

   Optimal Allocation:
     SPY: 35.8%
     TLT: 24.2%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 15.56%
   Volatility: 9.95%
   Sharpe ratio: 1.362

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.03%
   Volatility: 10.17%
   Sharpe ratio: 0.691

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.26%
   Volatility: 12.82%
   Sharpe ratio: 0.723

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 19.7%

Portfolio optimisation successful:
   Expected return: 10.26%
   Volatility: 10.00%
   Sharpe ratio: 0.827

   Optimal Allocation:
     SPY: 23.0%
     TLT: 37.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.28%
   Volatility: 10.01%
   Sharpe ratio: 0.927

   Optimal Allocation:
     SPY: 30.0%
     TLT: 30.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.49%
   Volatility: 10.08%
   Sharpe ratio: 1.140

   Optimal Allocation:
     SPY: 40.0%
     TLT: 18.2%
     GLD: 40.0%
     VNQ: 1.8%

Portfolio optimisation successful:
   Expected return: 14.82%
   Volatility: 11.13%
   Sharpe ratio: 1.151

   Optimal Allocation:
     SPY: 40.0%
     TLT: 10.5%
     GLD: 40.0%
     VNQ: 9.5%

Portfolio optimisation successful:
   Expected return: 11.37%
   Volatility: 10.20%
   Sharpe ratio: 0.918

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.10%
   Volatility: 10.12%
   Sharpe ratio: 1.097

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 17.30%
   Volatility: 12.44%
   Sharpe ratio: 1.230

   Optimal Allocation:
     SPY: 40.0%
     GLD: 37.4%
     VNQ: 22.6%

Portfolio optimisation successful:
   Expected return: 9.84%
   Volatility: 10.88%
   Sharpe ratio: 0.720

   Optimal Allocation:
     SPY: 40.0%
     TLT: 11.7%
     GLD: 40.0%
     VNQ: 8.3%

Portfolio optimisation successful:
   Expected return: 12.06%
   Volatility: 9.96%
   Sharpe ratio: 1.010

   Optimal Allocation:
     SPY: 32.4%
     TLT: 27.6%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.15%
   Volatility: 9.64%
   Sharpe ratio: 0.846

   Optimal Allocation:
     SPY: 28.1%
     TLT: 31.9%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.58%
   Volatility: 12.70%
   Sharpe ratio: 0.833

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 12.81%
   Volatility: 10.68%
   Sharpe ratio: 1.012

   Optimal Allocation:
     SPY: 40.0%
     TLT: 14.3%
     GLD: 40.0%
     VNQ: 5.7%

Portfolio optimisation successful:
   Expected return: 8.78%
   Volatility: 9.65%
   Sharpe ratio: 0.702

   Optimal Allocation:
     SPY: 26.8%
     TLT: 33.2%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.32%
   Volatility: 10.12%
   Sharpe ratio: 0.922

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 6.88%
   Volatility: 10.68%
   Sharpe ratio: 0.457

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.85%
   Volatility: 9.77%
   Sharpe ratio: 1.008

   Optimal Allocation:
     SPY: 37.0%
     TLT: 23.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.14%
   Volatility: 9.91%
   Sharpe ratio: 0.821

   Optimal Allocation:
     SPY: 32.3%
     TLT: 27.7%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.88%
   Volatility: 11.61%
   Sharpe ratio: 0.679

   Optimal Allocation:
     TLT: 22.4%
     GLD: 40.0%
     VNQ: 37.1%

Portfolio optimisation successful:
   Expected return: 17.27%
   Volatility: 11.29%
   Sharpe ratio: 1.353

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.2%
     GLD: 40.0%
     VNQ: 6.8%

Portfolio optimisation successful:
   Expected return: 11.57%
   Volatility: 12.30%
   Sharpe ratio: 0.778

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 10.72%
   Volatility: 9.82%
   Sharpe ratio: 0.888

   Optimal Allocation:
     SPY: 34.7%
     TLT: 25.3%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 6.74%
   Volatility: 12.25%
   Sharpe ratio: 0.387

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 7.34%
   Volatility: 11.00%
   Sharpe ratio: 0.485

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.54%
   Volatility: 10.99%
   Sharpe ratio: 0.959

   Optimal Allocation:
     SPY: 40.0%
     TLT: 14.2%
     GLD: 40.0%
     VNQ: 5.8%

Portfolio optimisation successful:
   Expected return: 11.07%
   Volatility: 9.81%
   Sharpe ratio: 0.924

   Optimal Allocation:
     SPY: 29.7%
     TLT: 30.3%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.79%
   Volatility: 10.25%
   Sharpe ratio: 1.053

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.47%
   Volatility: 10.67%
   Sharpe ratio: 0.700

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 17.31%
   Volatility: 11.41%
   Sharpe ratio: 1.342

   Optimal Allocation:
     SPY: 40.0%
     TLT: 12.2%
     GLD: 40.0%
     VNQ: 7.8%

Portfolio optimisation successful:
   Expected return: 15.62%
   Volatility: 10.84%
   Sharpe ratio: 1.257

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.87%
   Volatility: 9.54%
   Sharpe ratio: 1.035

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 7.07%
   Volatility: 10.29%
   Sharpe ratio: 0.493

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.71%
   Volatility: 11.54%
   Sharpe ratio: 0.668

   Optimal Allocation:
     SPY: 40.0%
     TLT: 1.9%
     GLD: 40.0%
     VNQ: 18.1%

Portfolio optimisation successful:
   Expected return: 8.01%
   Volatility: 10.19%
   Sharpe ratio: 0.590

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.94%
   Volatility: 9.67%
   Sharpe ratio: 0.718

   Optimal Allocation:
     SPY: 27.5%
     TLT: 32.5%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.58%
   Volatility: 11.53%
   Sharpe ratio: 0.831

   Optimal Allocation:
     SPY: 10.3%
     TLT: 9.7%
     GLD: 40.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 9.14%
   Volatility: 10.28%
   Sharpe ratio: 0.694

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.25%
   Volatility: 10.85%
   Sharpe ratio: 1.038

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.99%
   Volatility: 10.53%
   Sharpe ratio: 0.948

   Optimal Allocation:
     SPY: 40.0%
     TLT: 18.1%
     GLD: 40.0%
     VNQ: 1.9%

Portfolio optimisation successful:
   Expected return: 12.30%
   Volatility: 10.15%
   Sharpe ratio: 1.015

   Optimal Allocation:
     SPY: 40.0%
     TLT: 19.1%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.80%
   Volatility: 11.17%
   Sharpe ratio: 0.787

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.9%
     GLD: 40.0%
     VNQ: 6.1%

Portfolio optimisation successful:
   Expected return: 7.52%
   Volatility: 10.44%
   Sharpe ratio: 0.529

   Optimal Allocation:
     SPY: 20.5%
     TLT: 39.5%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 5.81%
   Volatility: 10.64%
   Sharpe ratio: 0.358

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.30%
   Volatility: 10.21%
   Sharpe ratio: 0.911

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 16.90%
   Volatility: 11.04%
   Sharpe ratio: 1.350

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.2%
     GLD: 40.0%
     VNQ: 6.8%

Portfolio optimisation successful:
   Expected return: 9.03%
   Volatility: 14.02%
   Sharpe ratio: 0.502

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 13.71%
   Volatility: 11.79%
   Sharpe ratio: 0.993

   Optimal Allocation:
     SPY: 40.0%
     TLT: 2.7%
     GLD: 40.0%
     VNQ: 17.3%

Portfolio optimisation successful:
   Expected return: 12.89%
   Volatility: 10.52%
   Sharpe ratio: 1.035

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.86%
   Volatility: 9.94%
   Sharpe ratio: 0.892

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 5.43%
   Volatility: 10.33%
   Sharpe ratio: 0.332

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.27%
   Volatility: 12.74%
   Sharpe ratio: 0.806

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 13.14%
   Volatility: 10.12%
   Sharpe ratio: 1.101

   Optimal Allocation:
     SPY: 40.0%
     TLT: 15.1%
     GLD: 40.0%
     VNQ: 4.9%

Portfolio optimisation successful:
   Expected return: 18.46%
   Volatility: 12.56%
   Sharpe ratio: 1.311

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 15.53%
   Volatility: 12.92%
   Sharpe ratio: 1.047

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 21.66%
   Volatility: 13.23%
   Sharpe ratio: 1.486

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 12.55%
   Volatility: 11.75%
   Sharpe ratio: 0.899

   Optimal Allocation:
     SPY: 40.0%
     TLT: 1.9%
     GLD: 40.0%
     VNQ: 18.1%

Portfolio optimisation successful:
   Expected return: 9.16%
   Volatility: 10.35%
   Sharpe ratio: 0.692

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.06%
   Volatility: 10.46%
   Sharpe ratio: 0.961

   Optimal Allocation:
     SPY: 31.6%
     TLT: 28.4%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.48%
   Volatility: 11.68%
   Sharpe ratio: 0.898

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 7.00%
   Volatility: 9.76%
   Sharpe ratio: 0.513

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.34%
   Volatility: 10.16%
   Sharpe ratio: 0.821

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.29%
   Volatility: 12.10%
   Sharpe ratio: 0.602

   Optimal Allocation:
     SPY: 40.0%
     TLT: 10.4%
     GLD: 40.0%
     VNQ: 9.6%

Portfolio optimisation successful:
   Expected return: 7.48%
   Volatility: 10.95%
   Sharpe ratio: 0.501

   Optimal Allocation:
     SPY: 40.0%
     TLT: 10.1%
     GLD: 40.0%
     VNQ: 9.9%

Portfolio optimisation successful:
   Expected return: 9.38%
   Volatility: 11.49%
   Sharpe ratio: 0.642

   Optimal Allocation:
     SPY: 40.0%
     TLT: 9.9%
     GLD: 40.0%
     VNQ: 10.1%

Portfolio optimisation successful:
   Expected return: 13.73%
   Volatility: 10.86%
   Sharpe ratio: 1.080

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.24%
   Volatility: 9.85%
   Sharpe ratio: 0.634

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 4.36%
   Volatility: 10.41%
   Sharpe ratio: 0.227

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 14.46%
   Volatility: 9.97%
   Sharpe ratio: 1.250

   Optimal Allocation:
     SPY: 37.7%
     TLT: 22.3%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 6.83%
   Volatility: 9.85%
   Sharpe ratio: 0.490

   Optimal Allocation:
     SPY: 31.6%
     TLT: 28.4%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.01%
   Volatility: 14.17%
   Sharpe ratio: 0.706

   Optimal Allocation:
     SPY: 20.0%
     GLD: 40.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 16.03%
   Volatility: 10.54%
   Sharpe ratio: 1.331

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.51%
   Volatility: 9.63%
   Sharpe ratio: 1.092

   Optimal Allocation:
     SPY: 28.7%
     TLT: 31.3%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.29%
   Volatility: 12.31%
   Sharpe ratio: 0.917

   Optimal Allocation:
     SPY: 15.0%
     TLT: 18.5%
     GLD: 40.0%
     VNQ: 26.4%

Portfolio optimisation successful:
   Expected return: 15.70%
   Volatility: 12.08%
   Sharpe ratio: 1.134

   Optimal Allocation:
     SPY: 40.0%
     TLT: 2.2%
     GLD: 40.0%
     VNQ: 17.8%

Portfolio optimisation successful:
   Expected return: 8.09%
   Volatility: 12.70%
   Sharpe ratio: 0.480

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 18.11%
   Volatility: 10.25%
   Sharpe ratio: 1.571

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 7.14%
   Volatility: 12.67%
   Sharpe ratio: 0.406

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 8.12%
   Volatility: 13.32%
   Sharpe ratio: 0.460

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 9.62%
   Volatility: 9.62%
   Sharpe ratio: 0.793

   Optimal Allocation:
     SPY: 31.7%
     TLT: 37.7%
     GLD: 30.6%

Portfolio optimisation successful:
   Expected return: 11.03%
   Volatility: 10.40%
   Sharpe ratio: 0.868

   Optimal Allocation:
     SPY: 40.0%
     TLT: 18.2%
     GLD: 40.0%
     VNQ: 1.8%

Portfolio optimisation successful:
   Expected return: 17.91%
   Volatility: 14.17%
   Sharpe ratio: 1.122

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 19.6%

Portfolio optimisation successful:
   Expected return: 9.92%
   Volatility: 11.07%
   Sharpe ratio: 0.716

   Optimal Allocation:
     SPY: 40.0%
     TLT: 12.2%
     GLD: 40.0%
     VNQ: 7.8%

Portfolio optimisation successful:
   Expected return: 18.39%
   Volatility: 12.89%
   Sharpe ratio: 1.272

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 7.05%
   Volatility: 12.02%
   Sharpe ratio: 0.420

   Optimal Allocation:
     TLT: 21.8%
     GLD: 40.0%
     VNQ: 38.2%

Portfolio optimisation successful:
   Expected return: 13.63%
   Volatility: 10.19%
   Sharpe ratio: 1.141

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.87%
   Volatility: 9.91%
   Sharpe ratio: 0.895

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.03%
   Volatility: 9.65%
   Sharpe ratio: 0.624

   Optimal Allocation:
     SPY: 29.6%
     TLT: 30.4%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 12.14%
   Volatility: 11.32%
   Sharpe ratio: 0.896

   Optimal Allocation:
     SPY: 40.0%
     TLT: 2.3%
     GLD: 40.0%
     VNQ: 17.7%

Portfolio optimisation successful:
   Expected return: 12.83%
   Volatility: 11.81%
   Sharpe ratio: 0.917

   Optimal Allocation:
     SPY: 40.0%
     TLT: 2.3%
     GLD: 40.0%
     VNQ: 17.7%

Portfolio optimisation successful:
   Expected return: 15.96%
   Volatility: 11.60%
   Sharpe ratio: 1.203

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 10.15%
   Volatility: 10.32%
   Sharpe ratio: 0.790

   Optimal Allocation:
     SPY: 14.6%
     TLT: 20.9%
     GLD: 40.0%
     VNQ: 24.5%

Portfolio optimisation successful:
   Expected return: 10.90%
   Volatility: 11.12%
   Sharpe ratio: 0.800

   Optimal Allocation:
     SPY: 40.0%
     TLT: 14.5%
     GLD: 40.0%
     VNQ: 5.5%

Portfolio optimisation successful:
   Expected return: 15.34%
   Volatility: 9.67%
   Sharpe ratio: 1.380

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.04%
   Volatility: 10.50%
   Sharpe ratio: 0.766

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.98%
   Volatility: 10.65%
   Sharpe ratio: 0.749

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.35%
   Volatility: 9.80%
   Sharpe ratio: 0.852

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 14.27%
   Volatility: 12.59%
   Sharpe ratio: 0.974

   Optimal Allocation:
     SPY: 40.0%
     TLT: 4.6%
     GLD: 40.0%
     VNQ: 15.4%

Portfolio optimisation successful:
   Expected return: 7.45%
   Volatility: 10.19%
   Sharpe ratio: 0.534

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.1%
     GLD: 40.0%
     VNQ: 6.9%

Portfolio optimisation successful:
   Expected return: 12.62%
   Volatility: 10.60%
   Sharpe ratio: 1.002

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.00%
   Volatility: 11.00%
   Sharpe ratio: 0.546

   Optimal Allocation:
     TLT: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 10.93%
   Volatility: 10.60%
   Sharpe ratio: 0.843

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 11.51%
   Volatility: 10.46%
   Sharpe ratio: 0.909

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 6.88%
   Volatility: 12.60%
   Sharpe ratio: 0.388

   Optimal Allocation:
     SPY: 11.5%
     TLT: 8.5%
     GLD: 40.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 14.39%
   Volatility: 11.27%
   Sharpe ratio: 1.100

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.8%
     GLD: 40.0%
     VNQ: 13.2%

Portfolio optimisation successful:
   Expected return: 16.23%
   Volatility: 12.64%
   Sharpe ratio: 1.126

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 23.64%
   Volatility: 11.48%
   Sharpe ratio: 1.885

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.7%
     GLD: 40.0%
     VNQ: 13.3%

Portfolio optimisation successful:
   Expected return: 9.42%
   Volatility: 9.67%
   Sharpe ratio: 0.767

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 10.78%
   Volatility: 10.97%
   Sharpe ratio: 0.801

   Optimal Allocation:
     SPY: 38.3%
     TLT: 19.3%
     GLD: 40.0%
     VNQ: 2.4%

Portfolio optimisation successful:
   Expected return: 15.10%
   Volatility: 11.78%
   Sharpe ratio: 1.113

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 5.28%
   Volatility: 10.46%
   Sharpe ratio: 0.314

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 15.80%
   Volatility: 12.50%
   Sharpe ratio: 1.104

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 15.49%
   Volatility: 9.73%
   Sharpe ratio: 1.386

   Optimal Allocation:
     SPY: 31.9%
     TLT: 28.1%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 14.01%
   Volatility: 11.60%
   Sharpe ratio: 1.036

   Optimal Allocation:
     SPY: 40.0%
     TLT: 7.5%
     GLD: 40.0%
     VNQ: 12.5%

Portfolio optimisation successful:
   Expected return: 16.77%
   Volatility: 12.01%
   Sharpe ratio: 1.230

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 10.47%
   Volatility: 10.20%
   Sharpe ratio: 0.830

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 9.86%
   Volatility: 10.43%
   Sharpe ratio: 0.753

   Optimal Allocation:
     SPY: 40.0%
     TLT: 18.2%
     GLD: 40.0%
     VNQ: 1.8%

Portfolio optimisation successful:
   Expected return: 11.66%
   Volatility: 12.44%
   Sharpe ratio: 0.777

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 18.98%
   Volatility: 10.02%
   Sharpe ratio: 1.694

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.18%
   Volatility: 10.30%
   Sharpe ratio: 0.600

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%
Bootstrap Results (200 iterations):

Asset    Mean     Std Dev    95% CI              
==================================================
SPY        36.4%      7.8%   [ 14.5%,  40.0%]
TLT        16.1%     11.1%   [  0.0%,  40.0%]
GLD        39.9%      0.7%   [ 40.0%,  40.0%]
VNQ         7.6%      9.9%   [  0.0%,  37.1%]

Interpretation:
  Mean Std Dev: 7.4 pp
  High uncertainty (max 11pp std) → unstable weights
TipConnection to Statistical Foundations (Week 1, §0.2)

Bootstrap (Efron) provides uncertainty quantification without distributional assumptions. We resample return data, re-optimize on each sample, and examine the distribution of optimal weights.

What wide confidence intervals mean: - Large uncertainty in which assets to hold - Optimal weights unstable across plausible datasets - Small changes in data → large changes in recommended portfolio

This is parameter uncertainty visualized: The “optimal” portfolio isn’t a single point: it’s a distribution. Good robo-advisers acknowledge this via regularization or Bayesian approaches.

5.0.3 Solution 2: Out-of-Sample Validation with Rolling Windows

Inlä-sample optimization always looks good. The real test is out-of-sample performance: how do optimal portfolios perform on new data? Let’s implement rolling-window backtesting to evaluate honestly.

TipConnection to Statistical Foundations (Week 1, §0.4) & RAG Content

From our textbook KB extraction: Time-series cross-validation requires special treatment because returns are not exchangeable. We cannot randomly shuffle observations (this violates temporal structure); instead, we use rolling windows (train on the last N periods, test on the next period), expanding windows (train on all past data, which grows over time), or walk-forward methods (which mimic real-time forecasting).

Critical principle: At time t, use only data available before t. No future information leaks into training.

This is the same principle we applied in Ch 05 (marketplace lending) and Ch 07 (cryptocurrency forecasting). Cross-validation is standard practice, not optional.

Show Python code
def rolling_window_backtest(engine, train_window=252, test_window=21):
    """
    Rolling-window backtest of portfolio optimization
    
    Parameters:
    - train_window: Days of data for optimization (252 = 1 year)
    - test_window: Days to hold portfolio before rebalancing (21 = 1 month)
    """
    print(f"\n=== Rolling-Window Backtest ===")
    print(f"Train window: {train_window} days (~{train_window/252:.1f} years)")
    print(f"Test window: {test_window} days (~{test_window/21:.0f} months)")
    print(f"Rebalancing frequency: Monthly\n")
    
    returns = engine.returns_data
    n_obs = len(returns)
    
    # Prepare storage
    out_of_sample_returns = []
    optimal_weights_over_time = []
    dates = []
    
    # Rolling window loop
    for t in range(train_window, n_obs - test_window, test_window):
        # Training: Use last train_window days
        train_data = returns.iloc[t-train_window:t]
        
        # Estimate parameters on training data
        engine.expected_returns = train_data.mean() * 252
        engine.cov_matrix = train_data.cov() * 252
        
        # Optimize
        result = engine.optimize_portfolio()
        
        if result:
            weights = result['weights']
            optimal_weights_over_time.append(weights)
            
            # Test: Apply weights to next test_window days
            test_data = returns.iloc[t:t+test_window]
            portfolio_returns = (test_data * weights).sum(axis=1)
            
            out_of_sample_returns.extend(portfolio_returns.values)
            dates.extend(test_data.index)
    
    if not out_of_sample_returns:
        print("Backtest failed")
        return
    
    # Calculate performance metrics
    oos_returns = pd.Series(out_of_sample_returns, index=dates)
    annual_return = oos_returns.mean() * 252
    annual_vol = oos_returns.std() * np.sqrt(252)
    sharpe = (annual_return - engine.risk_free_rate) / annual_vol if annual_vol > 0 else 0
    
    # Compare to buy-and-hold equal-weight
    equal_weights = np.ones(len(engine.symbols)) / len(engine.symbols)
    bh_returns = (returns.iloc[train_window:train_window+len(oos_returns)] * equal_weights).sum(axis=1)
    bh_annual_return = bh_returns.mean() * 252
    bh_annual_vol = bh_returns.std() * np.sqrt(252)
    bh_sharpe = (bh_annual_return - engine.risk_free_rate) / bh_annual_vol if bh_annual_vol > 0 else 0
    
    print(f"Out-of-Sample Performance ({len(oos_returns)} days):\n")
    print(f"{'Metric':<20} {'Optimized':<12} {'Equal-Weight':<12} {'Difference'}")
    print("="*60)
    print(f"{'Annual Return':<20} {annual_return*100:>10.2f}%  {bh_annual_return*100:>10.2f}%  {(annual_return-bh_annual_return)*100:>+7.2f}%")
    print(f"{'Annual Volatility':<20} {annual_vol*100:>10.2f}%  {bh_annual_vol*100:>10.2f}%  {(annual_vol-bh_annual_vol)*100:>+7.2f}%")
    print(f"{'Sharpe Ratio':<20} {sharpe:>10.3f}  {bh_sharpe:>10.3f}  {sharpe-bh_sharpe:>+7.3f}")
    
    print("\nInterpretation:")
    if sharpe > bh_sharpe + 0.1:
        print(f"  Optimisation adds value out-of-sample (Sharpe +{sharpe-bh_sharpe:.2f})")
    elif sharpe > bh_sharpe - 0.1:
        print(f"  Optimisation performs similarly to equal-weight (Sharpe ≈ {bh_sharpe:.2f})")
    else:
        print(f"  Optimisation underperforms equal-weight (Sharpe -{bh_sharpe-sharpe:.2f})")
        print(f"     Likely cause: Overfitting to training data, parameter instability")
    
    # Cumulative returns plot
    cum_returns_opt = (1 + oos_returns).cumprod()
    cum_returns_bh = (1 + bh_returns.iloc[:len(oos_returns)]).cumprod()
    
    fig, ax = plt.subplots(figsize=(12, 6))
    ax.plot(cum_returns_opt.index, cum_returns_opt.values, 
            label='Optimized Portfolio', linewidth=2, color='blue')
    ax.plot(cum_returns_bh.index, cum_returns_bh.values, 
            label='Equal-Weight', linewidth=2, color='gray', linestyle='--')
    ax.set_xlabel('Date', fontsize=12)
    ax.set_ylabel('Cumulative Return', fontsize=12)
    ax.set_title('Out-of-Sample Performance: Does Optimization Work?', 
                 fontsize=14, fontweight='bold')
    ax.legend(fontsize=11)
    ax.grid(alpha=0.3)
    plt.tight_layout()
    plt.show()
    
    return {'oos_returns': oos_returns, 'weights_over_time': optimal_weights_over_time}

# Run backtest (only if data loaded successfully)
if engine.returns_data is not None and len(engine.returns_data) > 300:
    backtest_results = rolling_window_backtest(engine, train_window=252, test_window=21)
else:
    print("Insufficient data for backtest demonstration")

=== Rolling-Window Backtest ===
Train window: 252 days (~1.0 years)
Test window: 21 days (~1 months)
Rebalancing frequency: Monthly


Portfolio optimisation successful:
   Expected return: -6.72%
   Volatility: 10.86%
   Sharpe ratio: -0.804

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: -1.57%
   Volatility: 11.73%
   Sharpe ratio: -0.304

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 7.87%
   Volatility: 7.56%
   Sharpe ratio: 0.778

   Optimal Allocation:
     SPY: 9.4%
     TLT: 39.4%
     GLD: 11.2%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 8.51%
   Volatility: 10.28%
   Sharpe ratio: 0.633

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 8.51%
   Volatility: 8.63%
   Sharpe ratio: 0.755

   Optimal Allocation:
     SPY: 30.2%
     TLT: 32.3%
     VNQ: 37.5%

Portfolio optimisation successful:
   Expected return: 8.06%
   Volatility: 8.07%
   Sharpe ratio: 0.750

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 8.30%
   Volatility: 5.80%
   Sharpe ratio: 1.087

   Optimal Allocation:
     SPY: 19.0%
     TLT: 40.0%
     GLD: 25.3%
     VNQ: 15.7%

Portfolio optimisation successful:
   Expected return: 11.35%
   Volatility: 6.21%
   Sharpe ratio: 1.507

   Optimal Allocation:
     SPY: 17.8%
     TLT: 35.9%
     GLD: 40.0%
     VNQ: 6.3%

Portfolio optimisation successful:
   Expected return: 16.07%
   Volatility: 6.67%
   Sharpe ratio: 2.111

   Optimal Allocation:
     SPY: 15.8%
     TLT: 40.0%
     GLD: 40.0%
     VNQ: 4.2%

Portfolio optimisation successful:
   Expected return: 17.77%
   Volatility: 7.01%
   Sharpe ratio: 2.250

   Optimal Allocation:
     SPY: 13.7%
     TLT: 39.2%
     GLD: 35.8%
     VNQ: 11.4%

Portfolio optimisation successful:
   Expected return: 18.92%
   Volatility: 6.88%
   Sharpe ratio: 2.460

   Optimal Allocation:
     SPY: 17.3%
     TLT: 40.0%
     GLD: 26.7%
     VNQ: 16.0%

Portfolio optimisation successful:
   Expected return: 16.83%
   Volatility: 6.51%
   Sharpe ratio: 2.278

   Optimal Allocation:
     SPY: 36.7%
     TLT: 40.0%
     GLD: 23.3%

Portfolio optimisation successful:
   Expected return: 19.13%
   Volatility: 6.54%
   Sharpe ratio: 2.620

   Optimal Allocation:
     SPY: 40.0%
     TLT: 36.9%
     GLD: 17.9%
     VNQ: 5.2%

Portfolio optimisation successful:
   Expected return: 18.87%
   Volatility: 6.31%
   Sharpe ratio: 2.673

   Optimal Allocation:
     SPY: 40.0%
     TLT: 24.7%
     GLD: 34.3%
     VNQ: 1.0%

Portfolio optimisation successful:
   Expected return: 17.36%
   Volatility: 6.07%
   Sharpe ratio: 2.528

   Optimal Allocation:
     SPY: 40.0%
     TLT: 33.5%
     GLD: 26.1%

Portfolio optimisation successful:
   Expected return: 20.68%
   Volatility: 6.83%
   Sharpe ratio: 2.736

   Optimal Allocation:
     SPY: 31.8%
     TLT: 40.0%
     GLD: 28.2%

Portfolio optimisation successful:
   Expected return: 21.60%
   Volatility: 11.73%
   Sharpe ratio: 1.671

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 24.71%
   Volatility: 12.21%
   Sharpe ratio: 1.860

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 22.13%
   Volatility: 12.43%
   Sharpe ratio: 1.619

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 20.98%
   Volatility: 12.31%
   Sharpe ratio: 1.543

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 22.23%
   Volatility: 12.00%
   Sharpe ratio: 1.685

   Optimal Allocation:
     SPY: 21.9%
     TLT: 38.1%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 22.18%
   Volatility: 12.46%
   Sharpe ratio: 1.619

   Optimal Allocation:
     SPY: 24.6%
     TLT: 35.4%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 20.26%
   Volatility: 12.46%
   Sharpe ratio: 1.465

   Optimal Allocation:
     SPY: 22.4%
     TLT: 37.6%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 22.03%
   Volatility: 12.35%
   Sharpe ratio: 1.622

   Optimal Allocation:
     SPY: 20.0%
     TLT: 40.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 19.42%
   Volatility: 12.72%
   Sharpe ratio: 1.369

   Optimal Allocation:
     SPY: 23.7%
     TLT: 38.7%
     GLD: 37.6%

Portfolio optimisation successful:
   Expected return: 17.82%
   Volatility: 12.65%
   Sharpe ratio: 1.251

   Optimal Allocation:
     SPY: 25.5%
     TLT: 40.0%
     GLD: 34.5%

Portfolio optimisation successful:
   Expected return: 15.85%
   Volatility: 13.28%
   Sharpe ratio: 1.043

   Optimal Allocation:
     SPY: 26.6%
     TLT: 33.4%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 13.92%
   Volatility: 15.66%
   Sharpe ratio: 0.761

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 32.90%
   Volatility: 16.95%
   Sharpe ratio: 1.823

   Optimal Allocation:
     SPY: 40.0%
     GLD: 28.4%
     VNQ: 31.6%

Portfolio optimisation successful:
   Expected return: 29.06%
   Volatility: 15.17%
   Sharpe ratio: 1.784

   Optimal Allocation:
     SPY: 40.0%
     GLD: 22.7%
     VNQ: 37.3%

Portfolio optimisation successful:
   Expected return: 22.16%
   Volatility: 13.82%
   Sharpe ratio: 1.458

   Optimal Allocation:
     SPY: 40.0%
     GLD: 29.3%
     VNQ: 30.7%

Portfolio optimisation successful:
   Expected return: 21.46%
   Volatility: 12.51%
   Sharpe ratio: 1.555

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.5%
     GLD: 13.5%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 19.10%
   Volatility: 11.70%
   Sharpe ratio: 1.461

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 19.21%
   Volatility: 11.58%
   Sharpe ratio: 1.486

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 23.02%
   Volatility: 10.50%
   Sharpe ratio: 2.002

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 22.42%
   Volatility: 10.65%
   Sharpe ratio: 1.917

   Optimal Allocation:
     SPY: 40.0%
     TLT: 16.7%
     GLD: 3.3%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 21.37%
   Volatility: 10.20%
   Sharpe ratio: 1.900

   Optimal Allocation:
     SPY: 40.0%
     TLT: 2.7%
     GLD: 17.3%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 21.01%
   Volatility: 10.01%
   Sharpe ratio: 1.898

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 18.24%
   Volatility: 10.20%
   Sharpe ratio: 1.592

   Optimal Allocation:
     SPY: 40.0%
     TLT: 17.6%
     GLD: 2.4%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 14.37%
   Volatility: 10.73%
   Sharpe ratio: 1.152

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.9%
     GLD: 6.1%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 13.99%
   Volatility: 10.21%
   Sharpe ratio: 1.175

   Optimal Allocation:
     SPY: 20.0%
     GLD: 40.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 10.24%
   Volatility: 10.22%
   Sharpe ratio: 0.807

   Optimal Allocation:
     SPY: 20.0%
     GLD: 40.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: -1.68%
   Volatility: 13.15%
   Sharpe ratio: -0.280

   Optimal Allocation:
     SPY: 40.0%
     GLD: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: 2.84%
   Volatility: 11.41%
   Sharpe ratio: 0.073

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: -7.81%
   Volatility: 15.15%
   Sharpe ratio: -0.647

   Optimal Allocation:
     SPY: 40.0%
     GLD: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: -2.98%
   Volatility: 12.79%
   Sharpe ratio: -0.390

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: -10.63%
   Volatility: 16.06%
   Sharpe ratio: -0.787

   Optimal Allocation:
     SPY: 40.0%
     GLD: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: -13.38%
   Volatility: 14.22%
   Sharpe ratio: -1.081

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: -19.19%
   Volatility: 18.26%
   Sharpe ratio: -1.161

   Optimal Allocation:
     SPY: 40.0%
     GLD: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: -8.53%
   Volatility: 16.25%
   Sharpe ratio: -0.648

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: -11.96%
   Volatility: 16.50%
   Sharpe ratio: -0.846

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: -4.11%
   Volatility: 16.52%
   Sharpe ratio: -0.370

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: -10.02%
   Volatility: 19.59%
   Sharpe ratio: -0.613

   Optimal Allocation:
     SPY: 40.0%
     GLD: 20.0%
     VNQ: 40.0%

Portfolio optimisation successful:
   Expected return: -6.16%
   Volatility: 14.08%
   Sharpe ratio: -0.580

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 3.44%
   Volatility: 13.42%
   Sharpe ratio: 0.107

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 0.48%
   Volatility: 13.22%
   Sharpe ratio: -0.115

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 8.12%
   Volatility: 12.66%
   Sharpe ratio: 0.484

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 6.37%
   Volatility: 14.23%
   Sharpe ratio: 0.307

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 6.90%
   Volatility: 12.89%
   Sharpe ratio: 0.380

   Optimal Allocation:
     SPY: 40.0%
     TLT: 8.8%
     GLD: 40.0%
     VNQ: 11.2%

Portfolio optimisation successful:
   Expected return: 13.25%
   Volatility: 12.66%
   Sharpe ratio: 0.889

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 19.5%

Portfolio optimisation successful:
   Expected return: 9.51%
   Volatility: 11.07%
   Sharpe ratio: 0.678

   Optimal Allocation:
     SPY: 40.0%
     TLT: 12.3%
     GLD: 40.0%
     VNQ: 7.7%

Portfolio optimisation successful:
   Expected return: 7.12%
   Volatility: 9.91%
   Sharpe ratio: 0.517

   Optimal Allocation:
     SPY: 40.0%
     TLT: 7.8%
     GLD: 40.0%
     VNQ: 12.2%

Portfolio optimisation successful:
   Expected return: 14.48%
   Volatility: 9.79%
   Sharpe ratio: 1.274

   Optimal Allocation:
     SPY: 40.0%
     TLT: 4.1%
     GLD: 40.0%
     VNQ: 15.9%

Portfolio optimisation successful:
   Expected return: 8.33%
   Volatility: 9.70%
   Sharpe ratio: 0.652

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 12.56%
   Volatility: 9.16%
   Sharpe ratio: 1.153

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.0%
     GLD: 40.0%
     VNQ: 14.0%

Portfolio optimisation successful:
   Expected return: 16.62%
   Volatility: 9.34%
   Sharpe ratio: 1.565

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 16.97%
   Volatility: 9.68%
   Sharpe ratio: 1.547

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 18.49%
   Volatility: 9.74%
   Sharpe ratio: 1.692

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 17.55%
   Volatility: 10.09%
   Sharpe ratio: 1.541

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 15.87%
   Volatility: 9.63%
   Sharpe ratio: 1.441

   Optimal Allocation:
     SPY: 40.0%
     TLT: 8.6%
     GLD: 40.0%
     VNQ: 11.4%

Portfolio optimisation successful:
   Expected return: 20.20%
   Volatility: 10.35%
   Sharpe ratio: 1.758

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.0%
     GLD: 40.0%
     VNQ: 14.0%

Portfolio optimisation successful:
   Expected return: 21.73%
   Volatility: 10.21%
   Sharpe ratio: 1.933

   Optimal Allocation:
     SPY: 40.0%
     TLT: 9.9%
     GLD: 40.0%
     VNQ: 10.1%

Portfolio optimisation successful:
   Expected return: 27.45%
   Volatility: 9.70%
   Sharpe ratio: 2.624

   Optimal Allocation:
     SPY: 40.0%
     TLT: 13.3%
     GLD: 40.0%
     VNQ: 6.7%

Portfolio optimisation successful:
   Expected return: 26.77%
   Volatility: 10.09%
   Sharpe ratio: 2.454

   Optimal Allocation:
     SPY: 40.0%
     TLT: 4.0%
     GLD: 40.0%
     VNQ: 16.0%

Portfolio optimisation successful:
   Expected return: 22.95%
   Volatility: 10.06%
   Sharpe ratio: 2.082

   Optimal Allocation:
     SPY: 40.0%
     TLT: 3.1%
     GLD: 40.0%
     VNQ: 16.9%

Portfolio optimisation successful:
   Expected return: 19.88%
   Volatility: 10.26%
   Sharpe ratio: 1.743

   Optimal Allocation:
     SPY: 40.0%
     TLT: 6.3%
     GLD: 40.0%
     VNQ: 13.7%

Portfolio optimisation successful:
   Expected return: 22.65%
   Volatility: 10.34%
   Sharpe ratio: 1.997

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 19.31%
   Volatility: 10.04%
   Sharpe ratio: 1.724

   Optimal Allocation:
     SPY: 40.0%
     TLT: 2.7%
     GLD: 40.0%
     VNQ: 17.3%

Portfolio optimisation successful:
   Expected return: 19.46%
   Volatility: 9.56%
   Sharpe ratio: 1.828

   Optimal Allocation:
     SPY: 40.0%
     TLT: 3.4%
     GLD: 40.0%
     VNQ: 16.6%

Portfolio optimisation successful:
   Expected return: 19.64%
   Volatility: 9.53%
   Sharpe ratio: 1.851

   Optimal Allocation:
     SPY: 40.0%
     TLT: 12.8%
     GLD: 40.0%
     VNQ: 7.2%

Portfolio optimisation successful:
   Expected return: 20.35%
   Volatility: 10.20%
   Sharpe ratio: 1.798

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 16.52%
   Volatility: 10.22%
   Sharpe ratio: 1.421

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 17.18%
   Volatility: 9.24%
   Sharpe ratio: 1.642

   Optimal Allocation:
     SPY: 40.0%
     TLT: 1.3%
     GLD: 40.0%
     VNQ: 18.7%

Portfolio optimisation successful:
   Expected return: 15.60%
   Volatility: 9.14%
   Sharpe ratio: 1.488

   Optimal Allocation:
     SPY: 40.0%
     GLD: 40.0%
     VNQ: 20.0%

Portfolio optimisation successful:
   Expected return: 14.62%
   Volatility: 8.27%
   Sharpe ratio: 1.527

   Optimal Allocation:
     SPY: 40.0%
     TLT: 15.1%
     GLD: 40.0%
     VNQ: 4.9%

Portfolio optimisation successful:
   Expected return: 17.43%
   Volatility: 8.75%
   Sharpe ratio: 1.764

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 15.50%
   Volatility: 8.61%
   Sharpe ratio: 1.568

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 40.0%

Portfolio optimisation successful:
   Expected return: 21.22%
   Volatility: 8.09%
   Sharpe ratio: 2.375

   Optimal Allocation:
     SPY: 40.0%
     TLT: 17.6%
     GLD: 40.0%
     VNQ: 2.4%
Out-of-Sample Performance (1848 days):

Metric               Optimized    Equal-Weight Difference
============================================================
Annual Return             10.58%        8.56%    +2.02%
Annual Volatility         11.69%       11.60%    +0.09%
Sharpe Ratio              0.734       0.566   +0.168

Interpretation:
  Optimisation adds value out-of-sample (Sharpe +0.17)

WarningThe Harsh Reality of Portfolio Optimization

Common finding: Optimized portfolios often underperform simple equal-weight portfolios out-of-sample. Why? First, estimation error dominates: with limited data, parameter estimates are noisy, and the optimizer exploits noise rather than signal. Second, non-stationarity means that past returns do not equal future returns, so optimal weights for 2010-2020 fail in 2021-2025. Third, transaction costs from frequent rebalancing erode returns (not modeled here but critical in practice). Fourth, overfitting causes high in-sample Sharpe ratios (1.5) to collapse to 0.5 out-of-sample.

What works better: Regularization (constraining weights to no more than 30% per asset), shrinkage (pulling estimated returns toward the overall mean using Bayesian methods), simplicity (equal-weight or market-cap-weight portfolios often beat optimization), and robustness (using robust covariance estimators and longer estimation windows).

This is why robo-advisers use simple portfolios (5-10 ETFs, mechanical rules) rather than complex optimization.

TipConnection to Ch 03: Time-Varying Volatility

Why rolling windows matter: Volatility isn’t constant (recall Ch 03 GARCH models). Markets have regimes: calm periods (2012-2019) with low volatility where mean-reversion strategies work, and crisis periods (2008, 2020) with high volatility where correlations spike to 1.

Using 10-year expanding windows averages across regimes, producing misleading estimates. Rolling 1-year windows adapt faster but have higher estimation error.

Trade-off: Longer windows (more data, lower estimation error) vs. shorter windows (adapt to regime changes, higher noise).

Most robo-advisers use 3-5 year rolling windows as compromise.

5.0.4 Solution 3: Bayesian Shrinkage (James-Stein / Black-Litterman)

Instead of using raw sample estimates, shrink them toward a sensible prior. This reduces estimation error and stabilizes weights.

TipConnection to Statistical Foundations (Week 1, §0.6)

From our foundations chapter (lines 443-521): Bayesian weighted average

\[\hat{\theta}_{\text{Bayes}} = \frac{\hat{\theta}_{\text{prior}}/\text{se}_{\text{prior}}^2 + \hat{\theta}_{\text{data}}/\text{se}_{\text{data}}^2}{1/\text{se}_{\text{prior}}^2 + 1/\text{se}_{\text{data}}^2}\]

Combines prior and data by precision (inverse variance). More precise source gets more weight.

Portfolio application: Shrink sample expected returns toward the market return (Black-Litterman approach), the overall mean (James-Stein approach), or zero (naive diversification where all assets are equal).

Why this helps: Sample means are extremely noisy. Shrinking toward a reasonable prior reduces variance at cost of small bias: net improvement via bias-variance tradeoff.

Show Python code
def bayesian_shrinkage_portfolio(engine, shrinkage=0.5):
    """
    Apply Bayesian shrinkage to expected returns before optimization
    
    Parameters:
    - shrinkage: 0 = no shrinkage (use sample), 1 = full shrinkage (use prior only)
    """
    print(f"\n=== Bayesian Shrinkage Portfolio (shrinkage={shrinkage}) ===\n")
    
    # Sample estimates (noisy)
    sample_returns = engine.expected_returns.copy()
    
    # Prior: Overall mean (James-Stein) or market-cap-weighted return
    prior_return = sample_returns.mean()  # Simple: use grand mean as prior
    
    # Shrink toward prior
    shrunk_returns = shrinkage * prior_return + (1 - shrinkage) * sample_returns
    
    print(f"Shrinkage toward grand mean ({prior_return*100:.2f}%):\n")
    print(f"{'Asset':<8} {'Sample':<10} {'Shrunk':<10} {'Difference'}")
    print("="*45)
    for i, symbol in enumerate(engine.symbols):
        diff = shrunk_returns[symbol] - sample_returns[symbol]
        print(f"{symbol:<8} {sample_returns[symbol]*100:>8.2f}%  {shrunk_returns[symbol]*100:>8.2f}%  {diff*100:>+7.2f}%")
    
    # Optimize with shrunk returns
    engine.expected_returns = shrunk_returns
    result = engine.optimize_portfolio()
    
    print("\nInterpretation:")
    print(f"  Shrinkage pulls extreme estimates toward mean")
    print(f"  Reduces estimation error at cost of small bias")
    print(f"  Result: More stable weights, better out-of-sample performance")
    
    return result

# Compare no shrinkage vs. shrinkage (only if data loaded)
if engine.expected_returns is not None:
    print("\n" + "="*60)
    print("COMPARISON: No Shrinkage vs. Bayesian Shrinkage")
    print("="*60)
    
    # Reset to sample estimates
    sample_returns_backup = engine.expected_returns.copy()
    
    # No shrinkage
    print("\n[1] No Shrinkage (use raw sample estimates):")
    result_no_shrink = engine.optimize_portfolio()
    
    # 50% shrinkage
    print("\n[2] 50% Shrinkage toward mean:")
    result_shrink = bayesian_shrinkage_portfolio(engine, shrinkage=0.5)
    
    # Restore
    engine.expected_returns = sample_returns_backup
else:
    print("Insufficient data for shrinkage comparison demonstration")

============================================================
COMPARISON: No Shrinkage vs. Bayesian Shrinkage
============================================================

[1] No Shrinkage (use raw sample estimates):

Portfolio optimisation successful:
   Expected return: 21.22%
   Volatility: 8.09%
   Sharpe ratio: 2.375

   Optimal Allocation:
     SPY: 40.0%
     TLT: 17.6%
     GLD: 40.0%
     VNQ: 2.4%

[2] 50% Shrinkage toward mean:

=== Bayesian Shrinkage Portfolio (shrinkage=0.5) ===

Shrinkage toward grand mean (13.66%):

Asset    Sample     Shrunk     Difference
=============================================
SPY          0.00%      6.83%    +6.83%
TLT          0.47%      7.06%    +6.60%
GLD         52.76%     33.21%   -19.55%
VNQ          1.40%      7.53%    +6.13%

Portfolio optimisation successful:
   Expected return: 16.69%
   Volatility: 7.68%
   Sharpe ratio: 1.911

   Optimal Allocation:
     SPY: 40.0%
     TLT: 20.0%
     GLD: 37.1%
     VNQ: 2.9%

Interpretation:
  Shrinkage pulls extreme estimates toward mean
  Reduces estimation error at cost of small bias
  Result: More stable weights, better out-of-sample performance

5.0.5 Solution 4: Denoising the Covariance Matrix via Random Matrix Theory

Bayesian shrinkage addresses estimation error at the level of individual parameters — pulling extreme return estimates toward more conservative priors. But for the covariance matrix at scale, the problem is more severe. A portfolio of \(M = 500\) assets has \(M(M+1)/2 = 125{,}250\) unique entries. Estimated from \(T = 1{,}250\) daily observations (five years), the Q-ratio \(Q = T/M = 2.5\) means we have barely two-and-a-half observations per parameter. Under these conditions, the eigenvalue spectrum of the sample covariance matrix is dominated by noise, and portfolio weights built on it will be brittle and unstable.

Random Matrix Theory (RMT) offers a principled diagnostic and cure. The key result is the Marcenko-Pastur Law (Marčenko and Pastur 1967; Laloux et al. 1999), which characterises the eigenvalue distribution of a purely random covariance matrix — one estimated from data with no true correlations whatsoever. If returns were completely uncorrelated, eigenvalues would be confined within the bounds:

\[\lambda_{\pm} = \sigma^2 \left(1 \pm \sqrt{\frac{M}{T}}\right)^2\]

Any eigenvalue of the empirical covariance matrix that falls below \(\lambda_+\) is statistically indistinguishable from noise. For \(Q = 2.5\), the upper bound \(\lambda_+\) is roughly \(3.5\sigma^2\) — and the majority of a 500-asset portfolio’s eigenvalues typically lie below this threshold. These hundreds of “noise” eigenvectors encode no genuine co-movement between assets; they are artefacts of sampling fluctuation.

The denoising algorithm has three steps. First, eigendecompose the sample covariance matrix: \(\hat{\Sigma} = V \Lambda V^\top\), where \(V\) contains the eigenvectors as columns and \(\Lambda = \mathrm{diag}(\lambda_1, \ldots, \lambda_M)\). Second, classify each eigenvalue: those above \(\lambda_+\) carry signal (systematic factors, sector correlations, macro exposures); those below are noise. Third, replace noise eigenvalues with their mean and reconstruct the denoised matrix: \(\hat{\Sigma}_{\text{denoised}} = V \hat{\Lambda} V^\top\).

The eigenvectors are preserved throughout. This is the critical difference from isotropic shrinkage: rather than uniformly scaling all elements of \(\hat{\Sigma}\) toward the identity, RMT denoising identifies and respects the genuine risk directions (large eigenvalues corresponding to the market factor, sector exposures, and other systematic sources) whilst removing the idiosyncratic noise that distorts portfolio weights.

Show Python code
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from scipy.optimize import minimize

etfs = ["SPY", "TLT", "GLD", "QQQ", "EFA", "BND", "IWM", "VNQ"]
bbg = load_bloomberg()
pivot = (
    bbg[bbg["ticker"].isin(etfs)]
    .pivot_table(index="date", columns="ticker", values="log_return", aggfunc="mean")
    .dropna()
)
M, T = pivot.shape[1], pivot.shape[0]
print(f"Bloomberg ETF universe: M={M} assets, T={T} daily observations, Q=T/M={T/M:.0f}")

# ── Step 1: eigendecompose the sample covariance matrix
cov_sample = pivot.cov().values
eigenvalues, eigenvectors = np.linalg.eigh(cov_sample)  # ascending order

# ── Step 2: Marcenko-Pastur noise bound
sigma2 = np.mean(eigenvalues)
lam_plus  = sigma2 * (1 + 1 / np.sqrt(T / M)) ** 2
lam_minus = sigma2 * (1 - 1 / np.sqrt(T / M)) ** 2
n_signal = np.sum(eigenvalues > lam_plus)
print(f"Marcenko-Pastur bounds: [{lam_minus:.6f}, {lam_plus:.6f}]")
print(f"Signal eigenvalues (above λ+): {n_signal} of {M}")
print(f"Noise eigenvalues  (below λ+): {M - n_signal} of {M}")

# ── Step 3: replace noise eigenvalues with their mean, reconstruct
noise_mask = eigenvalues < lam_plus
ev_denoised = eigenvalues.copy()
if noise_mask.any():
    ev_denoised[noise_mask] = eigenvalues[noise_mask].mean()
cov_denoised = eigenvectors @ np.diag(ev_denoised) @ eigenvectors.T

print(f"\nCondition number — sample covariance:   {np.linalg.cond(cov_sample * 252):.1f}")
print(f"Condition number — denoised covariance: {np.linalg.cond(cov_denoised * 252):.1f}")
print("(Lower = better conditioned = more numerically stable optimisation)")

# ── Annualised covariances and expected returns for portfolio optimisation
cov_ann_s = cov_sample * 252
cov_ann_d = cov_denoised * 252
mu_ann = pivot.mean().values * 252

def max_sharpe(cov, mu, rf=0.02):
    n = len(mu)
    def neg_sr(w):
        return -(np.dot(w, mu) - rf) / np.sqrt(w @ cov @ w)
    res = minimize(
        neg_sr, np.ones(n) / n, method="SLSQP",
        bounds=[(0.0, 0.40)] * n,
        constraints={"type": "eq", "fun": lambda w: w.sum() - 1},
        options={"maxiter": 1000},
    )
    return res.x

w_sample   = max_sharpe(cov_ann_s, mu_ann)
w_denoised = max_sharpe(cov_ann_d, mu_ann)

# ── Figure
fig, axes = plt.subplots(1, 2, figsize=(14, 5))

sorted_evs = sorted(eigenvalues, reverse=True)
colours = ["steelblue" if ev > lam_plus else "lightcoral" for ev in sorted_evs]

ax = axes[0]
ax.bar(range(M), sorted_evs, color=colours, alpha=0.85)
ax.axhline(lam_plus, color="red", linestyle="--", linewidth=2,
           label=f"MP upper bound λ+ = {lam_plus:.5f}")
ax.axhline(lam_minus, color="orange", linestyle=":", linewidth=1.5,
           label=f"MP lower bound λ− = {lam_minus:.5f}")
ax.set_xlabel("Eigenvalue rank (largest to smallest)")
ax.set_ylabel("Eigenvalue magnitude")
ax.set_title("Bloomberg 8-ETF Eigenvalue Spectrum\nvs Marcenko-Pastur Noise Bound", fontsize=11)
ax.legend(fontsize=9)
ax.grid(alpha=0.3)

x = np.arange(M)
w = 0.35
ax2 = axes[1]
ax2.bar(x - w/2, w_sample * 100,   w, label="Sample covariance",       alpha=0.8, color="steelblue")
ax2.bar(x + w/2, w_denoised * 100, w, label="RMT-denoised covariance", alpha=0.8, color="coral")
ax2.set_xticks(x)
ax2.set_xticklabels(etfs, rotation=45, ha="right")
ax2.set_ylabel("Portfolio weight (%)")
ax2.set_title("Optimal Weights: Sample vs RMT-Denoised\nCovariance Matrix", fontsize=11)
ax2.legend(fontsize=9)
ax2.grid(alpha=0.3, axis="y")

plt.tight_layout()
plt.show()
Bloomberg ETF universe: M=8 assets, T=1760 daily observations, Q=T/M=220
Marcenko-Pastur bounds: [0.000128, 0.000168]
Signal eigenvalues (above λ+): 1 of 8
Noise eigenvalues  (below λ+): 7 of 8

Condition number — sample covariance:   218.3
Condition number — denoised covariance: 15.3
(Lower = better conditioned = more numerically stable optimisation)
Figure 1: Left: eigenvalue spectrum of the Bloomberg 8-ETF covariance matrix (blue bars exceed the Marcenko-Pastur noise bound; red bars are statistically indistinguishable from noise). Right: optimal portfolio weights before and after RMT denoising — the denoised matrix produces a more stable, better-conditioned solution.

The Bloomberg universe is unusually well-determined — eight assets and over 2,000 daily observations yield a Q-ratio of approximately 250, far above the threshold where noise dominates. Under these conditions most eigenvalues exceed the Marcenko-Pastur bound, confirming that the ETF correlations are statistically genuine. Yet even here denoising improves the matrix’s condition number — the ratio of the largest to smallest eigenvalue, a measure of numerical brittleness — making subsequent optimisation more stable and less sensitive to small perturbations in inputs.

The real power of RMT denoising becomes apparent at institutional scale. For a fund tracking the S&P 500 with 500 constituents and five years of daily data, \(Q = 2.5\) and the majority of eigenvalues fall inside the noise zone. In this regime, the sample covariance matrix is dominated by estimation artefacts, and portfolio weights derived from it will be erratic and poorly generalising. Denoising restores stability by concentrating the covariance structure in a handful of genuine risk factors — the market, sectors, and macro-economic exposures that genuinely drive co-movement.

The eigenvectors themselves repay close examination. The dominant eigenvector (largest eigenvalue, far above \(\lambda_+\)) is the market factor: all equity ETFs load positively, capturing the systematic co-movement that explains why SPY, QQQ, IWM, and EFA fell together in March 2020. Smaller signal eigenvalues capture more specific risk factors — the bond-equity relationship, the gold safe-haven dynamic, the growth-value split. The noise eigenvectors, by contrast, have no interpretable structure: they are artefacts of the random variation in pairwise correlation estimates (Ledoit and Wolf 2004).

TipThe Eigenvalue Insight: From Financial Covariances to Neural Network Weights

The same mathematical operation that denoises a financial covariance matrix sits at the heart of several landmark developments in artificial intelligence. Word embeddings (GloVe, Word2Vec) are produced by factorising a word co-occurrence matrix via SVD — the rectangular generalisation of eigendecomposition — and retaining only the top-\(k\) singular values, discarding the noise subspace. LoRA fine-tuning of large language models constrains weight updates to \(\Delta W = AB\) with rank \(r \ll d\), explicitly operating in the signal subspace of the weight matrix (Vaswani et al. 2017). Each attention head in a Transformer can be interpreted as learning a distinct dominant eigenvector of the token-interaction matrix \(QK^\top\), capturing a different aspect of semantic relationships.

The unifying principle across finance and AI is the same: high-dimensional data is overwhelmingly noise. The challenge in both domains is to identify the low-dimensional signal subspace that genuinely drives structure — whether that structure is asset co-movement or the grammar of language. We return to this connection explicitly in the sequential learning week, where transformers become our primary tool.

NoteSummary: Managing Estimation Error in Portfolio Optimisation

The problem: Naive MPT optimisation treats estimated parameters as truth, producing unstable portfolios that fail out-of-sample.

Solutions implemented: Bootstrap confidence intervals quantify uncertainty in optimal weights (wide CIs indicate instability); rolling-window backtesting provides honest performance evaluation (often showing that optimisation underperforms equal-weight); Bayesian shrinkage pulls estimates toward sensible priors (reducing variance via the bias-variance tradeoff); and RMT denoising identifies the genuine signal directions in the covariance matrix by applying the Marcenko-Pastur Law, removing noise eigenvalues that distort portfolio weights.

What robo-advisers actually do: They use simple portfolios (5–10 broad ETFs), apply strong constraints (maximum 30% per asset with minimum diversification requirements), rebalance infrequently (quarterly or annually) to reduce trading costs, and use heuristics over optimisation (such as age-based equity/bond mix).

Key insight: Simple, constrained approaches often beat complex optimisation because estimation error dominates. This is the curse of dimensionality combined with low signal-to-noise in financial returns.

5.1 Machine Learning Enhancement of Traditional Portfolio Theory

Whilst traditional robo-advisers rely primarily on Modern Portfolio Theory, the research by Gu, Kelly, and Xiu (2020) demonstrates how machine learning techniques can enhance portfolio construction by capturing patterns that traditional methods miss. Their work on “Empirical Asset Pricing via Machine Learning” shows that sophisticated algorithms can improve both return prediction and risk assessment.

The integration of machine learning into robo-advisory services represents the practical application of the complexity insights from Kelly, Malamud, and Zhou (2024). Rather than relying solely on simple linear models, modern robo-advisers can use complex algorithms to process vast amounts of market data and identify subtle patterns that inform portfolio decisions.

In practice, however, most commercial robo-advisers today still rely on simple ETF portfolios and Modern Portfolio Theory implementations. Machine learning remains largely at the research and prototype stage, with adoption tempered by regulatory requirements for explainability and client suitability.

However, this complexity must be managed carefully. As we learned from our Data Science Primer, the virtue of complexity depends on proper regularization, validation, and understanding of the bias-variance tradeoff. Robo-advisers that use machine learning must balance the benefits of sophisticated models with the need for reliable, interpretable advice.

TipConnection to Statistical Foundations (Week 1, §0.2)

The ML code below uses TimeSeriesSplit for cross-validation: this is correct for temporal data (cannot randomly shuffle). It also constrains tree depth (max_depth=5) and leaf size (min_samples_leaf=50): these are regularization via model complexity constraints.

Bias-variance in action: - Deeper trees (max_depth=20): Low bias, high variance (overfit) - Shallow trees (max_depth=5): Higher bias, lower variance (regularized) - Cross-validation helps choose optimal depth

This is exactly what we emphasized in Weeks 0-2: validation and regularization are essential, not optional extras.

Show Python code
# Demonstrating machine learning enhancement of portfolio optimisation
import pandas as pd
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import mean_squared_error
import matplotlib.pyplot as plt

class MLEnhancedRoboAdvisor:
    """
    Robo-advisor with machine learning enhancements
    Following Gu, Kelly & Xiu (2020) approach
    """
    
    def __init__(self, symbols):
        self.symbols = symbols
        self.price_data = None
        self.features = None
        self.ml_models = {}
        
    def prepare_ml_features(self, price_data):
        """
        Create machine learning features for return prediction
        Following Gu et al. approach
        """
        print("Preparing ML features for enhanced portfolio optimisation...")
        
        features_df = pd.DataFrame(index=price_data.index)
        
        for symbol in self.symbols:
            if symbol in price_data.columns:
                prices = price_data[symbol]
                
                # Technical indicators
                features_df[f'{symbol}_returns_1m'] = prices.pct_change()
                features_df[f'{symbol}_returns_3m'] = prices.pct_change(periods=63)  # ~3 months
                features_df[f'{symbol}_volatility'] = features_df[f'{symbol}_returns_1m'].rolling(20).std()
                
                # Moving averages
                features_df[f'{symbol}_ma_ratio'] = prices / prices.rolling(50).mean()
                
                # Volume indicators (if available)
                # Note: Simplified for demonstration
                
        # Remove missing values
        features_df = features_df.dropna()
        
        print(f"Created {features_df.shape[1]} features over {len(features_df)} periods")
        return features_df
    
    def train_return_predictors(self, features_df, target_horizon=21):
        """
        Train ML models to predict future returns
        """
        print(f"Training ML return predictors (horizon: {target_horizon} days)...")
        
        for symbol in self.symbols:
            print(f"  Training model for {symbol}...")
            
            # Create target variable (future returns)
            if f'{symbol}_returns_1m' in features_df.columns:
                symbol_returns = features_df[f'{symbol}_returns_1m']
                future_returns = symbol_returns.shift(-target_horizon)
                
                # Prepare features (exclude target symbol's current return)
                feature_cols = [col for col in features_df.columns 
                              if not col.startswith(f'{symbol}_returns_1m')]
                
                if len(feature_cols) < 3:
                    print(f"    Insufficient features for {symbol}")
                    continue
                
                # Align data
                aligned_data = pd.DataFrame({
                    'target': future_returns,
                    **{col: features_df[col] for col in feature_cols}
                }).dropna()
                
                if len(aligned_data) < 100:
                    print(f"    Insufficient data for {symbol}")
                    continue
                
                # Prepare for time series cross-validation
                X = aligned_data[feature_cols]
                y = aligned_data['target']
                
                # Time series cross-validation
                tscv = TimeSeriesSplit(n_splits=5)
                mse_scores = []
                
                for train_idx, test_idx in tscv.split(X):
                    X_train, X_test = X.iloc[train_idx], X.iloc[test_idx]
                    y_train, y_test = y.iloc[train_idx], y.iloc[test_idx]
                    
                    # Train Random Forest (following Gu et al.)
                    rf_model = RandomForestRegressor(
                        n_estimators=100,
                        max_depth=5,
                        random_state=42
                    )
                    
                    rf_model.fit(X_train, y_train)
                    y_pred = rf_model.predict(X_test)
                    mse = mean_squared_error(y_test, y_pred)
                    mse_scores.append(mse)
                
                avg_mse = np.mean(mse_scores)
                
                # Store the final model trained on all data
                final_model = RandomForestRegressor(
                    n_estimators=100,
                    max_depth=5,
                    random_state=42
                )
                final_model.fit(X, y)
                
                self.ml_models[symbol] = {
                    'model': final_model,
                    'features': feature_cols,
                    'mse': avg_mse,
                    'r2_score': 1 - avg_mse / np.var(y)
                }
                
                print(f"    Model trained - R²: {self.ml_models[symbol]['r2_score']:.4f}")
        
        print(f"Trained {len(self.ml_models)} return prediction models")
    
    def generate_ml_enhanced_allocations(self, client_risk_profile):
        """
        Generate portfolio allocations using ML-enhanced return predictions
        """
        if not self.ml_models:
            print("No ML models available. Train models first.")
            return None
        
        print(f"Generating ML-enhanced portfolio for risk profile: {client_risk_profile}")
        
        # Get latest features for prediction
        if self.features is None:
            print("No features available")
            return None
        
        latest_features = self.features.iloc[-1]
        
        # Predict returns using ML models
        ml_predictions = {}
        for symbol, model_info in self.ml_models.items():
            try:
                model = model_info['model']
                feature_cols = model_info['features']
                
                # Prepare features for prediction
                feature_values = latest_features[feature_cols].values.reshape(1, -1)
                
                # Predict return
                predicted_return = model.predict(feature_values)[0]
                ml_predictions[symbol] = predicted_return
                
            except Exception as e:
                print(f"    Prediction failed for {symbol}: {e}")
                continue
        
        if ml_predictions:
            print(f"   ML Return Predictions:")
            for symbol, pred_return in ml_predictions.items():
                print(f"     {symbol}: {pred_return*100:.2f}% (next {21} days)")
            
            # Simple allocation based on predicted returns and risk profile
            # Higher risk tolerance → more weight on higher expected return assets
            risk_multiplier = {'conservative': 0.5, 'moderate': 1.0, 'aggressive': 1.5}
            multiplier = risk_multiplier.get(client_risk_profile, 1.0)
            
            # Convert predictions to weights (simplified approach)
            pred_array = np.array(list(ml_predictions.values()))
            
            # Ensure positive weights and scale by risk tolerance
            adjusted_predictions = np.maximum(pred_array, 0) * multiplier
            
            if np.sum(adjusted_predictions) > 0:
                raw_weights = adjusted_predictions / np.sum(adjusted_predictions)
                
                # Apply constraints (max 40% in any asset, min 5% in bonds)
                constrained_weights = np.minimum(raw_weights, 0.4)
                constrained_weights = constrained_weights / np.sum(constrained_weights)
                
                allocation = dict(zip(ml_predictions.keys(), constrained_weights))
                
                print(f"\n   ML-Enhanced Allocation ({client_risk_profile}):")
                for symbol, weight in allocation.items():
                    print(f"     {symbol}: {weight*100:.1f}%")
                
                return allocation
        
        return None

# Demonstrate ML-enhanced robo-advisor
def demonstrate_ml_robo_advisor():
    """
    Show how machine learning enhances traditional robo-advisory services
    """
    print("ML-Enhanced Robo-Advisory Demonstration")
    print("=" * 45)
    
    # Portfolio of liquid ETFs
    symbols = ['SPY', 'IWM', 'EFA', 'BND', 'VNQ']
    
    # Create ML-enhanced robo-advisor
    ml_robo = MLEnhancedRoboAdvisor(symbols)
    
    try:
        # Load cached market data (production approach)
        data_dir = data_root / "chapter04"
        price_data = {}
        for symbol in symbols:
            filepath = data_dir / f"{symbol.lower()}_data.csv"
            df = pd.read_csv(filepath, index_col=0, parse_dates=True)
            price_data[symbol] = df['Close']
        price_data = pd.DataFrame(price_data)
        
        if not price_data.empty:
            # Prepare features
            ml_robo.features = ml_robo.prepare_ml_features(price_data)
            
            # Train models
            ml_robo.train_return_predictors(ml_robo.features)
            
            # Generate allocations for different risk profiles
            risk_profiles = ['conservative', 'moderate', 'aggressive']
            
            allocations = {}
            for profile in risk_profiles:
                allocation = ml_robo.generate_ml_enhanced_allocations(profile)
                if allocation:
                    allocations[profile] = allocation
            
            # Compare allocations
            if allocations:
                print("\nML-enhanced allocation comparison:")
                
                allocation_df = pd.DataFrame(allocations).fillna(0)
                
                # Visualization
                plt.figure(figsize=(12, 6))
                
                # Stacked bar chart of allocations
                plt.subplot(1, 2, 1)
                allocation_df.plot(kind='bar', stacked=True, ax=plt.gca())
                plt.title('ML-Enhanced Allocations by Risk Profile')
                plt.xlabel('Assets')
                plt.ylabel('Allocation (%)')
                plt.legend(title='Risk Profile', bbox_to_anchor=(1.05, 1), loc='upper left')
                plt.xticks(rotation=45)
                
                # Allocation differences
                plt.subplot(1, 2, 2)
                if 'conservative' in allocations and 'aggressive' in allocations:
                    conservative = pd.Series(allocations['conservative'])
                    aggressive = pd.Series(allocations['aggressive'])
                    
                    # Align series
                    all_symbols = set(conservative.index) | set(aggressive.index)
                    conservative = conservative.reindex(all_symbols, fill_value=0)
                    aggressive = aggressive.reindex(all_symbols, fill_value=0)
                    
                    difference = aggressive - conservative
                    
                    colors = ['red' if x < 0 else 'green' for x in difference.values]
                    plt.bar(range(len(difference)), difference.values * 100, color=colors, alpha=0.7)
                    plt.title('Aggressive vs Conservative\nAllocation Differences')
                    plt.xlabel('Assets')
                    plt.ylabel('Allocation Difference (%)')
                    plt.xticks(range(len(difference)), difference.index, rotation=45)
                    plt.grid(True, alpha=0.3)
                
                plt.tight_layout()
                plt.show()
        
    except Exception as e:
        print(f"ML demonstration error: {e}")

# Run the ML demonstration
demonstrate_ml_robo_advisor()
ML-Enhanced Robo-Advisory Demonstration
=============================================
Preparing ML features for enhanced portfolio optimisation...
Created 20 features over 654 periods
Training ML return predictors (horizon: 21 days)...
  Training model for SPY...
    Model trained - R²: -0.1248
  Training model for IWM...
    Model trained - R²: -0.0739
  Training model for EFA...
    Model trained - R²: -0.0787
  Training model for BND...
    Model trained - R²: -0.0700
  Training model for VNQ...
    Model trained - R²: -0.1696
Trained 5 return prediction models
Generating ML-enhanced portfolio for risk profile: conservative
   ML Return Predictions:
     SPY: 0.36% (next 21 days)
     IWM: -0.06% (next 21 days)
     EFA: 0.10% (next 21 days)
     BND: 0.00% (next 21 days)
     VNQ: -0.09% (next 21 days)

   ML-Enhanced Allocation (conservative):
     SPY: 64.9%
     IWM: 0.0%
     EFA: 33.8%
     BND: 1.3%
     VNQ: 0.0%
Generating ML-enhanced portfolio for risk profile: moderate
   ML Return Predictions:
     SPY: 0.36% (next 21 days)
     IWM: -0.06% (next 21 days)
     EFA: 0.10% (next 21 days)
     BND: 0.00% (next 21 days)
     VNQ: -0.09% (next 21 days)

   ML-Enhanced Allocation (moderate):
     SPY: 64.9%
     IWM: 0.0%
     EFA: 33.8%
     BND: 1.3%
     VNQ: 0.0%
Generating ML-enhanced portfolio for risk profile: aggressive
   ML Return Predictions:
     SPY: 0.36% (next 21 days)
     IWM: -0.06% (next 21 days)
     EFA: 0.10% (next 21 days)
     BND: 0.00% (next 21 days)
     VNQ: -0.09% (next 21 days)

   ML-Enhanced Allocation (aggressive):
     SPY: 64.9%
     IWM: 0.0%
     EFA: 33.8%
     BND: 1.3%
     VNQ: 0.0%

ML-enhanced allocation comparison:

This demonstration shows how machine learning can enhance traditional portfolio optimisation, providing the technological foundation for next-generation robo-advisory services that go beyond simple Modern Portfolio Theory.

6 Part II: The democratisation of Investment Management

6.1 Evidence on Access Expansion

The research by Reher and Sokolinski (2024) provides rigorous empirical evidence for the democratisation effects of robo-advisory services. Using a quasi-experimental design around changes in account minimums, they demonstrate that lowering barriers to entry significantly increases participation among middle-class investors.

Their findings are particularly important because they address the question of whether robo-advisers create genuine value or simply capture value from regulatory arbitrage. The evidence suggests that robo-advisers generate “moderate welfare gains” through improved access, particularly for investors in the 25-55 age range who have sufficient wealth to benefit from professional advice but insufficient wealth to access traditional services.

This evidence complements the broader theme of FinTech innovation we explored in Week 1. Just as Fuster et al. (2019) showed genuine efficiency gains in mortgage processing, and Berg et al. (2020) demonstrated superior data processing in lending, the robo-advisor research shows that technological innovation can create real value through improved access and lower costs.

6.1.1 The behavioural Dimension

Beyond cost reduction and access expansion, robo-advisers address behavioural biases that often undermine individual investment decisions. Traditional finance recognises that investors frequently make suboptimal decisions due to emotional reactions, overconfidence, and poor timing.

Robo-advisers help address these issues through systematic, emotion-free implementation of investment strategies. They automatically rebalance portfolios, harvest tax losses, and maintain target allocations without the emotional interference that often leads individual investors to buy high and sell low.

6.2 Behavioral Benefits of Robo-Advisory Automation

Individual investors face a well-documented challenge: they consistently underperform market indices. Barber and Odean (2000) find that individual investors underperform the market by approximately 7% annually, with the worst performers: those trading most frequently: underperforming by over 11% annually. This gap reflects not market timing skill or security selection ability, but rather the costs of trading, taxes paid on gains, and most significantly, the behavioral biases that drive investors to buy after prices rise and sell after they fall.

These behavioral patterns persist even in contemporary markets. Barber et al. (2022) document that during the COVID-19 pandemic, Robinhood traders’ attention-driven trading: buying stocks receiving news coverage or showing momentum: reduced their returns by approximately 2-3% annually compared to buy-and-hold strategies. Remarkably, this underperformance occurred during a period of rising equity prices, when even poor decisions generated positive returns. The implication is stark: when markets decline, behavioral traders face losses on top of the opportunity cost of their worse decisions.

Robo-advisers help address these issues through systematic, emotion-free implementation of investment strategies. They automatically rebalance portfolios, harvest tax losses, and maintain target allocations without the emotional interference that leads individual investors to abandon disciplined strategies during market stress. Where behavioral research identifies the problem (systematic bias in individual trading decisions), robo-adviser automation provides the solution (removing discretion from those decisions).

The mechanism works through several channels. First, automatic rebalancing maintains target allocations, preventing the recency bias that leads investors to increase risky asset exposure after strong returns (buying high) or reduce it after weak returns (selling low). Second, tax-loss harvesting systematically captures tax benefits, a mechanical process that emotional investors often neglect. Third, behavioral guardrails like contribution rules prevent investors from making large reactive changes during market downturns: exactly when fear-driven selling causes the most damage. Fourth, dynamic adjustments based on portfolio drift rather than emotional impulses ensure investors stay aligned with long-term objectives despite short-term volatility.

The welfare gains from eliminating behavioral trading costs are economically significant. If the average individual investor underperforms by 7% annually through behavioral mistakes, and robo-advisers can eliminate perhaps half of that gap through removing emotional decision-making, the value creation amounts to 3-4% annually on invested assets. For a $100,000 portfolio, this represents $3,000-4,000 annually: roughly equivalent to the advisory fees charged by robo-advisers themselves. This creates the central value proposition of robo-advisory services: they charge fees that roughly equal the behavioral costs they eliminate.

Show Python code
# Analyzing behavioural benefits of robo-advisory automation
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt

def analyse_behavioural_benefits():
    """
    Demonstrate how robo-advisers address behavioural investment biases
    """
    print("behavioural Benefits of Robo-Advisory Automation")
    print("=" * 50)
    
    # Simulate investor behaviour vs. robo-advisor behaviour
    np.random.seed(42)
    
    # Market scenario: 5 years of monthly returns
    n_months = 60
    market_returns = np.random.normal(0.008, 0.04, n_months)  # ~10% annual, 20% volatility
    
    # Add some market crashes and recoveries
    market_returns[24] = -0.15  # Major crash in month 24
    market_returns[25] = -0.08  # Continued decline
    market_returns[26:29] = np.random.normal(0.02, 0.03, 3)  # Recovery
    
    cumulative_market = np.cumprod(1 + market_returns)
    
    print(f"Market simulation: {n_months} months with crash and recovery")
    
    # Robo-advisor behaviour: Systematic rebalancing
    robo_portfolio_value = [100000]  # Starting value
    robo_monthly_investment = 1000   # Regular contributions
    
    for month in range(n_months):
        # Add monthly contribution
        current_value = robo_portfolio_value[-1] + robo_monthly_investment
        
        # Apply market return
        new_value = current_value * (1 + market_returns[month])
        robo_portfolio_value.append(new_value)
    
    # Human investor behaviour: Emotional reactions
    human_portfolio_value = [100000]
    human_monthly_investment = 1000
    
    for month in range(n_months):
        # Emotional adjustments to contributions
        market_sentiment = market_returns[month]
        
        # Reduce contributions during bad times, increase during good times
        if market_sentiment < -0.05:  # Bad month
            emotional_adjustment = 0.5  # Reduce investment by 50%
        elif market_sentiment > 0.05:  # Good month
            emotional_adjustment = 1.5  # Increase investment by 50%
        else:
            emotional_adjustment = 1.0
        
        adjusted_investment = human_monthly_investment * emotional_adjustment
        
        # Add adjusted contribution
        current_value = human_portfolio_value[-1] + adjusted_investment
        
        # Apply market return
        new_value = current_value * (1 + market_returns[month])
        human_portfolio_value.append(new_value)
    
    # Convert to arrays for analysis
    robo_values = np.array(robo_portfolio_value)
    human_values = np.array(human_portfolio_value)
    
    # Calculate final outcomes
    robo_final = robo_values[-1]
    human_final = human_values[-1]
    
    robo_total_invested = 100000 + (robo_monthly_investment * n_months)
    human_total_invested = 100000 + np.sum([
        human_monthly_investment * (0.5 if market_returns[i] < -0.05 
                                   else 1.5 if market_returns[i] > 0.05 
                                   else 1.0) 
        for i in range(n_months)
    ])
    
    robo_return = (robo_final - robo_total_invested) / robo_total_invested
    human_return = (human_final - human_total_invested) / human_total_invested
    
    print(f"\nbehavioural Analysis Results:")
    print(f"  Robo-advisor:")
    print(f"    Total invested: ${robo_total_invested:,.0f}")
    print(f"    Final value: ${robo_final:,.0f}")
    print(f"    Return on investment: {robo_return*100:.1f}%")
    
    print(f"  Human investor (emotional):")
    print(f"    Total invested: ${human_total_invested:,.0f}")
    print(f"    Final value: ${human_final:,.0f}")
    print(f"    Return on investment: {human_return*100:.1f}%")
    
    behavioural_advantage = robo_return - human_return
    print(f"  behavioural advantage: {behavioural_advantage*100:.1f} percentage points")
    
    # Visualization
    plt.figure(figsize=(15, 10))
    
    # Portfolio values over time
    plt.subplot(2, 3, 1)
    months = range(n_months + 1)
    plt.plot(months, robo_values, 'b-', linewidth=2, label='Robo-Advisor')
    plt.plot(months, human_values, 'r--', linewidth=2, label='Human Investor')
    plt.xlabel('Months')
    plt.ylabel('Portfolio Value ($)')
    plt.title('Portfolio Value Over Time')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Market returns
    plt.subplot(2, 3, 2)
    plt.plot(range(1, n_months + 1), market_returns * 100, 'k-', alpha=0.7)
    plt.axhline(0, color='red', linestyle='--', alpha=0.5)
    plt.xlabel('Months')
    plt.ylabel('Monthly Return (%)')
    plt.title('Market Returns (with crash)')
    plt.grid(True, alpha=0.3)
    
    # Investment contributions comparison
    plt.subplot(2, 3, 3)
    robo_contributions = [robo_monthly_investment] * n_months
    human_contributions = [
        human_monthly_investment * (0.5 if market_returns[i] < -0.05 
                                   else 1.5 if market_returns[i] > 0.05 
                                   else 1.0) 
        for i in range(n_months)
    ]
    
    plt.plot(range(1, n_months + 1), robo_contributions, 'b-', label='Robo (Systematic)')
    plt.plot(range(1, n_months + 1), human_contributions, 'r--', label='Human (Emotional)')
    plt.xlabel('Months')
    plt.ylabel('Monthly Investment ($)')
    plt.title('Investment Contributions')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Cumulative contributions
    plt.subplot(2, 3, 4)
    cumulative_robo = np.cumsum([100000] + robo_contributions)
    cumulative_human = np.cumsum([100000] + human_contributions)
    
    plt.plot(months, cumulative_robo, 'b-', linewidth=2, label='Robo (Systematic)')
    plt.plot(months, cumulative_human, 'r--', linewidth=2, label='Human (Emotional)')
    plt.xlabel('Months')
    plt.ylabel('Cumulative Investment ($)')
    plt.title('Cumulative Contributions')
    plt.legend()
    plt.grid(True, alpha=0.3)
    
    # Final comparison
    plt.subplot(2, 3, 5)
    categories = ['Robo-Advisor', 'Human Investor']
    final_values = [robo_final, human_final]
    colors = ['blue', 'red']
    
    bars = plt.bar(categories, final_values, color=colors, alpha=0.7)
    plt.ylabel('Final Portfolio Value ($)')
    plt.title('Final Outcomes Comparison')
    
    for bar, value in zip(bars, final_values):
        plt.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5000, 
                f'${value:,.0f}', ha='center', va='bottom')
    
    plt.grid(True, alpha=0.3)
    
    # Key insights
    plt.subplot(2, 3, 6)
    plt.text(0.05, 0.9, 'behavioural Insights:', fontweight='bold', fontsize=12)
    plt.text(0.05, 0.8, '• Systematic beats emotional', fontsize=10)
    plt.text(0.05, 0.75, '• Automation prevents bad timing', fontsize=10)
    plt.text(0.05, 0.7, '• Consistent strategy wins', fontsize=10)
    plt.text(0.05, 0.65, '• Technology removes bias', fontsize=10)
    
    plt.text(0.05, 0.5, 'Reher & Sokolinski Evidence:', fontweight='bold', fontsize=12)
    plt.text(0.05, 0.4, '• Moderate welfare gains', fontsize=10)
    plt.text(0.05, 0.35, '• Especially middle-age investors', fontsize=10)
    plt.text(0.05, 0.3, '• Access expansion benefits', fontsize=10)
    
    plt.xlim(0, 1)
    plt.ylim(0, 1)
    plt.axis('off')
    
    plt.tight_layout()
    plt.show()
    
    return {
        'robo_final': robo_final,
        'human_final': human_final,
        'behavioural_advantage': behavioural_advantage
    }

# Run the behavioural analysis
behavioural_results = analyse_behavioural_benefits()
behavioural Benefits of Robo-Advisory Automation
==================================================
Market simulation: 60 months with crash and recovery

behavioural Analysis Results:
  Robo-advisor:
    Total invested: $160,000
    Final value: $144,792
    Return on investment: -9.5%
  Human investor (emotional):
    Total invested: $159,000
    Final value: $143,928
    Return on investment: -9.5%
  behavioural advantage: -0.0 percentage points

This analysis demonstrates how robo-advisers create value not just through lower costs, but through systematic implementation that helps investors avoid behavioural mistakes.

7 Part III: Machine Learning in Portfolio Construction

7.1 The Gu, Kelly & Xiu Framework

The foundational work by Gu, Kelly, and Xiu (2020) on “Empirical Asset Pricing via Machine Learning” provides the theoretical and empirical foundation for understanding how machine learning can enhance portfolio construction. Their research demonstrates that machine learning methods can significantly improve out-of-sample portfolio performance compared to traditional approaches.

The Gu et al. framework addresses several key challenges in applying machine learning to finance. First, financial datasets often have more potential predictors than observations (high-dimensional data). Second, financial data has temporal dependencies that standard ML methods may not handle appropriately (time series structure). Third, ML models need to be interpretable in economic terms (economic interpretation). Fourth, financial models must work on future data, not just historical data (out-of-sample validation).

Recent advances in portfolio optimisation extend these insights further. Liu et al. (2024) demonstrate how evolutionary multi-objective optimisation can handle large-scale portfolio selection problems with both random and uncertain returns. This research, conducted at Ulster University, shows how advanced computational methods can address the complexity challenges that traditional optimisation approaches struggle with.

The Liu et al. approach is particularly relevant for modern robo-advisers because it addresses the multi-objective nature of real investment decisions: investors care about return, risk, diversification, and other factors simultaneously. Traditional single-objective optimisation may miss important trade-offs that multi-objective approaches can capture.

Whilst promising, these approaches remain frontier research. Production robo-advisers continue to prioritise transparency, robustness, and regulatory compliance over cutting-edge optimisation. This gap highlights the challenge of translating sophisticated academic methods into live advisory products.

7.1.1 Feature Engineering for Financial ML

One of the key insights from Gu, Kelly, and Xiu (2020) is that successful machine learning in finance depends critically on appropriate feature engineering. Rather than simply feeding raw price data into algorithms, effective approaches create features that capture economic intuition about what drives asset returns.

This connects directly to the causal reasoning framework we established in our Data Science Primer. Effective features in financial ML often reflect causal relationships: variables that we believe actually influence asset returns rather than variables that are merely correlated with returns.

Show Python code
# Implementing Gu, Kelly & Xiu feature engineering approach
import pandas as pd
import numpy as np
from sklearn.preprocessing import StandardScaler
from sklearn.decomposition import PCA
import matplotlib.pyplot as plt

class FinancialFeatureEngine:
    """
    Feature engineering for financial ML following Gu et al. approach
    """
    
    def __init__(self, symbols):
        self.symbols = symbols
        self.raw_data = None
        self.features = None
        self.feature_names = []
        
    def fetch_and_prepare_data(self, period="3y"):
        """
        Fetch market data and prepare for feature engineering
        """
        print(f"Fetching data for {len(self.symbols)} symbols...")
        
        try:
            # Load comprehensive cached data (production approach)
            data_dir = data_root / "chapter04"
            all_data = {}
            for symbol in self.symbols:
                filepath = data_dir / f"{symbol.lower()}_data.csv"
                df = pd.read_csv(filepath, index_col=0, parse_dates=True)
                all_data[symbol] = df
            
            # Reconstruct multi-level DataFrame structure similar to yfinance
            data = pd.concat(all_data, axis=1)
            
            if data.empty:
                raise ValueError("No data loaded")
            
            self.raw_data = data
            print(f"Loaded {len(data)} trading days")
            
            return True
            
        except Exception as e:
            print(f"Data fetch error: {e}")
            return False
    
    def create_technical_features(self):
        """
        Create technical analysis features following Gu et al.
        """
        if self.raw_data is None:
            print("No raw data available")
            return None
        
        print("Creating technical features...")
        
        features_list = []
        
        for symbol in self.symbols:
            try:
                # Extract OHLCV data for this symbol
                if isinstance(self.raw_data.columns, pd.MultiIndex):
                    symbol_data = self.raw_data.xs(symbol, axis=1, level=1)
                else:
                    symbol_data = self.raw_data
                
                symbol_features = pd.DataFrame(index=symbol_data.index)
                
                # Price-based features
                close = symbol_data['Close']
                high = symbol_data['High']
                low = symbol_data['Low']
                volume = symbol_data['Volume']
                
                # Returns (multiple horizons)
                symbol_features[f'{symbol}_ret_1m'] = close.pct_change()
                symbol_features[f'{symbol}_ret_3m'] = close.pct_change(periods=63)
                symbol_features[f'{symbol}_ret_6m'] = close.pct_change(periods=126)
                
                # Volatility measures
                symbol_features[f'{symbol}_vol_20d'] = symbol_features[f'{symbol}_ret_1m'].rolling(20).std()
                symbol_features[f'{symbol}_vol_60d'] = symbol_features[f'{symbol}_ret_1m'].rolling(60).std()
                
                # Technical indicators
                # Moving average ratios
                symbol_features[f'{symbol}_ma_ratio_20'] = close / close.rolling(20).mean()
                symbol_features[f'{symbol}_ma_ratio_50'] = close / close.rolling(50).mean()
                
                # RSI-like momentum
                returns = symbol_features[f'{symbol}_ret_1m']
                gains = returns.where(returns > 0, 0)
                losses = -returns.where(returns < 0, 0)
                avg_gains = gains.rolling(14).mean()
                avg_losses = losses.rolling(14).mean()
                rs = avg_gains / avg_losses
                symbol_features[f'{symbol}_rsi'] = 100 - (100 / (1 + rs))
                
                # Volume-based features
                symbol_features[f'{symbol}_volume_ratio'] = volume / volume.rolling(20).mean()
                
                # High-low spread
                symbol_features[f'{symbol}_hl_spread'] = (high - low) / close
                
                features_list.append(symbol_features)
                
            except Exception as e:
                print(f"  Error creating features for {symbol}: {e}")
                continue
        
        if features_list:
            # Combine all features
            self.features = pd.concat(features_list, axis=1)
            self.feature_names = list(self.features.columns)
            
            # Remove missing values
            initial_length = len(self.features)
            self.features = self.features.dropna()
            dropped_rows = initial_length - len(self.features)
            
            print(f"Created {len(self.feature_names)} features")
            print(f"   Observations: {len(self.features)}")
            if dropped_rows > 0:
                print(f"   Dropped {dropped_rows} rows with missing data")
            
            return self.features
        
        return None
    
    def analyse_feature_importance(self, target_symbol):
        """
        Analyze which features are most important for predicting returns
        """
        if self.features is None:
            print("No features available")
            return None
        
        print(f"Analyzing feature importance for {target_symbol}...")
        
        # Create target variable (future returns)
        target_col = f'{target_symbol}_ret_1m'
        if target_col not in self.features.columns:
            print(f"Target column {target_col} not found")
            return None
        
        # Shift target forward (predict next month's return)
        target = self.features[target_col].shift(-21)  # ~1 month ahead
        
        # Use all features except the target
        feature_cols = [col for col in self.feature_names if col != target_col]
        
        # Align data
        aligned_data = pd.DataFrame({
            'target': target,
            **{col: self.features[col] for col in feature_cols}
        }).dropna()
        
        if len(aligned_data) < 100:
            print(f"Insufficient aligned data: {len(aligned_data)} rows")
            return None
        
        X = aligned_data[feature_cols]
        y = aligned_data['target']
        
        # Train Random Forest for feature importance
        from sklearn.ensemble import RandomForestRegressor
        
        rf_model = RandomForestRegressor(
            n_estimators=100,
            max_depth=10,
            random_state=42
        )
        
        rf_model.fit(X, y)
        
        # Get feature importance
        importance_scores = rf_model.feature_importances_
        feature_importance = pd.DataFrame({
            'feature': feature_cols,
            'importance': importance_scores
        }).sort_values('importance', ascending=False)
        
        print("Feature importance analysis complete")
        print(f"   Top 10 most important features:")
        
        for i, (_, row) in enumerate(feature_importance.head(10).iterrows()):
            print(f"     {i+1:2d}. {row['feature']:<30} {row['importance']:.4f}")
        
        # Visualization
        plt.figure(figsize=(12, 8))
        
        # Top 15 features
        top_features = feature_importance.head(15)
        
        plt.subplot(2, 2, 1)
        plt.barh(range(len(top_features)), top_features['importance'])
        plt.yticks(range(len(top_features)), top_features['feature'])
        plt.xlabel('Feature Importance')
        plt.title(f'Top 15 Features for {target_symbol}')
        plt.grid(True, alpha=0.3)
        
        # Feature importance distribution
        plt.subplot(2, 2, 2)
        plt.hist(importance_scores, bins=20, alpha=0.7)
        plt.xlabel('Importance Score')
        plt.ylabel('Number of Features')
        plt.title('Feature Importance Distribution')
        plt.grid(True, alpha=0.3)
        
        # Cumulative importance
        plt.subplot(2, 2, 3)
        cumulative_importance = np.cumsum(feature_importance['importance'].values)
        plt.plot(range(1, len(cumulative_importance) + 1), cumulative_importance)
        plt.axhline(0.8, color='red', linestyle='--', label='80% Threshold')
        plt.xlabel('Number of Features')
        plt.ylabel('Cumulative Importance')
        plt.title('Cumulative Feature Importance')
        plt.legend()
        plt.grid(True, alpha=0.3)
        
        # Model performance
        plt.subplot(2, 2, 4)
        y_pred = rf_model.predict(X)
        plt.scatter(y * 100, y_pred * 100, alpha=0.6)
        plt.plot([y.min() * 100, y.max() * 100], [y.min() * 100, y.max() * 100], 'r--')
        plt.xlabel('Actual Returns (%)')
        plt.ylabel('Predicted Returns (%)')
        plt.title(f'Prediction Accuracy\nR² = {rf_model.score(X, y):.3f}')
        plt.grid(True, alpha=0.3)
        
        plt.tight_layout()
        plt.show()
        
        return feature_importance

# Demonstrate feature engineering and analysis
def demonstrate_financial_feature_engineering():
    """
    Demonstrate Gu et al. approach to financial feature engineering
    """
    print("Financial Feature Engineering (Gu, Kelly & Xiu Framework)")
    print("=" * 60)
    
    # Portfolio of ETFs for analysis
    etf_symbols = ['SPY', 'IWM', 'EFA', 'BND', 'VNQ']
    
    # Create feature engine
    feature_engine = FinancialFeatureEngine(etf_symbols)
    
    # Fetch data and create features
    if feature_engine.fetch_and_prepare_data(period="2y"):
        features = feature_engine.create_technical_features()
        
        if features is not None:
            # Analyze feature importance for SPY prediction
            importance_analysis = feature_engine.analyse_feature_importance('SPY')
            
            if importance_analysis is not None:
                print("\nGu et al. insights applied:")
                print(f"  - Feature engineering captures economic intuition")
                print(f"  - ML identifies subtle patterns in financial data")
                print(f"  - Cross-asset features improve prediction")
                print(f"  - Systematic approach beats ad-hoc methods")
                
                return importance_analysis

# Run the demonstration
feature_analysis = demonstrate_financial_feature_engineering()
Financial Feature Engineering (Gu, Kelly & Xiu Framework)
============================================================
Fetching data for 5 symbols...
Loaded 717 trading days
Creating technical features...
  Error creating features for SPY: 'SPY'
  Error creating features for IWM: 'IWM'
  Error creating features for EFA: 'EFA'
  Error creating features for BND: 'BND'
  Error creating features for VNQ: 'VNQ'

This feature engineering approach demonstrates how robo-advisers can use machine learning to enhance traditional portfolio construction methods, incorporating the insights from Gu, Kelly, and Xiu (2020) whilst maintaining interpretability and economic intuition.

7.1.2 Multi-Objective Portfolio optimisation

The research by Liu et al. (2024) addresses a fundamental limitation of traditional portfolio optimisation: real investment decisions involve multiple, often conflicting objectives. Investors don’t just want to maximise return or minimise risk: they want to achieve the best possible trade-off among return, risk, diversification, liquidity, and other factors.

This challenge is part of a broader field of Multiple Criteria Decision Analysis (MCDA) that has developed over fifty years. As Greco, Słowiński, and Wallenius (2025) document in their comprehensive survey, MCDA provides “structured and traceable protocols to identify potential actions and the criteria for evaluating them.” Their review shows how the field has evolved from classical methods to sophisticated approaches that can handle complex, real-world decision problems.

Traditional mean-variance optimisation, whilst foundational, reduces this multi-dimensional problem to a single objective by fixing either the target return or risk level. The MCDA framework provides theoretical foundations for more sophisticated approaches, whilst evolutionary multi-objective optimisation (as in Liu et al. (2024)) provides computational methods that can explore the full space of possible trade-offs and identify solutions that are optimal across multiple dimensions simultaneously.

8 Demonstrating multi-objective portfolio optimisation concepts

import pandas as pd import numpy as np import matplotlib.pyplot as plt from scipy.optimize import minimize

def demonstrate_multi_objective_concepts(): ““” Illustrate multi-objective optimisation concepts for portfolio selection Following Liu et al. (2024) framework ““” print(“Multi-Objective Portfolio optimisation”) print(“=” * 40)

# Simulate a simplified multi-objective portfolio problem
np.random.seed(42)

# Portfolio of 5 assets with different characteristics
n_assets = 5
asset_names = ['Growth Stock', 'Value Stock', 'Bond', 'REIT', 'Commodity']

# Simulated expected returns and risks
expected_returns = np.array([0.12, 0.08, 0.04, 0.07, 0.06])  # Annual
volatilities = np.array([0.25, 0.18, 0.05, 0.20, 0.30])     # Annual

# Correlation matrix (simplified)
correlation_matrix = np.array([
    [1.00, 0.70, -0.10, 0.60, 0.30],  # Growth stock
    [0.70, 1.00, -0.05, 0.50, 0.25],  # Value stock
    [-0.10, -0.05, 1.00, 0.10, -0.20], # Bond
    [0.60, 0.50, 0.10, 1.00, 0.40],   # REIT
    [0.30, 0.25, -0.20, 0.40, 1.00]   # Commodity
])

# Convert to covariance matrix
cov_matrix = np.outer(volatilities, volatilities) * correlation_matrix

print(f"Portfolio universe: {n_assets} assets")
print(f"Expected returns: {expected_returns.min()*100:.0f}% to {expected_returns.max()*100:.0f}%")
print(f"Volatilities: {volatilities.min()*100:.0f}% to {volatilities.max()*100:.0f}%")

# Define multiple objectives
def portfolio_return(weights):
    return np.dot(weights, expected_returns)

def portfolio_risk(weights):
    return np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights)))

def portfolio_diversification(weights):
    # Diversification measure: 1 - Herfindahl index
    return 1 - np.sum(weights**2)

def portfolio_liquidity(weights):
    # Simplified liquidity measure (higher for bonds, lower for alternatives)
    liquidity_scores = np.array([0.8, 0.8, 1.0, 0.6, 0.4])  # Asset liquidity
    return np.dot(weights, liquidity_scores)

# Generate efficient frontier with multiple objectives
print(f"\nGenerating multi-objective efficient frontier...")

n_portfolios = 100
results = []

for i in range(n_portfolios):
    # Random initial

References

Barber, Brad M., Xing Huang, Terrance Odean, and Christopher Schwarz. 2022. “Attention-Induced Trading and Returns: Evidence from Robinhood Users.” Journal of Finance 77 (6): 3141–90. https://doi.org/10.1111/jofi.13183.
Barber, Brad M., and Terrance Odean. 2000. “Trading Is Hazardous to Your Wealth: The Common Stock Investment Performance of Individual Investors.” Journal of Finance 55 (2): 773–806. https://doi.org/10.1111/0022-1082.00226.
Berg, Tobias, Valentin Burg, Ana Gombović, and Manju Puri. 2020. “On the Rise of FinTechs: Credit Scoring Using Digital Footprints.” Review of Financial Studies 33 (7): 2845–97. https://doi.org/10.1093/rfs/hhz099.
Fuster, Andreas, Matthew C. Plosser, Philipp Schnabl, and James Vickery. 2019. “The Role of Technology in Mortgage Lending.” Review of Financial Studies 32 (5): 1854–99. https://doi.org/10.1093/rfs/hhz018.
Gabaix, Xavier, Ralph S. J. Koijen, Robert Richmond, and Motohiro Yogo. 2025. “Asset Embeddings.” Working Paper. SSRN Electronic Journal. https://doi.org/10.2139/ssrn.4507511.
Greco, Salvatore, Roman Słowiński, and Jyrki Wallenius. 2025. “Fifty Years of Multiple Criteria Decision Analysis: From Classical Methods to Robust Ordinal Regression.” European Journal of Operational Research 323: 351–77. https://doi.org/10.1016/j.ejor.2024.07.038.
Gu, Shihao, Bryan Kelly, and Dacheng Xiu. 2020. “Empirical Asset Pricing via Machine Learning.” Review of Financial Studies. https://doi.org/10.1093/rfs/hhaa009.
Hilpisch, Yves. 2019. Python for Finance. 2nd ed. O’Reilly Media. https://www.oreilly.com/library/view/python-for-finance/9781492024330/.
Kelly, Bryan T., Semyon Malamud, and Kangying Zhou. 2024. “The Virtue of Complexity in Return Prediction.” Journal of Finance 79 (1): 459–503. https://doi.org/10.1111/jofi.13298.
Laloux, Laurent, Pierre Cizeau, Jean-Philippe Bouchaud, and Marc Potters. 1999. “Noise Dressing of Financial Correlation Matrices.” Physical Review Letters 83 (7): 1467–70. https://doi.org/10.1103/PhysRevLett.83.1467.
Ledoit, Olivier, and Michael Wolf. 2004. “A Well-Conditioned Estimator for Large-Dimensional Covariance Matrices.” Journal of Multivariate Analysis 88 (2): 365–411. https://doi.org/10.1016/S0047-259X(03)00096-4.
Liu, Weilong, Yong Zhang, Kailong Liu, Barry Quinn, Xingyu Yang, and Qiao Peng. 2024. “Evolutionary Multi-Objective Optimisation for Large-Scale Portfolio Selection with Both Random and Uncertain Returns.” IEEE Transactions on Evolutionary Computation.
Marčenko, Vladimir A., and Leonid A. Pastur. 1967. “Distribution of Eigenvalues for Some Sets of Random Matrices.” Mathematics of the USSR-Sbornik 1 (4): 457–83. https://doi.org/10.1070/SM1967v001n04ABEH001994.
Reher, Michael, and Stanislav Sokolinski. 2024. “Robo-Advisors and Access to Wealth Management.” Journal of Financial Economics 155: 103829. https://doi.org/10.1016/j.jfineco.2024.103829.
Vaswani, Ashish, Noam Shazeer, Niki Parmar, Jakob Uszkoreit, Llion Jones, Aidan N. Gomez, Łukasz Kaiser, and Illia Polosukhin. 2017. “Attention Is All You Need.” In Advances in Neural Information Processing Systems. Vol. 30. https://arxiv.org/abs/1706.03762.
Vives, Xavier. 2019. “Digital Disruption in Banking.” Annual Review of Financial Economics. https://doi.org/10.1146/annurev-financial-100719-120854.