Module 6: Portfolio Analytics
Learning Objectives
By the end of this module, you will:
- Understand Modern Portfolio Theory (MPT) fundamentals
- Calculate portfolio returns and risk
- Construct and optimize portfolios using Python
- Generate the efficient frontier
- Calculate and interpret the Sharpe ratio
- Perform portfolio rebalancing
- Analyze portfolio performance attribution
- Build portfolio optimization tools
- Apply constraints to portfolio construction
6.1 Introduction to Portfolio Theory
Why Portfolios?
Individual stocks are risky. But combining stocks in a portfolio can reduce risk without sacrificing returns—sometimes even improving them. This is the fundamental insight of Modern Portfolio Theory, which earned Harry Markowitz a Nobel Prize.
Key Concepts
Diversification: The only free lunch in finance. By holding multiple assets, you can reduce portfolio volatility below the weighted average of individual volatilities.
Risk vs Return Trade-off: Higher expected returns typically require accepting higher risk. Portfolio theory helps you find the optimal balance.
Efficient Frontier: The set of portfolios offering the highest expected return for each level of risk, or the lowest risk for each level of return.
Portfolio Mathematics: The Basics
For a portfolio of N assets with weights w₁, w₂, ..., wₙ (where Σw = 1):
Portfolio Return Rₚ = Σ(wᵢ × Rᵢ)
Simply the weighted average of individual returns.
Portfolio Variance (more complex due to correlations) σₚ² = Σᵢ Σⱼ (wᵢ × wⱼ × σᵢⱼ)
Where σᵢⱼ is the covariance between assets i and j.
Let's implement this in Python.
6.2 Calculating Portfolio Returns and Risk
Simple Two-Asset Portfolio
import numpy as np
import pandas as pd
import yfinance as yf
# Download data for two stocks
tickers = ['AAPL', 'MSFT']
data = yf.download(tickers, start='2023-01-01', end='2024-01-01', progress=False)
prices = data['Adj Close']
returns = prices.pct_change().dropna()
# Define portfolio weights
weights = np.array([0.6, 0.4]) # 60% AAPL, 40% MSFT
# Calculate individual statistics
mean_returns = returns.mean() * 252 # Annualized
print("Annual Expected Returns:")
print(mean_returns)
std_returns = returns.std() * np.sqrt(252) # Annualized
print("\nAnnual Volatility:")
print(std_returns)
# Calculate portfolio return
portfolio_return = np.sum(weights * mean_returns)
print(f"\nPortfolio Expected Return: {portfolio_return*100:.2f}%")
# Calculate portfolio variance
# Method 1: Using covariance matrix
cov_matrix = returns.cov() * 252 # Annualized
portfolio_variance = np.dot(weights.T, np.dot(cov_matrix, weights))
portfolio_std = np.sqrt(portfolio_variance)
print(f"Portfolio Volatility: {portfolio_std*100:.2f}%")
Multi-Asset Portfolio
# Download multiple stocks
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'JNJ']
data = yf.download(tickers, start='2023-01-01', end='2024-01-01', progress=False)
prices = data['Adj Close']
returns = prices.pct_change().dropna()
# Equal weight portfolio
n_assets = len(tickers)
weights = np.array([1/n_assets] * n_assets)
print("Portfolio Weights:")
for ticker, weight in zip(tickers, weights):
print(f"{ticker}: {weight*100:.1f}%")
# Calculate portfolio metrics
mean_returns = returns.mean() * 252
cov_matrix = returns.cov() * 252
portfolio_return = np.sum(weights * mean_returns)
portfolio_variance = np.dot(weights.T, np.dot(cov_matrix, weights))
portfolio_std = np.sqrt(portfolio_variance)
print(f"\nPortfolio Expected Return: {portfolio_return*100:.2f}%")
print(f"Portfolio Volatility: {portfolio_std*100:.2f}%")
# Calculate Sharpe Ratio (assuming 0% risk-free rate)
sharpe_ratio = portfolio_return / portfolio_std
print(f"Sharpe Ratio: {sharpe_ratio:.2f}")
Portfolio Return Over Time
# Calculate actual portfolio returns over time
portfolio_returns = (returns * weights).sum(axis=1)
# Calculate cumulative returns
cumulative_portfolio = (1 + portfolio_returns).cumprod()
cumulative_individual = (1 + returns).cumprod()
# Compare
print("Portfolio Performance:")
print(f"Total Return: {(cumulative_portfolio.iloc[-1] - 1)*100:.2f}%")
print("\nIndividual Stock Performance:")
for ticker in tickers:
total_return = (cumulative_individual[ticker].iloc[-1] - 1) * 100
print(f"{ticker}: {total_return:.2f}%")
Portfolio Diversification Benefit
# Calculate weighted average volatility vs actual portfolio volatility
weighted_avg_vol = np.sum(weights * std_returns)
print(f"\nWeighted Average Volatility: {weighted_avg_vol*100:.2f}%")
print(f"Actual Portfolio Volatility: {portfolio_std*100:.2f}%")
print(f"Diversification Benefit: {(weighted_avg_vol - portfolio_std)*100:.2f}%")
if portfolio_std < weighted_avg_vol:
print("✓ Portfolio benefits from diversification!")
6.3 Portfolio Optimization
Random Portfolio Generation
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
# Download data
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'JNJ']
data = yf.download(tickers, start='2022-01-01', end='2024-01-01', progress=False)
prices = data['Adj Close']
returns = prices.pct_change().dropna()
# Calculate expected returns and covariance
mean_returns = returns.mean() * 252
cov_matrix = returns.cov() * 252
# Generate random portfolios
num_portfolios = 10000
results = np.zeros((3, num_portfolios))
weights_record = []
np.random.seed(42)
for i in range(num_portfolios):
# Generate random weights
weights = np.random.random(len(tickers))
weights /= np.sum(weights) # Normalize to sum to 1
weights_record.append(weights)
# Calculate portfolio return and risk
portfolio_return = np.sum(weights * mean_returns)
portfolio_std = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights)))
# Calculate Sharpe ratio
sharpe = portfolio_return / portfolio_std
# Store results
results[0,i] = portfolio_return
results[1,i] = portfolio_std
results[2,i] = sharpe
# Convert to DataFrame for easier analysis
portfolio_results = pd.DataFrame({
'Returns': results[0],
'Volatility': results[1],
'Sharpe': results[2]
})
print("Portfolio Statistics:")
print(portfolio_results.describe())
Visualizing Random Portfolios
# Create scatter plot
plt.figure(figsize=(14, 8))
scatter = plt.scatter(portfolio_results['Volatility']*100,
portfolio_results['Returns']*100,
c=portfolio_results['Sharpe'],
cmap='viridis',
alpha=0.6,
s=50)
plt.colorbar(scatter, label='Sharpe Ratio')
plt.xlabel('Volatility (%)', fontsize=12)
plt.ylabel('Expected Return (%)', fontsize=12)
plt.title('Random Portfolio Simulations', fontsize=16, fontweight='bold', pad=20)
plt.grid(True, alpha=0.3)
# Mark the portfolio with highest Sharpe ratio
max_sharpe_idx = portfolio_results['Sharpe'].idxmax()
max_sharpe_port = portfolio_results.loc[max_sharpe_idx]
plt.scatter(max_sharpe_port['Volatility']*100,
max_sharpe_port['Returns']*100,
marker='*', color='red', s=500, edgecolors='black',
label='Max Sharpe Ratio')
# Mark the portfolio with minimum volatility
min_vol_idx = portfolio_results['Volatility'].idxmin()
min_vol_port = portfolio_results.loc[min_vol_idx]
plt.scatter(min_vol_port['Volatility']*100,
min_vol_port['Returns']*100,
marker='*', color='green', s=500, edgecolors='black',
label='Min Volatility')
plt.legend(fontsize=11)
plt.tight_layout()
plt.show()
# Print optimal portfolios
print("\nMaximum Sharpe Ratio Portfolio:")
print(f"Return: {max_sharpe_port['Returns']*100:.2f}%")
print(f"Volatility: {max_sharpe_port['Volatility']*100:.2f}%")
print(f"Sharpe Ratio: {max_sharpe_port['Sharpe']:.2f}")
print("\nWeights:")
max_sharpe_weights = weights_record[max_sharpe_idx]
for ticker, weight in zip(tickers, max_sharpe_weights):
print(f"{ticker}: {weight*100:.2f}%")
print("\n" + "="*50)
print("\nMinimum Volatility Portfolio:")
print(f"Return: {min_vol_port['Returns']*100:.2f}%")
print(f"Volatility: {min_vol_port['Volatility']*100:.2f}%")
print(f"Sharpe Ratio: {min_vol_port['Sharpe']:.2f}")
print("\nWeights:")
min_vol_weights = weights_record[min_vol_idx]
for ticker, weight in zip(tickers, min_vol_weights):
print(f"{ticker}: {weight*100:.2f}%")
6.4 Efficient Frontier
Building the Efficient Frontier
The efficient frontier shows the best possible portfolios—those with the highest return for each level of risk.
from scipy.optimize import minimize
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
# Download data
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'JNJ']
data = yf.download(tickers, start='2022-01-01', end='2024-01-01', progress=False)
prices = data['Adj Close']
returns = prices.pct_change().dropna()
mean_returns = returns.mean() * 252
cov_matrix = returns.cov() * 252
def portfolio_stats(weights, mean_returns, cov_matrix):
"""Calculate portfolio return and volatility"""
portfolio_return = np.sum(weights * mean_returns)
portfolio_std = np.sqrt(np.dot(weights.T, np.dot(cov_matrix, weights)))
return portfolio_return, portfolio_std
def negative_sharpe(weights, mean_returns, cov_matrix, risk_free_rate=0):
"""Return negative Sharpe ratio (for minimization)"""
p_return, p_std = portfolio_stats(weights, mean_returns, cov_matrix)
return -(p_return - risk_free_rate) / p_std
def portfolio_volatility(weights, mean_returns, cov_matrix):
"""Return portfolio volatility"""
return portfolio_stats(weights, mean_returns, cov_matrix)[1]
# Constraints and bounds
constraints = {'type': 'eq', 'fun': lambda x: np.sum(x) - 1} # Weights sum to 1
bounds = tuple((0, 1) for _ in range(len(tickers))) # No short selling
initial_weights = np.array([1/len(tickers)] * len(tickers))
# Optimize for maximum Sharpe ratio
opt_sharpe = minimize(
negative_sharpe,
initial_weights,
args=(mean_returns, cov_matrix),
method='SLSQP',
bounds=bounds,
constraints=constraints
)
# Optimize for minimum volatility
opt_min_vol = minimize(
portfolio_volatility,
initial_weights,
args=(mean_returns, cov_matrix),
method='SLSQP',
bounds=bounds,
constraints=constraints
)
# Get optimal portfolio stats
max_sharpe_return, max_sharpe_vol = portfolio_stats(opt_sharpe.x, mean_returns, cov_matrix)
min_vol_return, min_vol_vol = portfolio_stats(opt_min_vol.x, mean_returns, cov_matrix)
print("Maximum Sharpe Ratio Portfolio:")
print(f"Return: {max_sharpe_return*100:.2f}%")
print(f"Volatility: {max_sharpe_vol*100:.2f}%")
print(f"Sharpe Ratio: {max_sharpe_return/max_sharpe_vol:.2f}")
print("\nWeights:")
for ticker, weight in zip(tickers, opt_sharpe.x):
if weight > 0.01: # Only show significant weights
print(f"{ticker}: {weight*100:.2f}%")
print("\n" + "="*60)
print("\nMinimum Volatility Portfolio:")
print(f"Return: {min_vol_return*100:.2f}%")
print(f"Volatility: {min_vol_vol*100:.2f}%")
print(f"Sharpe Ratio: {min_vol_return/min_vol_vol:.2f}")
print("\nWeights:")
for ticker, weight in zip(tickers, opt_min_vol.x):
if weight > 0.01:
print(f"{ticker}: {weight*100:.2f}%")
Plotting the Efficient Frontier
def efficient_frontier(mean_returns, cov_matrix, num_portfolios=100):
"""Generate efficient frontier"""
# Find minimum and maximum returns
min_ret = min_vol_return
max_ret = max_sharpe_return * 1.2 # Extend a bit beyond max Sharpe
target_returns = np.linspace(min_ret, max_ret, num_portfolios)
efficient_portfolios = []
for target in target_returns:
# Constraint: target return must be achieved
constraints = [
{'type': 'eq', 'fun': lambda x: np.sum(x) - 1}, # Weights sum to 1
{'type': 'eq', 'fun': lambda x: portfolio_stats(x, mean_returns, cov_matrix)[0] - target}
]
result = minimize(
portfolio_volatility,
initial_weights,
args=(mean_returns, cov_matrix),
method='SLSQP',
bounds=bounds,
constraints=constraints
)
if result.success:
eff_return, eff_vol = portfolio_stats(result.x, mean_returns, cov_matrix)
efficient_portfolios.append([eff_vol, eff_return])
return np.array(efficient_portfolios)
# Generate efficient frontier
efficient_portfolios = efficient_frontier(mean_returns, cov_matrix, num_portfolios=50)
# Plot
plt.figure(figsize=(14, 8))
# Plot random portfolios (from earlier)
plt.scatter(portfolio_results['Volatility']*100,
portfolio_results['Returns']*100,
c=portfolio_results['Sharpe'],
cmap='viridis',
alpha=0.3,
s=30,
label='Random Portfolios')
# Plot efficient frontier
plt.plot(efficient_portfolios[:,0]*100, efficient_portfolios[:,1]*100,
'r-', linewidth=3, label='Efficient Frontier')
# Plot optimal portfolios
plt.scatter(max_sharpe_vol*100, max_sharpe_return*100,
marker='*', color='red', s=500, edgecolors='black',
label='Max Sharpe Ratio', zorder=3)
plt.scatter(min_vol_vol*100, min_vol_return*100,
marker='*', color='green', s=500, edgecolors='black',
label='Min Volatility', zorder=3)
# Plot individual assets
for ticker in tickers:
asset_return = mean_returns[ticker]
asset_vol = np.sqrt(cov_matrix.loc[ticker, ticker])
plt.scatter(asset_vol*100, asset_return*100, marker='o', s=150,
label=ticker, edgecolors='black', linewidth=1.5)
plt.xlabel('Volatility (%)', fontsize=13)
plt.ylabel('Expected Return (%)', fontsize=13)
plt.title('Efficient Frontier with Optimal Portfolios', fontsize=16, fontweight='bold', pad=20)
plt.legend(loc='best', fontsize=10)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
6.5 Portfolio Constraints and Advanced Optimization
Adding Constraints
Real-world portfolios have constraints beyond "weights sum to 1":
from scipy.optimize import minimize
import numpy as np
def optimize_with_constraints(mean_returns, cov_matrix, tickers,
min_weight=0.05, max_weight=0.40,
sector_limits=None):
"""
Optimize portfolio with additional constraints
Parameters:
- min_weight: Minimum weight per asset (prevent tiny positions)
- max_weight: Maximum weight per asset (prevent concentration)
- sector_limits: Dictionary of sector constraints
"""
num_assets = len(tickers)
# Constraints
constraints = [
{'type': 'eq', 'fun': lambda x: np.sum(x) - 1} # Weights sum to 1
]
# Bounds: each weight between min_weight and max_weight
bounds = tuple((min_weight, max_weight) for _ in range(num_assets))
# Initial guess (equal weights)
initial_weights = np.array([1/num_assets] * num_assets)
# Optimize for maximum Sharpe ratio
result = minimize(
negative_sharpe,
initial_weights,
args=(mean_returns, cov_matrix),
method='SLSQP',
bounds=bounds,
constraints=constraints
)
return result
# Example with constraints
constrained_portfolio = optimize_with_constraints(
mean_returns,
cov_matrix,
tickers,
min_weight=0.10, # At least 10% per stock
max_weight=0.30 # At most 30% per stock
)
if constrained_portfolio.success:
const_return, const_vol = portfolio_stats(constrained_portfolio.x, mean_returns, cov_matrix)
print("Constrained Portfolio (10% min, 30% max per stock):")
print(f"Return: {const_return*100:.2f}%")
print(f"Volatility: {const_vol*100:.2f}%")
print(f"Sharpe Ratio: {const_return/const_vol:.2f}")
print("\nWeights:")
for ticker, weight in zip(tickers, constrained_portfolio.x):
print(f"{ticker}: {weight*100:.2f}%")
print("\n" + "="*60)
print("Comparison to Unconstrained Max Sharpe Portfolio:")
print(f"Return difference: {(const_return - max_sharpe_return)*100:.2f}%")
print(f"Volatility difference: {(const_vol - max_sharpe_vol)*100:.2f}%")
Target Return Optimization
def optimize_for_target_return(mean_returns, cov_matrix, target_return):
"""Find portfolio with minimum volatility for a target return"""
num_assets = len(mean_returns)
constraints = [
{'type': 'eq', 'fun': lambda x: np.sum(x) - 1},
{'type': 'eq', 'fun': lambda x: np.sum(x * mean_returns) - target_return}
]
bounds = tuple((0, 1) for _ in range(num_assets))
initial_weights = np.array([1/num_assets] * num_assets)
result = minimize(
portfolio_volatility,
initial_weights,
args=(mean_returns, cov_matrix),
method='SLSQP',
bounds=bounds,
constraints=constraints
)
return result
# Find portfolio for different target returns
target_returns = [0.10, 0.15, 0.20, 0.25] # 10%, 15%, 20%, 25%
print("Portfolios for Different Target Returns:")
print("="*60)
for target in target_returns:
result = optimize_for_target_return(mean_returns, cov_matrix, target)
if result.success:
ret, vol = portfolio_stats(result.x, mean_returns, cov_matrix)
print(f"\nTarget Return: {target*100:.0f}%")
print(f"Achieved Return: {ret*100:.2f}%")
print(f"Volatility: {vol*100:.2f}%")
print(f"Sharpe Ratio: {ret/vol:.2f}")
6.6 Portfolio Rebalancing
Understanding Rebalancing
Over time, portfolio weights drift from their targets as assets perform differently. Rebalancing brings weights back to target.
import numpy as np
import pandas as pd
import yfinance as yf
import matplotlib.pyplot as plt
# Download data
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN']
data = yf.download(tickers, start='2023-01-01', end='2024-01-01', progress=False)
prices = data['Adj Close']
# Initial equal weights
initial_weights = np.array([0.25, 0.25, 0.25, 0.25])
initial_value = 100000 # $100,000 portfolio
# Calculate initial shares
initial_prices = prices.iloc[0]
initial_shares = (initial_weights * initial_value) / initial_prices
print("Initial Portfolio:")
print(f"Total Value: ${initial_value:,.2f}")
print("\nShares purchased:")
for ticker, shares in zip(tickers, initial_shares):
print(f"{ticker}: {shares:.2f}")
# Track portfolio value over time without rebalancing
portfolio_values = (prices * initial_shares).sum(axis=1)
# Calculate actual weights over time
position_values = prices * initial_shares
actual_weights = position_values.div(portfolio_values, axis=0)
# Plot weight drift
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10))
# Portfolio value
ax1.plot(portfolio_values.index, portfolio_values.values, linewidth=2, color='#2E86AB')
ax1.set_title('Portfolio Value Over Time (No Rebalancing)',
fontsize=14, fontweight='bold')
ax1.set_ylabel('Value ($)', fontsize=12)
ax1.grid(True, alpha=0.3)
ax1.axhline(y=initial_value, color='red', linestyle='--', alpha=0.5, label='Initial Value')
ax1.legend()
# Weight drift
for ticker in tickers:
ax2.plot(actual_weights.index, actual_weights[ticker]*100,
linewidth=2, label=ticker)
ax2.axhline(y=25, color='gray', linestyle='--', alpha=0.3)
ax2.set_title('Portfolio Weight Drift Over Time', fontsize=14, fontweight='bold')
ax2.set_xlabel('Date', fontsize=12)
ax2.set_ylabel('Weight (%)', fontsize=12)
ax2.legend(loc='best')
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Final weights
final_weights = actual_weights.iloc[-1]
print("\nFinal Weights (No Rebalancing):")
for ticker, weight in final_weights.items():
print(f"{ticker}: {weight*100:.2f}%")
Implementing Periodic Rebalancing
def rebalance_portfolio(prices, target_weights, initial_value,
rebalance_frequency='Q'): # Q = Quarterly, M = Monthly
"""
Simulate portfolio with periodic rebalancing
Returns: DataFrame with portfolio value and turnover
"""
# Resample to rebalancing frequency
rebal_dates = prices.resample(rebalance_frequency).last().index
# Initialize
shares = (target_weights * initial_value) / prices.iloc[0]
portfolio_values = []
turnover_records = []
for date in prices.index:
# Calculate portfolio value
portfolio_value = (prices.loc[date] * shares).sum()
portfolio_values.append(portfolio_value)
# Check if rebalancing date
if date in rebal_dates and date != prices.index[0]:
# Calculate target dollar amounts
target_values = target_weights * portfolio_value
current_values = prices.loc[date] * shares
# Calculate new shares
new_shares = target_values / prices.loc[date]
# Calculate turnover (total value traded)
trades = abs(new_shares - shares) * prices.loc[date]
turnover = trades.sum()
turnover_records.append({'Date': date, 'Turnover': turnover})
# Update shares
shares = new_shares
results = pd.DataFrame({
'Value': portfolio_values
}, index=prices.index)
turnover_df = pd.DataFrame(turnover_records)
return results, turnover_df
# Compare strategies
target_weights = np.array([0.25, 0.25, 0.25, 0.25])
# No rebalancing
no_rebal_value = (prices * initial_shares).sum(axis=1)
# Quarterly rebalancing
quarterly_rebal, quarterly_turnover = rebalance_portfolio(
prices, target_weights, initial_value, 'Q'
)
# Monthly rebalancing
monthly_rebal, monthly_turnover = rebalance_portfolio(
prices, target_weights, initial_value, 'M'
)
# Plot comparison
plt.figure(figsize=(14, 7))
plt.plot(no_rebal_value.index, no_rebal_value.values,
linewidth=2, label='No Rebalancing', alpha=0.8)
plt.plot(quarterly_rebal.index, quarterly_rebal['Value'].values,
linewidth=2, label='Quarterly Rebalancing', alpha=0.8)
plt.plot(monthly_rebal.index, monthly_rebal['Value'].values,
linewidth=2, label='Monthly Rebalancing', alpha=0.8)
plt.title('Portfolio Value: Rebalancing Strategy Comparison',
fontsize=16, fontweight='bold', pad=20)
plt.xlabel('Date', fontsize=12)
plt.ylabel('Portfolio Value ($)', fontsize=12)
plt.legend(fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
# Compare final values and turnover
print("Strategy Comparison:")
print("="*60)
print(f"No Rebalancing Final Value: ${no_rebal_value.iloc[-1]:,.2f}")
print(f"Quarterly Rebalancing Final Value: ${quarterly_rebal['Value'].iloc[-1]:,.2f}")
print(f"Monthly Rebalancing Final Value: ${monthly_rebal['Value'].iloc[-1]:,.2f}")
print(f"\nQuarterly Turnover: ${quarterly_turnover['Turnover'].sum():,.2f}")
print(f"Monthly Turnover: ${monthly_turnover['Turnover'].sum():,.2f}")
6.7 Performance Attribution
Understanding Performance Attribution
Performance attribution answers: "Why did my portfolio perform the way it did?" It breaks down returns into components.
import numpy as np
import pandas as pd
import yfinance as yf
def performance_attribution(prices, weights, benchmark_ticker='^GSPC'):
"""
Analyze portfolio performance vs benchmark
"""
# Download benchmark
benchmark_data = yf.download(benchmark_ticker,
start=prices.index[0],
end=prices.index[-1],
progress=False)
benchmark_prices = benchmark_data['Adj Close']
# Calculate returns
portfolio_returns = prices.pct_change()
benchmark_returns = benchmark_prices.pct_change()
# Portfolio return (weighted)
weighted_returns = (portfolio_returns * weights).sum(axis=1)
# Align dates
aligned_returns = pd.DataFrame({
'Portfolio': weighted_returns,
'Benchmark': benchmark_returns
}).dropna()
# Calculate cumulative returns
portfolio_cumulative = (1 + aligned_returns['Portfolio']).cumprod()
benchmark_cumulative = (1 + aligned_returns['Benchmark']).cumprod()
# Performance metrics
total_portfolio_return = portfolio_cumulative.iloc[-1] - 1
total_benchmark_return = benchmark_cumulative.iloc[-1] - 1
excess_return = total_portfolio_return - total_benchmark_return
# Calculate tracking error
excess_returns = aligned_returns['Portfolio'] - aligned_returns['Benchmark']
tracking_error = excess_returns.std() * np.sqrt(252)
# Information ratio
information_ratio = (excess_returns.mean() * 252) / tracking_error if tracking_error > 0 else 0
# Attribution by holding
individual_returns = portfolio_returns.mean() * 252
contributions = individual_returns * weights
print("PERFORMANCE ATTRIBUTION ANALYSIS")
print("="*60)
print(f"\nPortfolio Return: {total_portfolio_return*100:.2f}%")
print(f"Benchmark Return: {total_benchmark_return*100:.2f}%")
print(f"Excess Return (Alpha): {excess_return*100:.2f}%")
print(f"Tracking Error: {tracking_error*100:.2f}%")
print(f"Information Ratio: {information_ratio:.2f}")
print("\n" + "="*60)
print("CONTRIBUTION BY HOLDING:")
print("-"*60)
attribution_df = pd.DataFrame({
'Weight': weights * 100,
'Return': individual_returns * 100,
'Contribution': contributions * 100
}, index=prices.columns)
print(attribution_df.sort_values('Contribution', ascending=False).to_string())
return aligned_returns, attribution_df
# Run attribution analysis
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN']
data = yf.download(tickers, start='2023-01-01', end='2024-01-01', progress=False)
prices = data['Adj Close']
weights = np.array([0.30, 0.30, 0.20, 0.20])
returns, attribution = performance_attribution(prices, weights, '^GSPC')
Visualizing Attribution
import matplotlib.pyplot as plt
# Create attribution visualization
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(16, 6))
# Contribution by holding
colors = ['green' if x > 0 else 'red' for x in attribution['Contribution']]
ax1.barh(attribution.index, attribution['Contribution'], color=colors, alpha=0.7, edgecolor='black')
ax1.set_xlabel('Contribution to Return (%)', fontsize=12)
ax1.set_title('Return Contribution by Holding', fontsize=14, fontweight='bold')
ax1.axvline(x=0, color='black', linewidth=1)
ax1.grid(True, alpha=0.3, axis='x')
# Portfolio vs Benchmark
cumulative_portfolio = (1 + returns['Portfolio']).cumprod()
cumulative_benchmark = (1 + returns['Benchmark']).cumprod()
ax2.plot(cumulative_portfolio.index, cumulative_portfolio.values,
linewidth=2.5, label='Portfolio', color='#2E86AB')
ax2.plot(cumulative_benchmark.index, cumulative_benchmark.values,
linewidth=2.5, label='Benchmark (S&P 500)', color='#A23B72')
ax2.set_xlabel('Date', fontsize=12)
ax2.set_ylabel('Cumulative Return', fontsize=12)
ax2.set_title('Portfolio vs Benchmark Performance', fontsize=14, fontweight='bold')
ax2.legend(fontsize=11)
ax2.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
6.8 Complete Portfolio Management System
Building a Portfolio Manager Class
import numpy as np
import pandas as pd
import yfinance as yf
from scipy.optimize import minimize
import matplotlib.pyplot as plt
class PortfolioManager:
"""
Complete portfolio management system
"""
def __init__(self, tickers, start_date, end_date):
self.tickers = tickers
self.start_date = start_date
self.end_date = end_date
self.data = None
self.returns = None
self.mean_returns = None
self.cov_matrix = None
self._download_data()
def _download_data(self):
"""Download price data"""
self.data = yf.download(self.tickers, start=self.start_date,
end=self.end_date, progress=False)
self.prices = self.data['Adj Close']
self.returns = self.prices.pct_change().dropna()
self.mean_returns = self.returns.mean() * 252
self.cov_matrix = self.returns.cov() * 252
def portfolio_stats(self, weights):
"""Calculate portfolio statistics"""
returns = np.sum(self.mean_returns * weights)
std = np.sqrt(np.dot(weights.T, np.dot(self.cov_matrix, weights)))
sharpe = returns / std if std > 0 else 0
return {'return': returns, 'volatility': std, 'sharpe': sharpe}
def optimize_portfolio(self, objective='sharpe', constraints=None):
"""
Optimize portfolio
objective: 'sharpe' or 'min_vol'
"""
num_assets = len(self.tickers)
# Default constraints
if constraints is None:
constraints = [{'type': 'eq', 'fun': lambda x: np.sum(x) - 1}]
bounds = tuple((0, 1) for _ in range(num_assets))
initial = np.array([1/num_assets] * num_assets)
if objective == 'sharpe':
objective_func = lambda w: -self.portfolio_stats(w)['sharpe']
elif objective == 'min_vol':
objective_func = lambda w: self.portfolio_stats(w)['volatility']
else:
raise ValueError("Objective must be 'sharpe' or 'min_vol'")
result = minimize(objective_func, initial, method='SLSQP',
bounds=bounds, constraints=constraints)
if result.success:
return result.x
else:
raise ValueError("Optimization failed")
def efficient_frontier(self, num_portfolios=50):
"""Generate efficient frontier"""
# Get min and max returns
min_vol_weights = self.optimize_portfolio('min_vol')
max_sharpe_weights = self.optimize_portfolio('sharpe')
min_ret = self.portfolio_stats(min_vol_weights)['return']
max_ret = self.portfolio_stats(max_sharpe_weights)['return'] * 1.2
target_returns = np.linspace(min_ret, max_ret, num_portfolios)
frontier = []
for target in target_returns:
try:
constraints = [
{'type': 'eq', 'fun': lambda x: np.sum(x) - 1},
{'type': 'eq', 'fun': lambda x: self.portfolio_stats(x)['return'] - target}
]
weights = self.optimize_portfolio('min_vol', constraints)
stats = self.portfolio_stats(weights)
frontier.append([stats['volatility'], stats['return'], stats['sharpe']])
except:
continue
return np.array(frontier)
def plot_efficient_frontier(self):
"""Visualize efficient frontier"""
# Generate frontier
frontier = self.efficient_frontier(50)
# Get optimal portfolios
max_sharpe_weights = self.optimize_portfolio('sharpe')
min_vol_weights = self.optimize_portfolio('min_vol')
max_sharpe_stats = self.portfolio_stats(max_sharpe_weights)
min_vol_stats = self.portfolio_stats(min_vol_weights)
# Plot
plt.figure(figsize=(14, 8))
# Efficient frontier
plt.plot(frontier[:,0]*100, frontier[:,1]*100,
'b-', linewidth=3, label='Efficient Frontier')
# Optimal portfolios
plt.scatter(max_sharpe_stats['volatility']*100, max_sharpe_stats['return']*100,
marker='*', s=500, c='red', edgecolors='black',
label=f"Max Sharpe ({max_sharpe_stats['sharpe']:.2f})")
plt.scatter(min_vol_stats['volatility']*100, min_vol_stats['return']*100,
marker='*', s=500, c='green', edgecolors='black',
label='Min Volatility')
# Individual assets
for ticker in self.tickers:
ret = self.mean_returns[ticker]
vol = np.sqrt(self.cov_matrix.loc[ticker, ticker])
plt.scatter(vol*100, ret*100, marker='o', s=150, label=ticker)
plt.xlabel('Volatility (%)', fontsize=13)
plt.ylabel('Expected Return (%)', fontsize=13)
plt.title('Efficient Frontier Analysis', fontsize=16, fontweight='bold', pad=20)
plt.legend(loc='best', fontsize=11)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.show()
def get_optimal_weights(self, objective='sharpe'):
"""Get optimal portfolio weights"""
weights = self.optimize_portfolio(objective)
stats = self.portfolio_stats(weights)
portfolio_df = pd.DataFrame({
'Weight': weights * 100,
}, index=self.tickers)
portfolio_df = portfolio_df[portfolio_df['Weight'] > 0.5] # Filter small weights
print(f"\nOptimal Portfolio ({objective.upper()}):")
print("="*50)
print(f"Expected Return: {stats['return']*100:.2f}%")
print(f"Volatility: {stats['volatility']*100:.2f}%")
print(f"Sharpe Ratio: {stats['sharpe']:.2f}")
print("\nWeights:")
print(portfolio_df.to_string())
return weights, stats
# Example usage
tickers = ['AAPL', 'MSFT', 'GOOGL', 'AMZN', 'JNJ', 'JPM', 'XOM']
pm = PortfolioManager(tickers, '2022-01-01', '2024-01-01')
# Get optimal portfolios
max_sharpe_weights, max_sharpe_stats = pm.get_optimal_weights('sharpe')
min_vol_weights, min_vol_stats = pm.get_optimal_weights('min_vol')
# Plot efficient frontier
pm.plot_efficient_frontier()
6.9 Practice Exercises
Exercise 1: Build Your Own Portfolio
# Your task: Create and optimize a portfolio
# 1. Choose 6-10 stocks from different sectors
# 2. Download 2 years of data
# 3. Calculate the efficient frontier
# 4. Find the maximum Sharpe ratio portfolio
# 5. Find the minimum volatility portfolio
# 6. Compare to an equal-weighted portfolio
# 7. Visualize all three on a risk-return chart
Exercise 2: Sector Allocation
# Your task: Create a sector-balanced portfolio
# 1. Select 2 stocks from each sector: Tech, Finance, Healthcare, Energy
# 2. Add constraints: max 40% in any one sector
# 3. Optimize for maximum Sharpe ratio
# 4. Compare performance to S&P 500
# 5. Calculate tracking error and information ratio
Exercise 3: Rebalancing Strategy
# Your task: Test different rebalancing strategies
# 1. Create a portfolio with equal weights
# 2. Simulate: no rebalancing, monthly, quarterly, annual
# 3. Calculate total return for each strategy
# 4. Calculate total turnover (trading costs proxy)
# 5. Determine which strategy is optimal
# 6. Visualize the weight drift for each approach
Exercise 4: Risk Parity Portfolio
# Your task: Build a risk parity portfolio
# Risk parity allocates based on risk contribution, not dollars
# 1. Download data for 4-6 assets
# 2. Calculate each asset's volatility
# 3. Create weights inversely proportional to volatility
# 4. Normalize weights to sum to 1
# 5. Compare risk parity to equal weight and optimal Sharpe
# 6. Calculate and compare Sharpe ratios
Module 6 Summary
Congratulations! You've mastered portfolio analytics and optimization.
What You've Accomplished
Portfolio Theory
- Understanding Modern Portfolio Theory fundamentals
- Calculating portfolio returns and risk
- Recognizing diversification benefits
- Applying the risk-return trade-off
Optimization Techniques
- Building efficient frontiers
- Maximizing Sharpe ratios
- Minimizing portfolio volatility
- Adding realistic constraints
- Targeting specific returns
Portfolio Management
- Implementing rebalancing strategies
- Performing performance attribution
- Comparing to benchmarks
- Calculating tracking error and information ratios
Practical Skills
- Using scipy.optimize for portfolio optimization
- Building complete portfolio management systems
- Creating professional visualization tools
- Making data-driven allocation decisions
Real-World Capabilities
You can now:
- Construct optimized portfolios from any universe of stocks
- Balance risk and return scientifically
- Implement professional portfolio management strategies
- Analyze and attribute performance
- Build tools used by institutional investors
What's Next
In the remaining modules, you'll learn:
- Module 7: Technical analysis with Python
- Module 8: Financial modeling fundamentals
- Module 9: Statistical analysis for finance
- Module 10: Real-world capstone project
Before Moving Forward
Ensure you're comfortable with:
- Calculating portfolio statistics
- Using optimization functions
- Understanding the efficient frontier
- Implementing rebalancing logic
- Performing attribution analysis
Key Takeaways
- Diversification is Powerful: Proper diversification reduces risk without sacrificing returns
- Optimization Requires Judgment: Mathematical optimization is a tool; real portfolios need constraints and practical considerations
- Rebalancing Matters: How and when you rebalance significantly impacts performance
- Attribution Explains Why: Understanding what drove returns helps improve future decisions
The Professional Edge
Portfolio optimization is where quantitative finance meets practical investment management. The tools you've learned in this module are used daily by:
- Portfolio managers at mutual funds and hedge funds
- Robo-advisors automating allocation decisions
- Risk managers ensuring diversification
- Financial advisors constructing client portfolios
You're no longer just analyzing individual stocks—you're thinking like a professional portfolio manager.
Continue to Module 7: Technical Analysis with Python →

