Chapter 9: Optimal Portfolio Construction

A Comprehensive Practical Guide with Real-World Examples

Portfolio Management Course

2025-12-06

Introduction

This workbook demonstrates the key concepts from Chapter 9: Optimal Portfolio Construction. We will work through real-world examples using actual stock data to understand:

  1. Practical complexities in portfolio construction
  2. The Treynor-Black model for combining active and passive investing
  3. Tracking risk and benchmark-relative performance
  4. Forecast precision and alpha adjustment

Throughout this workbook, we’ll use clear commentary to explain each step, making it easy to follow the analysis and understand the underlying concepts.


Setup and Data Preparation

Load Required Libraries

# Core data manipulation and visualization
library(tidyverse)      # Data manipulation and visualization
library(lubridate)      # Date handling
library(scales)         # Formatting for plots

# Financial data and analysis
library(quantmod)       # Download financial data
library(PerformanceAnalytics)  # Portfolio analytics
library(quadprog)       # Quadratic programming for optimization

# Tables and presentation
library(knitr)          # Tables
library(kableExtra)     # Enhanced tables
library(corrplot)       # Correlation visualization
library(rmdformats)     # Document formatting

# Set random seed for reproducibility
set.seed(42)

# Define color palette for dark theme plots
dark_palette <- c(
  "#00d4ff",  # Cyan

  "#ff6b6b",  # Coral

  "#4ecdc4",  # Teal
  "#ffe66d",  # Yellow

  "#95e1d3",  # Mint

  "#f38181",  # Salmon

  "#aa96da",  # Lavender
  "#fcbad3",  # Pink
  "#a8d8ea",  # Light blue

  "#ff9f43",  # Orange
  "#74b9ff"   # Sky blue
)

Download Real Market Data

We’ll work with a diversified set of US stocks representing different sectors, plus the S&P 500 as our market benchmark.

# Define our investment universe
# We're selecting stocks from different sectors for diversification
tickers <- c(
  "AAPL",   # Technology - Apple
  "MSFT",   # Technology - Microsoft
  "JPM",    # Financials - JPMorgan Chase
  "JNJ",    # Healthcare - Johnson & Johnson
  "XOM",    # Energy - Exxon Mobil
  "PG",     # Consumer Staples - Procter & Gamble
  "AMZN",   # Consumer Discretionary - Amazon
  "DIS",    # Communication Services - Disney
  "CAT",    # Industrials - Caterpillar
  "NEE"     # Utilities - NextEra Energy
)

# Market benchmark
market_ticker <- "SPY"  # S&P 500 ETF

# Risk-free rate proxy
rf_ticker <- "^IRX"  # 13-week Treasury Bill rate

# Download data period (5 years of monthly data)
start_date <- "2019-01-01"
end_date <- "2024-01-01"

# Function to safely download and extract adjusted prices
get_prices <- function(ticker, start, end) {
  tryCatch({
    data <- getSymbols(ticker, src = "yahoo", from = start, to = end, 
                       auto.assign = FALSE, periodicity = "monthly")
    Ad(data)  # Get adjusted closing prices
  }, error = function(e) {
    message(paste("Error downloading", ticker, ":", e$message))
    return(NULL)
  })
}

# Download all stock data
cat("Downloading stock data...\n")
## Downloading stock data...
stock_prices <- do.call(merge, lapply(tickers, get_prices, start_date, end_date))
colnames(stock_prices) <- tickers

# Download market data
cat("Downloading market data...\n")
## Downloading market data...
market_prices <- get_prices(market_ticker, start_date, end_date)
colnames(market_prices) <- "Market"

# Download risk-free rate (and convert from annual % to monthly decimal)
cat("Downloading risk-free rate data...\n")
## Downloading risk-free rate data...
rf_data <- get_prices(rf_ticker, start_date, end_date)
rf_rate <- mean(as.numeric(rf_data), na.rm = TRUE) / 100 / 12  # Monthly risk-free rate

cat(paste("Monthly risk-free rate:", round(rf_rate * 100, 4), "%\n"))
## Monthly risk-free rate: 0.1583 %

Calculate Returns

# Calculate monthly returns
stock_returns <- Return.calculate(stock_prices, method = "log")
market_returns <- Return.calculate(market_prices, method = "log")

# Remove first row (NA from return calculation)
stock_returns <- stock_returns[-1, ]
market_returns <- market_returns[-1, ]

# Merge to align dates automatically (xts handles this well)
all_returns <- merge(stock_returns, market_returns, join = "inner")
colnames(all_returns) <- c(tickers, "Market")

# Remove any rows with NA values
all_returns <- na.omit(all_returns)

# Separate back into stock and market returns
stock_returns <- all_returns[, tickers]
market_returns <- all_returns[, "Market"]

# Convert to data frames for easier manipulation
returns_df <- data.frame(
  Date = index(all_returns),
  coredata(all_returns)
)

# Display summary statistics
cat(paste0(
  "\n=== RETURN SUMMARY STATISTICS ===\n",
  "Number of observations: ", nrow(returns_df), "\n",
  "Date range: ", min(returns_df$Date), " to ", max(returns_df$Date), "\n\n"
))
## 
## === RETURN SUMMARY STATISTICS ===
## Number of observations: 59
## Date range: 2019-02-01 to 2023-12-01
# Summary statistics table
summary_stats <- returns_df %>%
  select(-Date) %>%
  summarise(across(everything(), list(
    Mean = ~mean(., na.rm = TRUE) * 12,  # Annualized
    StdDev = ~sd(., na.rm = TRUE) * sqrt(12),  # Annualized
    Min = ~min(., na.rm = TRUE),
    Max = ~max(., na.rm = TRUE)
  ))) %>%
  pivot_longer(everything(), 
               names_to = c("Asset", "Stat"), 
               names_pattern = "(.*)_(.*)") %>%
  pivot_wider(names_from = Stat, values_from = value)

kable(summary_stats, digits = 4, 
      caption = "Annualized Return Statistics (2019-2024)") %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
Annualized Return Statistics (2019-2024)
Asset Mean StdDev Min Max
AAPL 0.3199 0.2962 -0.1365 0.1942
MSFT 0.2711 0.2166 -0.1136 0.1624
JPM 0.1319 0.2840 -0.2544 0.1935
JNJ 0.0608 0.1753 -0.1017 0.1347
XOM 0.1152 0.3447 -0.2894 0.2384
PG 0.1108 0.1725 -0.0899 0.1235
AMZN 0.1159 0.3215 -0.2712 0.2395
DIS -0.0405 0.3496 -0.2060 0.2221
CAT 0.1870 0.3191 -0.1887 0.2771
NEE 0.0851 0.2381 -0.1782 0.1614
Market 0.1309 0.1876 -0.1392 0.1254

Visualize Return Distributions

# Reshape for plotting
returns_long <- returns_df %>%
  pivot_longer(-Date, names_to = "Asset", values_to = "Return")

# Histogram of returns by asset
ggplot(returns_long, aes(x = Return, fill = Asset)) +
  geom_histogram(bins = 30, alpha = 0.8, color = "#1a1a2e") +
  facet_wrap(~Asset, scales = "free_y", ncol = 4) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "#ff6b6b", linewidth = 0.8) +
  scale_fill_manual(values = dark_palette) +
  labs(title = "Distribution of Monthly Returns by Asset",
       subtitle = "Red dashed line indicates zero return",
       x = "Monthly Return",
       y = "Frequency")

Commentary: The histograms show the return distributions for each asset. Notice that:

  • Most returns cluster around zero (normal market behavior)
  • Some assets show wider distributions (higher volatility)
  • The red line at zero helps visualize whether returns skew positive or negative

Part 1: The Sensitivity Problem in Portfolio Optimization

The Core Issue: Small Input Changes -> Large Output Changes

Let’s demonstrate why portfolio optimization is so sensitive to expected return estimates.

# Calculate sample statistics
# Convert xts to matrix for calculations
stock_mat <- as.matrix(coredata(stock_returns))

# Expected returns (annualized)
exp_returns <- colMeans(stock_mat, na.rm = TRUE) * 12

# Variance-covariance matrix (annualized)
cov_matrix <- cov(stock_mat, use = "complete.obs") * 12

# Standard deviations
std_devs <- sqrt(diag(cov_matrix))

# Display expected returns
cat("=== ESTIMATED EXPECTED RETURNS (Annualized) ===\n\n")
## === ESTIMATED EXPECTED RETURNS (Annualized) ===
exp_return_df <- data.frame(
  Asset = names(exp_returns),
  Expected_Return = exp_returns,
  Std_Dev = std_devs
) %>%
  arrange(desc(Expected_Return))

kable(exp_return_df, digits = 4,
      col.names = c("Asset", "Expected Return", "Std Dev")) %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
Asset Expected Return Std Dev
AAPL AAPL 0.3199 0.2962
MSFT MSFT 0.2711 0.2166
CAT CAT 0.1870 0.3191
JPM JPM 0.1319 0.2840
AMZN AMZN 0.1159 0.3215
XOM XOM 0.1152 0.3447
PG PG 0.1108 0.1725
NEE NEE 0.0851 0.2381
JNJ JNJ 0.0608 0.1753
DIS DIS -0.0405 0.3496

Markowitz Mean-Variance Optimization

#' Solve for the optimal portfolio weights using quadratic programming
#' 
#' This function implements the Markowitz mean-variance optimization
#' to find weights that maximize the Sharpe ratio
#' 
#' @param exp_returns Vector of expected returns
#' @param cov_matrix Covariance matrix
#' @param rf Risk-free rate
#' @param allow_short Whether to allow short selling
#' @return Vector of optimal weights
optimize_portfolio <- function(exp_returns, cov_matrix, rf, allow_short = TRUE) {
  n <- length(exp_returns)
  
  # Excess returns
  excess_returns <- exp_returns - rf
  
  if (allow_short) {
    # Unconstrained optimization (allows short selling)
    # Maximize: w'mu - rf / sqrt(w'Sumw)
    # Equivalent to: minimize w'Sumw subject to w'mu = 1
    
    # Using quadprog: minimize 0.5 * w' D w - d' w
    # Subject to: A' w >= b
    
    Dmat <- 2 * cov_matrix
    dvec <- rep(0, n)
    
    # Constraints: sum of weights = 1
    Amat <- cbind(rep(1, n), excess_returns)
    bvec <- c(1, 0.01)  # Require positive expected excess return
    
    result <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
    
    # Scale to sum to 1 and have positive Sharpe ratio
    weights <- result$solution
    
    # Rescale weights for target excess return
    weights <- weights / sum(weights)
    
  } else {
    # Constrained optimization (no short selling, max 100% in any asset)
    
    # Add non-negativity constraints
    Dmat <- 2 * cov_matrix
    dvec <- rep(0, n)
    
    # Constraints matrix: 
    # 1. Sum of weights = 1
    # 2. Each weight >= 0
    # 3. Each weight <= 1
    Amat <- cbind(
      rep(1, n),           # Sum = 1
      diag(n),             # w_i >= 0
      -diag(n)             # w_i <= 1 (i.e., -w_i >= -1)
    )
    bvec <- c(1, rep(0, n), rep(-1, n))
    
    result <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
    weights <- result$solution
  }
  
  names(weights) <- names(exp_returns)
  return(weights)
}

# Calculate optimal portfolio with short selling allowed
weights_short <- optimize_portfolio(exp_returns, cov_matrix, rf_rate * 12, allow_short = TRUE)

# Calculate optimal portfolio without short selling
weights_long <- optimize_portfolio(exp_returns, cov_matrix, rf_rate * 12, allow_short = FALSE)

# Display comparison
weight_comparison <- data.frame(
  Asset = names(weights_short),
  With_Short_Selling = weights_short,
  Long_Only = weights_long
) %>%
  mutate(
    Difference = With_Short_Selling - Long_Only
  )

cat("\n=== OPTIMAL PORTFOLIO WEIGHTS COMPARISON ===\n\n")
## 
## === OPTIMAL PORTFOLIO WEIGHTS COMPARISON ===
kable(weight_comparison, digits = 4,
      col.names = c("Asset", "With Short Selling", "Long Only", "Difference")) %>%
  kable_styling(bootstrap_options = c("striped", "hover")) %>%
  column_spec(2, color = ifelse(weight_comparison$With_Short_Selling < 0, "red", "green")) %>%
  column_spec(3, color = "blue")
Asset With Short Selling Long Only Difference
AAPL AAPL -0.3409 0.0000 -0.3409
MSFT MSFT 0.0788 0.0000 0.0788
JPM JPM 0.0994 0.0322 0.0672
JNJ JNJ 0.2845 0.2735 0.0110
XOM XOM 0.0831 0.0611 0.0220
PG PG 0.4580 0.3832 0.0748
AMZN AMZN 0.2732 0.1008 0.1724
DIS DIS -0.0587 0.0000 -0.0587
CAT CAT -0.0163 0.0000 -0.0163
NEE NEE 0.1390 0.1493 -0.0103

Commentary: Notice the dramatic differences:

  • With short selling: The optimizer may suggest extreme positions (large positive and negative weights)
  • Long only: Weights are constrained to be between 0% and 100%
  • The “Difference” column shows how constraints fundamentally change the portfolio

Demonstrating Sensitivity to Expected Return Changes

#' Demonstrate how small changes in expected returns affect optimal weights
#' 
#' We'll perturb one stock's expected return by small amounts and observe
#' how the entire optimal portfolio changes
sensitivity_analysis <- function(exp_returns, cov_matrix, rf, 
                                  perturb_asset, perturbations) {
  
  results <- map_dfr(perturbations, function(delta) {
    # Create perturbed expected returns
    perturbed_returns <- exp_returns
    perturbed_returns[perturb_asset] <- exp_returns[perturb_asset] + delta
    
    # Optimize
    weights <- optimize_portfolio(perturbed_returns, cov_matrix, rf, allow_short = TRUE)
    
    # Calculate portfolio statistics
    port_return <- sum(weights * perturbed_returns)
    port_vol <- sqrt(t(weights) %*% cov_matrix %*% weights)
    sharpe <- (port_return - rf) / port_vol
    
    data.frame(
      Perturbation = delta,
      t(weights),
      Portfolio_Return = port_return,
      Portfolio_Vol = port_vol,
      Sharpe_Ratio = sharpe
    )
  })
  
  return(results)
}

# Perturbations: -5% to +5% change in expected return
perturbations <- seq(-0.05, 0.05, by = 0.01)

# Run sensitivity analysis on AAPL
sensitivity_results <- sensitivity_analysis(
  exp_returns, cov_matrix, rf_rate * 12,
  perturb_asset = "AAPL",
  perturbations = perturbations
)

cat("\n=== SENSITIVITY ANALYSIS: Perturbing AAPL Expected Return ===\n\nHow do optimal weights change when we adjust AAPL's expected return?\n\n")
## 
## === SENSITIVITY ANALYSIS: Perturbing AAPL Expected Return ===
## 
## How do optimal weights change when we adjust AAPL's expected return?
# Display key results
kable(sensitivity_results %>% 
        select(Perturbation, AAPL, MSFT, JPM, Portfolio_Return, Sharpe_Ratio) %>%
        filter(Perturbation %in% c(-0.05, -0.02, 0, 0.02, 0.05)),
      digits = 4,
      caption = "Portfolio Weights Under Different AAPL Expected Return Assumptions") %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
Portfolio Weights Under Different AAPL Expected Return Assumptions
Perturbation AAPL MSFT JPM Portfolio_Return Sharpe_Ratio
-0.05 -0.3409 0.0788 0.0994 0.0629 0.3548
0.00 -0.3409 0.0788 0.0994 0.0458 0.2170
0.05 -0.3405 0.0792 0.0994 0.0290 0.0809
# Visualize sensitivity
sensitivity_long <- sensitivity_results %>%
  select(Perturbation, all_of(tickers)) %>%
  pivot_longer(-Perturbation, names_to = "Asset", values_to = "Weight")

ggplot(sensitivity_long, aes(x = Perturbation, y = Weight, color = Asset)) +
  geom_line(linewidth = 1) +
  geom_point(size = 2) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "#ff6b6b", linewidth = 0.8) +
  geom_vline(xintercept = 0, linetype = "dashed", color = "#ff6b6b", linewidth = 0.8) +
  scale_color_manual(values = dark_palette) +
  labs(
    title = "Sensitivity of Optimal Weights to AAPL Expected Return",
    subtitle = "Small changes in expected return cause large changes in optimal allocation",
    x = "Perturbation to AAPL Expected Return",
    y = "Optimal Weight",
    color = "Asset"
  ) +
  scale_x_continuous(labels = percent_format()) +
  scale_y_continuous(labels = percent_format())

Commentary: This chart is crucial for understanding the sensitivity problem:

  • The x-axis shows changes to AAPL’s expected return (from -5% to +5%)
  • The y-axis shows how the optimal weights change
  • Notice how even a 1-2% change in expected return can cause weights to swing dramatically
  • This is why practitioners are skeptical of pure mean-variance optimization

Part 2: The Treynor-Black Model

Overview of the Treynor-Black Approach

The Treynor-Black model provides a practical framework for active portfolio management:

  1. Start with a passive benchmark (the market portfolio)
  2. Identify mispriced securities (those with non-zero alpha)
  3. Construct an active portfolio from mispriced securities
  4. Combine active and passive to maximize Sharpe ratio

Step 1: Estimate Alpha and Beta for Each Security

#' Run CAPM regressions for each security
#' 
#' For each stock, we estimate: R_i - R_f = alpha_i + beta_i * (R_m - R_f) + e_i
#' 
#' alpha_i: Security's abnormal return (what we're looking for!)
#' beta_i: Sensitivity to market movements
#' sigma_e_i: Idiosyncratic (firm-specific) risk
run_capm_regressions <- function(stock_returns, market_returns, rf) {
  # Convert to numeric vectors/matrix
  stock_mat <- as.matrix(coredata(stock_returns))
  market_vec <- as.numeric(coredata(market_returns))
  
  results <- map_dfr(colnames(stock_mat), function(ticker) {
    # Get excess returns
    stock_excess <- stock_mat[, ticker] - rf
    market_excess <- market_vec - rf
    
    # Run regression
    model <- lm(stock_excess ~ market_excess)
    
    # Extract results
    data.frame(
      Asset = ticker,
      Alpha = coef(model)[1] * 12,  # Annualized
      Alpha_SE = summary(model)$coefficients[1, 2] * sqrt(12),
      Beta = coef(model)[2],
      Beta_SE = summary(model)$coefficients[2, 2],
      Sigma_e = sd(residuals(model)) * sqrt(12),  # Annualized
      R_squared = summary(model)$r.squared,
      T_stat_alpha = coef(model)[1] / summary(model)$coefficients[1, 2]
    )
  })
  
  return(results)
}

# Run CAPM regressions
capm_results <- run_capm_regressions(stock_returns, market_returns, rf_rate)

cat("\n=== CAPM REGRESSION RESULTS ===\n\nModel: R_i - R_f = alpha + beta x (R_m - R_f) + epsilon\n\n")
## 
## === CAPM REGRESSION RESULTS ===
## 
## Model: R_i - R_f = alpha + beta x (R_m - R_f) + epsilon
kable(capm_results, digits = 4,
      col.names = c("Asset", "Alpha (Ann.)", "Alpha SE", "Beta", 
                    "Beta SE", "sigma(epsilon) (Ann.)", "R^2", "t-stat (alpha)"),
      caption = "CAPM Regression Results for Each Security") %>%
  kable_styling(bootstrap_options = c("striped", "hover")) %>%
  column_spec(2, color = ifelse(capm_results$Alpha > 0, "green", "red"))
CAPM Regression Results for Each Security
Asset Alpha (Ann.) Alpha SE Beta Beta SE sigma(epsilon) (Ann.) R^2 t-stat (alpha)
(Intercept)…1 AAPL 0.1597 0.0237 1.2624 0.1257 0.1780 0.6390 1.9435
(Intercept)…2 MSFT 0.1554 0.0192 0.8642 0.1015 0.1437 0.5599 2.3421
(Intercept)…3 JPM -0.0142 0.0250 1.1361 0.1326 0.1877 0.5631 -0.1638
(Intercept)…4 JNJ -0.0127 0.0199 0.4869 0.1056 0.1496 0.2716 -0.1834
(Intercept)…5 XOM -0.0096 0.0394 0.9458 0.2087 0.2956 0.2648 -0.0703
(Intercept)…6 PG 0.0454 0.0205 0.4149 0.1087 0.1539 0.2037 0.6383
(Intercept)…7 AMZN -0.0259 0.0329 1.0981 0.1743 0.2468 0.4106 -0.2276
(Intercept)…8 DIS -0.2133 0.0315 1.3752 0.1667 0.2360 0.5443 -1.9573
(Intercept)…9 CAT 0.0465 0.0327 1.0859 0.1734 0.2456 0.4075 0.4103
(Intercept)…10 NEE 0.0072 0.0289 0.5262 0.1530 0.2166 0.1719 0.0722

Commentary: Understanding the regression results:

  • Alpha (alpha): The annualized abnormal return. Positive alpha means the stock outperforms what CAPM predicts. Negative alpha means underperformance.
  • Beta (beta): Market sensitivity. beta > 1 means more volatile than market; beta < 1 means less volatile.
  • sigma(epsilon): Idiosyncratic volatility - the firm-specific risk that can be diversified away.
  • R^2: How much of the stock’s movement is explained by market movements.
  • t-stat (alpha): Statistical significance of alpha. |t| > 2 suggests significant alpha.
# Visualize alpha vs beta
ggplot(capm_results, aes(x = Beta, y = Alpha, label = Asset)) +
  geom_point(aes(size = Sigma_e, color = Alpha), alpha = 0.8) +
  geom_text(vjust = -1, hjust = 0.5, size = 3, color = "#e0e0e0") +
  geom_hline(yintercept = 0, linetype = "dashed", color = "#ff6b6b", linewidth = 0.8) +
  geom_vline(xintercept = 1, linetype = "dashed", color = "#00d4ff", linewidth = 0.8) +
  scale_color_gradient2(low = "#ff6b6b", mid = "#808080", high = "#4ecdc4", midpoint = 0) +
  labs(
    title = "Security Alpha vs Beta",
    subtitle = "Point size represents idiosyncratic risk (sigma_epsilon)",
    x = "Beta (Market Sensitivity)",
    y = "Alpha (Annualized Abnormal Return)",
    size = "sigma(epsilon)",
    color = "Alpha"
  )

Commentary: This scatter plot shows:

  • Vertical axis (Alpha): Stocks above zero have positive abnormal returns; below zero have negative
  • Horizontal axis (Beta): Stocks to the right of 1 are more aggressive; to the left are more defensive
  • Point size: Larger points have higher idiosyncratic risk
  • Ideally, we want stocks in the upper portion (positive alpha) with smaller points (low idiosyncratic risk)

Step 2: Calculate the Information Ratio for Each Security

The information ratio tells us how much alpha we get per unit of idiosyncratic risk:

\[IR_i = \frac{\alpha_i}{\sigma_{\varepsilon,i}}\]

# Calculate information ratios
capm_results <- capm_results %>%
  mutate(
    # Information ratio = alpha / idiosyncratic volatility
    Info_Ratio = Alpha / Sigma_e,
    
    # Alpha-to-variance ratio (used for weighting)
    Alpha_to_Var = Alpha / (Sigma_e^2),
    
    # Squared information ratio (contribution to portfolio Sharpe ratio)
    IR_Squared = Info_Ratio^2
  )

cat("\n=== INFORMATION RATIOS ===\n\nInformation Ratio = alpha / sigma(epsilon)\nThis measures abnormal return per unit of diversifiable risk\n\n")
## 
## === INFORMATION RATIOS ===
## 
## Information Ratio = alpha / sigma(epsilon)
## This measures abnormal return per unit of diversifiable risk
kable(capm_results %>% 
        select(Asset, Alpha, Sigma_e, Info_Ratio, IR_Squared) %>%
        arrange(desc(Info_Ratio)),
      digits = 4,
      col.names = c("Asset", "Alpha", "sigma(epsilon)", "Info Ratio", "IR^2"),
      caption = "Information Ratios Ranked from Best to Worst") %>%
  kable_styling(bootstrap_options = c("striped", "hover")) %>%
  column_spec(4, color = ifelse(capm_results %>% 
                                   arrange(desc(Info_Ratio)) %>% 
                                   pull(Info_Ratio) > 0, "green", "red"))
Information Ratios Ranked from Best to Worst
Asset Alpha sigma(epsilon) Info Ratio IR^2
(Intercept)…1 MSFT 0.1554 0.1437 1.0814 1.1695
(Intercept)…2 AAPL 0.1597 0.1780 0.8974 0.8053
(Intercept)…3 PG 0.0454 0.1539 0.2947 0.0869
(Intercept)…4 CAT 0.0465 0.2456 0.1895 0.0359
(Intercept)…5 NEE 0.0072 0.2166 0.0333 0.0011
(Intercept)…6 XOM -0.0096 0.2956 -0.0325 0.0011
(Intercept)…7 JPM -0.0142 0.1877 -0.0756 0.0057
(Intercept)…8 JNJ -0.0127 0.1496 -0.0847 0.0072
(Intercept)…9 AMZN -0.0259 0.2468 -0.1051 0.0110
(Intercept)…10 DIS -0.2133 0.2360 -0.9037 0.8168

Commentary: The information ratio is crucial in Treynor-Black:

  • High positive IR: The stock offers good alpha relative to its idiosyncratic risk -> overweight
  • Negative IR: The stock underperforms for its risk -> underweight or short
  • The squared IR tells us how much each security contributes to the portfolio’s enhanced Sharpe ratio

Step 3: Construct the Active Portfolio

In the Treynor-Black model, we weight each security in the active portfolio proportionally to its alpha-to-variance ratio:

\[w_i^A = \frac{\alpha_i / \sigma_{\varepsilon,i}^2}{\sum_{j=1}^n \alpha_j / \sigma_{\varepsilon,j}^2}\]

#' Construct the Treynor-Black active portfolio
#' 
#' Weights are proportional to alpha/variance ratio
construct_active_portfolio <- function(capm_results) {
  # Calculate alpha-to-variance ratios
  alpha_var_ratios <- capm_results$Alpha / (capm_results$Sigma_e^2)
  names(alpha_var_ratios) <- capm_results$Asset
  
  # Normalize to sum to 1
  active_weights <- alpha_var_ratios / sum(alpha_var_ratios)
  
  # Calculate active portfolio characteristics
  alpha_A <- sum(active_weights * capm_results$Alpha)
  beta_A <- sum(active_weights * capm_results$Beta)
  sigma_e_A <- sqrt(sum(active_weights^2 * capm_results$Sigma_e^2))
  info_ratio_A <- alpha_A / sigma_e_A
  
  list(
    weights = active_weights,
    alpha = alpha_A,
    beta = beta_A,
    sigma_e = sigma_e_A,
    info_ratio = info_ratio_A
  )
}

# Construct active portfolio
active_portfolio <- construct_active_portfolio(capm_results)

cat("\n=== ACTIVE PORTFOLIO CONSTRUCTION ===\n\nWeights are proportional to alpha_i / sigma^2(epsilon_i)\n\n")
## 
## === ACTIVE PORTFOLIO CONSTRUCTION ===
## 
## Weights are proportional to alpha_i / sigma^2(epsilon_i)
# Display active portfolio weights
active_weights_df <- data.frame(
  Asset = names(active_portfolio$weights),
  Weight = active_portfolio$weights,
  Alpha = capm_results$Alpha,
  Alpha_to_Var = capm_results$Alpha_to_Var
) %>%
  arrange(desc(Weight))

kable(active_weights_df, digits = 4,
      col.names = c("Asset", "Weight in Active Portfolio", "Alpha", "alpha/sigma^2(epsilon)"),
      caption = "Active Portfolio Weights (Treynor-Black)") %>%
  kable_styling(bootstrap_options = c("striped", "hover")) %>%
  column_spec(2, color = ifelse(active_weights_df$Weight > 0, "green", "red"))
Active Portfolio Weights (Treynor-Black)
Asset Weight in Active Portfolio Alpha alpha/sigma^2(epsilon)
MSFT MSFT 0.7469 0.1554 7.5246
AAPL AAPL 0.5005 0.1597 5.0425
PG PG 0.1901 0.0454 1.9151
CAT CAT 0.0766 0.0465 0.7714
NEE NEE 0.0153 0.0072 0.1539
XOM XOM -0.0109 -0.0096 -0.1098
JPM JPM -0.0400 -0.0142 -0.4030
AMZN AMZN -0.0423 -0.0259 -0.4259
JNJ JNJ -0.0562 -0.0127 -0.5661
DIS DIS -0.3801 -0.2133 -3.8288
cat(paste0(
  "\n=== ACTIVE PORTFOLIO CHARACTERISTICS ===\n\n",
  sprintf("Alpha (alpha_A):              %0.4f (%0.2f%% annualized)\n", 
          active_portfolio$alpha, active_portfolio$alpha * 100),
  sprintf("Beta (beta_A):               %0.4f\n", active_portfolio$beta),
  sprintf("Idiosyncratic Vol (sigma_epsilonA): %0.4f (%0.2f%% annualized)\n", 
          active_portfolio$sigma_e, active_portfolio$sigma_e * 100),
  sprintf("Information Ratio (IR_A): %0.4f\n", active_portfolio$info_ratio)
))
## 
## === ACTIVE PORTFOLIO CHARACTERISTICS ===
## 
## Alpha (alpha_A):              0.2919 (29.19% annualized)
## Beta (beta_A):               0.7952
## Idiosyncratic Vol (sigma_epsilonA): 0.1702 (17.02% annualized)
## Information Ratio (IR_A): 1.7148

Commentary: Understanding the active portfolio:

  • Positive weights: Securities with positive alpha-to-variance ratios (we want more of these)
  • Negative weights: Securities with negative alpha (we want to short these, or avoid them)
  • The active portfolio alpha is the weighted average of individual alphas
  • The active portfolio beta tells us how much market exposure the active portfolio has
# Visualize active portfolio weights
ggplot(active_weights_df, aes(x = reorder(Asset, Weight), y = Weight, fill = Weight > 0)) +
  geom_col(alpha = 0.9) +
  geom_hline(yintercept = 0, linetype = "dashed", color = "#e0e0e0") +
  coord_flip() +
  scale_fill_manual(values = c("#ff6b6b", "#4ecdc4"), guide = "none") +
  labs(
    title = "Active Portfolio Weights (Treynor-Black Model)",
    subtitle = "Positive = Long position, Negative = Short position",
    x = "Asset",
    y = "Weight in Active Portfolio"
  ) +
  scale_y_continuous(labels = percent_format())

Step 4: Combine Active Portfolio with Market Index

Now we determine the optimal mix between the active portfolio (A) and the market index (M).

#' Calculate optimal weight in active portfolio
#' 
#' Formula: w_A* = w_A^0 / [1 + (1 - beta_A) * w_A^0]
#' where w_A^0 = (alpha_A / sigma^2_A) / (E[R_M] / sigma^2_M)
calculate_optimal_active_weight <- function(active_portfolio, market_returns, rf) {
  # Market statistics (annualized)
  market_vec <- as.numeric(coredata(market_returns))
  market_excess <- market_vec - rf
  market_return <- mean(market_excess) * 12
  market_vol <- sd(market_excess) * sqrt(12)
  
  # Active portfolio statistics
  alpha_A <- active_portfolio$alpha
  beta_A <- active_portfolio$beta
  sigma_A <- sqrt(beta_A^2 * market_vol^2 + active_portfolio$sigma_e^2)
  
  # Initial weight (before beta adjustment)
  w_A_0 <- (alpha_A / sigma_A^2) / (market_return / market_vol^2)
  
  # Adjusted weight (accounting for beta)
  w_A_star <- w_A_0 / (1 + (1 - beta_A) * w_A_0)
  
  # Market weight
  w_M_star <- 1 - w_A_star
  
  list(
    w_A_0 = w_A_0,
    w_A_star = w_A_star,
    w_M_star = w_M_star,
    market_return = market_return,
    market_vol = market_vol
  )
}

# Calculate optimal weights
optimal_weights <- calculate_optimal_active_weight(
  active_portfolio, 
  market_returns, 
  rf_rate
)

cat(paste0(
  "\n=== OPTIMAL PORTFOLIO ALLOCATION ===\n\n",
  "Step 1: Initial Active Weight (before beta adjustment)\n",
  sprintf("  w_A^0 = (alpha_A / sigma^2_A) / (E[R_M] / sigma^2_M) = %0.4f (%0.2f%%)\n\n", 
          optimal_weights$w_A_0, optimal_weights$w_A_0 * 100),
  "Step 2: Adjusted Active Weight (accounting for beta_A)\n",
  sprintf("  w_A* = w_A^0 / [1 + (1 - beta_A) x w_A^0] = %0.4f (%0.2f%%)\n\n", 
          optimal_weights$w_A_star, optimal_weights$w_A_star * 100),
  "Step 3: Market Weight\n",
  sprintf("  w_M* = 1 - w_A* = %0.4f (%0.2f%%)\n\n", 
          optimal_weights$w_M_star, optimal_weights$w_M_star * 100)
))
## 
## === OPTIMAL PORTFOLIO ALLOCATION ===
## 
## Step 1: Initial Active Weight (before beta adjustment)
##   w_A^0 = (alpha_A / sigma^2_A) / (E[R_M] / sigma^2_M) = 1.7921 (179.21%)
## 
## Step 2: Adjusted Active Weight (accounting for beta_A)
##   w_A* = w_A^0 / [1 + (1 - beta_A) x w_A^0] = 1.3110 (131.10%)
## 
## Step 3: Market Weight
##   w_M* = 1 - w_A* = -0.3110 (-31.10%)
# Calculate final weights in each individual security
final_weights <- active_portfolio$weights * optimal_weights$w_A_star
final_weights["Market"] <- optimal_weights$w_M_star

cat("\n=== FINAL PORTFOLIO WEIGHTS (All Securities + Market Index) ===\n\n")
## 
## === FINAL PORTFOLIO WEIGHTS (All Securities + Market Index) ===
final_weights_df <- data.frame(
  Asset = names(final_weights),
  Final_Weight = final_weights
) %>%
  arrange(desc(Final_Weight))

kable(final_weights_df, digits = 4,
      col.names = c("Asset", "Final Portfolio Weight"),
      caption = "Complete Treynor-Black Optimal Portfolio") %>%
  kable_styling(bootstrap_options = c("striped", "hover")) %>%
  column_spec(2, color = ifelse(final_weights_df$Final_Weight > 0, "green", "red"))
Complete Treynor-Black Optimal Portfolio
Asset Final Portfolio Weight
MSFT MSFT 0.9792
AAPL AAPL 0.6562
PG PG 0.2492
CAT CAT 0.1004
NEE NEE 0.0200
XOM XOM -0.0143
JPM JPM -0.0524
AMZN AMZN -0.0554
JNJ JNJ -0.0737
Market Market -0.3110
DIS DIS -0.4983

Commentary: The final Treynor-Black portfolio shows:

  • Market index weight: This is our passive, diversified exposure
  • Individual stock weights: These are our active bets based on alpha estimates
  • The sum of all weights equals 100% (or close to it)

Step 5: Calculate Portfolio Performance Metrics

#' Calculate the Sharpe ratio enhancement from active management
#' 
#' Key formula: SR_P^2 = SR_M^2 + Sum(alpha_i / sigma_epsiloni)^2
calculate_sharpe_enhancement <- function(capm_results, market_returns, rf) {
  # Market Sharpe ratio
  market_vec <- as.numeric(coredata(market_returns))
  market_excess <- market_vec - rf
  market_sr <- mean(market_excess) * 12 / (sd(market_excess) * sqrt(12))
  
  # Sum of squared information ratios
  sum_ir_squared <- sum(capm_results$IR_Squared)
  
  # Portfolio Sharpe ratio
  portfolio_sr_squared <- market_sr^2 + sum_ir_squared
  portfolio_sr <- sqrt(portfolio_sr_squared)
  
  list(
    market_sr = market_sr,
    sum_ir_squared = sum_ir_squared,
    portfolio_sr = portfolio_sr,
    improvement = portfolio_sr - market_sr,
    improvement_pct = (portfolio_sr / market_sr - 1) * 100
  )
}

sharpe_enhancement <- calculate_sharpe_enhancement(capm_results, market_returns, rf_rate)

cat(paste0(
  "\n=== SHARPE RATIO ENHANCEMENT FROM ACTIVE MANAGEMENT ===\n\n",
  "Formula: SR_P^2 = SR_M^2 + Sum(alpha_i / sigma_epsiloni)^2\n\n",
  sprintf("Market Sharpe Ratio (SR_M):     %0.4f\n", sharpe_enhancement$market_sr),
  sprintf("Sum of IR^2 from active stocks:  %0.4f\n", sharpe_enhancement$sum_ir_squared),
  sprintf("Portfolio Sharpe Ratio (SR_P):  %0.4f\n", sharpe_enhancement$portfolio_sr),
  sprintf("\nSharpe Ratio Improvement:       %0.4f (%0.2f%% increase)\n", 
          sharpe_enhancement$improvement, sharpe_enhancement$improvement_pct)
))
## 
## === SHARPE RATIO ENHANCEMENT FROM ACTIVE MANAGEMENT ===
## 
## Formula: SR_P^2 = SR_M^2 + Sum(alpha_i / sigma_epsiloni)^2
## 
## Market Sharpe Ratio (SR_M):     0.5964
## Sum of IR^2 from active stocks:  2.9404
## Portfolio Sharpe Ratio (SR_P):  1.8155
## 
## Sharpe Ratio Improvement:       1.2191 (204.41% increase)

Commentary: The Sharpe ratio enhancement demonstrates the value of active management:

  • The market Sharpe ratio is what you get from passive investing
  • The sum of squared IRs represents the contribution from security selection
  • The portfolio Sharpe ratio shows total risk-adjusted performance
  • This formula guarantees that optimal active management can only improve (never hurt) the Sharpe ratio
# Visualize contribution to Sharpe ratio
sr_contribution <- capm_results %>%
  select(Asset, IR_Squared) %>%
  add_row(Asset = "Market (Base)", IR_Squared = sharpe_enhancement$market_sr^2) %>%
  mutate(
    Cumulative_SR = sqrt(cumsum(IR_Squared)),
    Contribution_Type = ifelse(Asset == "Market (Base)", "Passive", "Active")
  )

ggplot(sr_contribution, aes(x = reorder(Asset, -IR_Squared), y = IR_Squared, fill = Contribution_Type)) +
  geom_col(alpha = 0.9) +
  labs(
    title = "Contribution to Portfolio Sharpe Ratio^2",
    subtitle = "Active management adds to the base market Sharpe ratio",
    x = "Asset",
    y = "Contribution to SR^2 (Information Ratio^2)",
    fill = "Type"
  ) +
  scale_fill_manual(values = c("Active" = "#00d4ff", "Passive" = "#4ecdc4")) +
  theme(axis.text.x = element_text(angle = 45, hjust = 1))


Part 3: Tracking Risk and Benchmark-Relative Performance

Understanding Tracking Error

Tracking error measures how much your portfolio deviates from the benchmark:

\[TE = R_P - R_M\]

The tracking risk is the standard deviation of tracking error:

\[\sigma(TE) = w_A^* \sqrt{(1 - \beta_A)^2 \sigma_M^2 + \sigma_{\varepsilon A}^2}\]

#' Calculate tracking risk
#' 
#' Tracking risk measures how much the portfolio deviates from the benchmark
calculate_tracking_risk <- function(active_portfolio, market_returns, 
                                     active_weight, rf) {
  # Market volatility (annualized)
  market_vec <- as.numeric(coredata(market_returns))
  market_vol <- sd(market_vec - rf) * sqrt(12)
  
  # Components of tracking risk
  beta_A <- active_portfolio$beta
  sigma_e_A <- active_portfolio$sigma_e
  
  # Tracking risk formula
  tracking_risk <- active_weight * sqrt((1 - beta_A)^2 * market_vol^2 + sigma_e_A^2)
  
  # If we put 100% in active portfolio
  tracking_risk_full <- sqrt((1 - beta_A)^2 * market_vol^2 + sigma_e_A^2)
  
  list(
    tracking_risk = tracking_risk,
    tracking_risk_full = tracking_risk_full,
    beta_component = (1 - beta_A)^2 * market_vol^2,
    idiosyncratic_component = sigma_e_A^2
  )
}

tracking <- calculate_tracking_risk(
  active_portfolio, 
  market_returns, 
  optimal_weights$w_A_star, 
  rf_rate
)

cat(paste0(
  "\n=== TRACKING RISK ANALYSIS ===\n\n",
  "Tracking Error: TE = R_P - R_M\n",
  "Tracking Risk: sigma(TE) = w_A x sqrt[(1-beta_A)^2 x sigma_M^2 + sigma^2_epsilonA]\n\n",
  sprintf("Active Portfolio Beta (beta_A):         %0.4f\n", active_portfolio$beta),
  sprintf("Active Portfolio sigma_epsilonA:               %0.4f (%0.2f%%)\n", 
          active_portfolio$sigma_e, active_portfolio$sigma_e * 100),
  sprintf("Optimal Active Weight (w_A*):        %0.4f (%0.2f%%)\n\n", 
          optimal_weights$w_A_star, optimal_weights$w_A_star * 100),
  sprintf("Beta Contribution to Variance:       %0.6f\n", tracking$beta_component),
  sprintf("Idiosyncratic Contribution:          %0.6f\n", tracking$idiosyncratic_component),
  sprintf("\nTracking Risk (Current Allocation):  %0.4f (%0.2f%% annualized)\n", 
          tracking$tracking_risk, tracking$tracking_risk * 100),
  sprintf("Tracking Risk (100%% Active):         %0.4f (%0.2f%% annualized)\n", 
          tracking$tracking_risk_full, tracking$tracking_risk_full * 100)
))
## 
## === TRACKING RISK ANALYSIS ===
## 
## Tracking Error: TE = R_P - R_M
## Tracking Risk: sigma(TE) = w_A x sqrt[(1-beta_A)^2 x sigma_M^2 + sigma^2_epsilonA]
## 
## Active Portfolio Beta (beta_A):         0.7952
## Active Portfolio sigma_epsilonA:               0.1702 (17.02%)
## Optimal Active Weight (w_A*):        1.3110 (131.10%)
## 
## Beta Contribution to Variance:       0.001475
## Idiosyncratic Contribution:          0.028973
## 
## Tracking Risk (Current Allocation):  0.2288 (22.88% annualized)
## Tracking Risk (100% Active):         0.1745 (17.45% annualized)

Commentary: Tracking risk tells us:

  • Beta component: If beta_A != 1, market movements themselves create tracking error
  • Idiosyncratic component: Firm-specific risks that cause deviation from benchmark
  • Current tracking risk: Based on our optimal allocation
  • Full active tracking risk: What we’d have if we put 100% in the active portfolio

Setting Tracking Risk Constraints

In practice, portfolio managers often face tracking risk limits:

#' Calculate maximum active weight given tracking risk constraint
#' 
#' w_A(TE) = sigma*(TE) / sqrt[(1-beta_A)^2 x sigma_M^2 + sigma^2_epsilonA]
max_active_weight <- function(target_tracking_risk, active_portfolio, 
                               market_returns, rf) {
  market_vec <- as.numeric(coredata(market_returns))
  market_vol <- sd(market_vec - rf) * sqrt(12)
  beta_A <- active_portfolio$beta
  sigma_e_A <- active_portfolio$sigma_e
  
  denominator <- sqrt((1 - beta_A)^2 * market_vol^2 + sigma_e_A^2)
  target_tracking_risk / denominator
}

# Calculate for various tracking risk targets
tracking_targets <- seq(0.01, 0.10, by = 0.01)

tracking_analysis <- map_dfr(tracking_targets, function(target) {
  max_w_A <- max_active_weight(target, active_portfolio, market_returns, rf_rate)
  
  data.frame(
    Target_Tracking_Risk = target,
    Max_Active_Weight = min(max_w_A, 1),  # Cap at 100%
    Market_Weight = max(1 - max_w_A, 0)
  )
})

cat("\n=== TRACKING RISK CONSTRAINT ANALYSIS ===\n\nMaximum Active Weight = Target sigma(TE) / sqrt[(1-beta_A)^2 x sigma_M^2 + sigma^2_epsilonA]\n\n")
## 
## === TRACKING RISK CONSTRAINT ANALYSIS ===
## 
## Maximum Active Weight = Target sigma(TE) / sqrt[(1-beta_A)^2 x sigma_M^2 + sigma^2_epsilonA]
kable(tracking_analysis, digits = 4,
      col.names = c("Target Tracking Risk", "Max Active Weight", "Min Market Weight"),
      caption = "Maximum Active Portfolio Weight Under Different Tracking Risk Limits") %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
Maximum Active Portfolio Weight Under Different Tracking Risk Limits
Target Tracking Risk Max Active Weight Min Market Weight
0.01 0.0573 0.9427
0.02 0.1146 0.8854
0.03 0.1719 0.8281
0.04 0.2292 0.7708
0.05 0.2865 0.7135
0.06 0.3438 0.6562
0.07 0.4012 0.5988
0.08 0.4585 0.5415
0.09 0.5158 0.4842
0.10 0.5731 0.4269
# Visualize the trade-off
ggplot(tracking_analysis, aes(x = Target_Tracking_Risk)) +
  geom_line(aes(y = Max_Active_Weight, color = "Active Portfolio"), linewidth = 1.2) +
  geom_line(aes(y = Market_Weight, color = "Market Index"), linewidth = 1.2) +
  geom_point(aes(y = Max_Active_Weight), size = 2, color = "#00d4ff") +
  geom_point(aes(y = Market_Weight), size = 2, color = "#4ecdc4") +
  geom_vline(xintercept = tracking$tracking_risk, linetype = "dashed", color = "#ff6b6b") +
  annotate("text", x = tracking$tracking_risk + 0.005, y = 0.5, 
           label = "Current\nOptimal", color = "#ff6b6b", size = 3) +
  labs(
    title = "Portfolio Allocation Under Tracking Risk Constraints",
    subtitle = "Higher tracking risk tolerance allows more active management",
    x = "Target Tracking Risk (Annualized)",
    y = "Portfolio Weight",
    color = "Allocation"
  ) +
  scale_x_continuous(labels = percent_format()) +
  scale_y_continuous(labels = percent_format()) +
  scale_color_manual(values = c("Active Portfolio" = "#00d4ff", 
                                "Market Index" = "#4ecdc4"))

Commentary: This chart shows the fundamental trade-off:

  • Tight tracking risk constraint (left): Forces more market index allocation (closet indexing)
  • Loose tracking risk constraint (right): Allows more active portfolio allocation
  • The red dashed line shows our current optimal allocation
  • Fund managers must balance potential outperformance against career risk of significant underperformance

Part 4: Forecast Precision and Alpha Adjustment

The Problem of Overconfident Forecasts

Analysts often overestimate the magnitude of their alpha predictions. Historical backtesting can help adjust forecasts.

#' Simulate an analyst's forecasting track record
#' 
#' We'll create synthetic data showing how forecasts compare to actual outcomes
set.seed(123)

# Simulate 40 quarters (10 years) of forecasting data
n_quarters <- 40

# True relationship: Actual = 0.005 + 0.4 x Forecast + noise
# This means the analyst's forecasts are inflated (only 40% materializes)
true_intercept <- 0.005  # Small positive bias
true_slope <- 0.4        # Forecasts are overconfident (60% shrinkage needed)
noise_sd <- 0.02         # Estimation error

# Analyst's raw forecasts (they tend to be overconfident)
analyst_forecasts <- rnorm(n_quarters, mean = 0.08, sd = 0.05)

# Actual realized abnormal returns
actual_returns <- true_intercept + true_slope * analyst_forecasts + 
                  rnorm(n_quarters, mean = 0, sd = noise_sd)

# Create data frame
forecast_data <- data.frame(
  Quarter = 1:n_quarters,
  Year = rep(2014:2023, each = 4),
  Q = rep(paste0("Q", 1:4), 10),
  Forecast = analyst_forecasts,
  Actual = actual_returns
)

cat(paste0(
  "\n=== ANALYST FORECASTING TRACK RECORD ===\n\n",
  "We've simulated 40 quarters of alpha forecasts and actual outcomes.\n",
  "Let's assess how accurate this analyst's forecasts have been.\n\n",
  "Forecast Statistics:\n",
  sprintf("  Mean Forecast:    %0.4f (%0.2f%%)\n", 
          mean(forecast_data$Forecast), mean(forecast_data$Forecast) * 100),
  sprintf("  Mean Actual:      %0.4f (%0.2f%%)\n", 
          mean(forecast_data$Actual), mean(forecast_data$Actual) * 100),
  sprintf("  Forecast Std Dev: %0.4f\n", sd(forecast_data$Forecast)),
  sprintf("  Actual Std Dev:   %0.4f\n\n", sd(forecast_data$Actual))
))
## 
## === ANALYST FORECASTING TRACK RECORD ===
## 
## We've simulated 40 quarters of alpha forecasts and actual outcomes.
## Let's assess how accurate this analyst's forecasts have been.
## 
## Forecast Statistics:
##   Mean Forecast:    0.0823 (8.23%)
##   Mean Actual:      0.0378 (3.78%)
##   Forecast Std Dev: 0.0449
##   Actual Std Dev:   0.0270
# Display recent data
kable(tail(forecast_data, 12), digits = 4,
      caption = "Last 12 Quarters of Forecasting Data") %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
Last 12 Quarters of Forecasting Data
Quarter Year Q Forecast Actual
29 29 2021 Q1 0.0231 0.0327
30 30 2021 Q2 0.1427 0.1031
31 31 2021 Q3 0.1013 0.0357
32 32 2021 Q4 0.0652 -0.0151
33 33 2022 Q1 0.1248 0.0750
34 34 2022 Q2 0.1239 0.0404
35 35 2022 Q3 0.1211 0.0397
36 36 2022 Q4 0.1144 0.0713
37 37 2023 Q1 0.1077 0.0424
38 38 2023 Q2 0.0769 0.0113
39 39 2023 Q3 0.0647 0.0345
40 40 2023 Q4 0.0610 0.0266

Evaluating Forecast Accuracy

We regress actual outcomes on forecasts to assess the analyst’s skill:

\[u_t = a + b \times \alpha_t^f + \varepsilon_t\]

# Run the accuracy regression
accuracy_model <- lm(Actual ~ Forecast, data = forecast_data)

cat("\n=== FORECAST ACCURACY REGRESSION ===\n\nModel: Actual Return = a + b x Forecast + epsilon\n\n")
## 
## === FORECAST ACCURACY REGRESSION ===
## 
## Model: Actual Return = a + b x Forecast + epsilon
summary(accuracy_model)
## 
## Call:
## lm(formula = Actual ~ Forecast, data = forecast_data)
## 
## Residuals:
##       Min        1Q    Median        3Q       Max 
## -0.045669 -0.010855 -0.001053  0.010246  0.043485 
## 
## Coefficients:
##             Estimate Std. Error t value Pr(>|t|)    
## (Intercept) 0.003028   0.006472   0.468    0.643    
## Forecast    0.422342   0.069267   6.097 4.19e-07 ***
## ---
## Signif. codes:  0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
## 
## Residual standard error: 0.01942 on 38 degrees of freedom
## Multiple R-squared:  0.4945, Adjusted R-squared:  0.4812 
## F-statistic: 37.18 on 1 and 38 DF,  p-value: 4.186e-07
# Extract coefficients
intercept <- coef(accuracy_model)[1]
slope <- coef(accuracy_model)[2]
r_squared <- summary(accuracy_model)$r.squared

bias_type <- ifelse(intercept > 0, "positive", "negative")

cat(paste0(
  "\n=== INTERPRETATION ===\n\n",
  sprintf("Intercept (a):  %0.4f\n", intercept),
  sprintf("  -> The analyst has a slight %s bias\n\n", bias_type),
  sprintf("Slope (b):      %0.4f\n", slope),
  sprintf("  -> Only %.0f%% of the analyst's forecast typically materializes\n", slope * 100),
  sprintf("  -> The analyst is OVERCONFIDENT by %.0f%%\n\n", (1 - slope) * 100),
  sprintf("R-squared:      %0.4f\n", r_squared),
  sprintf("  -> The forecast explains %.0f%% of variation in actual outcomes\n", r_squared * 100)
))
## 
## === INTERPRETATION ===
## 
## Intercept (a):  0.0030
##   -> The analyst has a slight positive bias
## 
## Slope (b):      0.4223
##   -> Only 42% of the analyst's forecast typically materializes
##   -> The analyst is OVERCONFIDENT by 58%
## 
## R-squared:      0.4945
##   -> The forecast explains 49% of variation in actual outcomes
# Visualize forecast vs actual
# Using base geom_smooth without message
forecast_plot <- ggplot(forecast_data, aes(x = Forecast, y = Actual)) +
  geom_point(alpha = 0.7, size = 3, color = "#00d4ff") +
  geom_abline(intercept = intercept, slope = slope, color = "#ff6b6b", linewidth = 1) +
  geom_ribbon(aes(ymin = predict(accuracy_model, interval = "confidence")[,2],
                  ymax = predict(accuracy_model, interval = "confidence")[,3]),
              alpha = 0.2, fill = "#ff6b6b") +
  geom_abline(intercept = 0, slope = 1, linetype = "dashed", color = "#4ecdc4", linewidth = 0.8) +
  annotate("text", x = 0.15, y = 0.15, label = "Perfect Forecast\n(45 degree line)", 
           color = "#4ecdc4", size = 3) +
  annotate("text", x = 0.15, y = slope * 0.15 + intercept + 0.02, 
           label = "Actual Relationship", color = "#ff6b6b", size = 3) +
  labs(
    title = "Analyst Forecast Accuracy Assessment",
    subtitle = paste0("Actual = ", round(intercept, 3), " + ", round(slope, 3), 
                      " x Forecast (R-squared = ", round(r_squared, 3), ")"),
    x = "Analyst's Alpha Forecast",
    y = "Actual Realized Abnormal Return"
  ) +
  scale_x_continuous(labels = percent_format()) +
  scale_y_continuous(labels = percent_format())

print(forecast_plot)

Commentary: This chart is crucial for understanding forecast adjustment:

  • Blue dashed line (45 degree): What perfect forecasting would look like (Forecast = Actual)
  • Red line: The actual relationship between forecasts and outcomes
  • The gap between the lines shows the analyst’s overconfidence
  • Points should scatter around the red line with some noise

Adjusting Future Forecasts

Now when the analyst issues a new forecast, we shrink it based on historical accuracy:

\[\alpha_{adjusted} = a + b \times \alpha_{forecast}\]

#' Adjust a new forecast based on historical accuracy
#' 
#' This is the BLUE (Best Linear Unbiased Estimator) of the true alpha
adjust_forecast <- function(raw_forecast, accuracy_model) {
  intercept <- coef(accuracy_model)[1]
  slope <- coef(accuracy_model)[2]
  
  adjusted <- intercept + slope * raw_forecast
  
  list(
    raw = raw_forecast,
    adjusted = adjusted,
    shrinkage = (raw_forecast - adjusted) / raw_forecast * 100
  )
}

# Example: Analyst forecasts 20% alpha for a new stock
new_forecast <- 0.20

adjusted <- adjust_forecast(new_forecast, accuracy_model)

cat(paste0(
  "\n=== ADJUSTING A NEW FORECAST ===\n\n",
  sprintf("Analyst's Raw Forecast: %0.4f (%0.2f%%)\n", 
          adjusted$raw, adjusted$raw * 100),
  sprintf("Adjusted Forecast:      %0.4f (%0.2f%%)\n", 
          adjusted$adjusted, adjusted$adjusted * 100),
  sprintf("Shrinkage Applied:      %0.1f%%\n\n", adjusted$shrinkage),
  "The adjusted forecast accounts for the analyst's historical tendency\n",
  "to overestimate alphas. This is statistically optimal (BLUE).\n"
))
## 
## === ADJUSTING A NEW FORECAST ===
## 
## Analyst's Raw Forecast: 0.2000 (20.00%)
## Adjusted Forecast:      0.0875 (8.75%)
## Shrinkage Applied:      56.3%
## 
## The adjusted forecast accounts for the analyst's historical tendency
## to overestimate alphas. This is statistically optimal (BLUE).
# Show shrinkage for various forecast levels
forecast_levels <- seq(0.05, 0.30, by = 0.05)

shrinkage_table <- map_dfr(forecast_levels, function(f) {
  adj <- adjust_forecast(f, accuracy_model)
  data.frame(
    Raw_Forecast = f,
    Adjusted_Forecast = adj$adjusted,
    Shrinkage_Pct = adj$shrinkage
  )
})

cat("\n=== SHRINKAGE TABLE ===\nHow different forecast levels are adjusted:\n\n")
## 
## === SHRINKAGE TABLE ===
## How different forecast levels are adjusted:
kable(shrinkage_table, digits = 4,
      col.names = c("Raw Forecast", "Adjusted Forecast", "Shrinkage %"),
      caption = "Forecast Adjustment at Different Levels") %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
Forecast Adjustment at Different Levels
Raw Forecast Adjusted Forecast Shrinkage %
(Intercept)…1 0.05 0.0241 51.7101
(Intercept)…2 0.10 0.0453 54.7380
(Intercept)…3 0.15 0.0664 55.7472
(Intercept)…4 0.20 0.0875 56.2519
(Intercept)…5 0.25 0.1086 56.5547
(Intercept)…6 0.30 0.1297 56.7565

Commentary: The shrinkage adjustment is important because:

  • Higher forecasts are shrunk more (in absolute terms)
  • The adjustment is based on the analyst’s actual track record
  • Using adjusted forecasts leads to more realistic portfolio weights
  • This prevents the optimizer from overreacting to overconfident predictions

Part 5: Putting It All Together - Complete Treynor-Black Implementation

Complete Implementation with Adjusted Alphas

#' Complete Treynor-Black implementation with all adjustments
#' 
#' This function implements the full model including:
#' 1. CAPM regression for alpha/beta estimation
#' 2. Alpha adjustment for forecast precision
#' 3. Active portfolio construction
#' 4. Optimal combination with market index
#' 5. Tracking risk calculation
complete_treynor_black <- function(stock_returns, market_returns, rf, 
                                    alpha_shrinkage = 0.5,  # Default 50% shrinkage
                                    tracking_risk_limit = NULL) {
  
  # Step 1: Run CAPM regressions
  capm_results <- run_capm_regressions(stock_returns, market_returns, rf)
  
  # Step 2: Apply alpha shrinkage (simulating forecast adjustment)
  capm_results$Alpha_Adjusted <- capm_results$Alpha * alpha_shrinkage
  capm_results$Info_Ratio_Adj <- capm_results$Alpha_Adjusted / capm_results$Sigma_e
  capm_results$Alpha_to_Var_Adj <- capm_results$Alpha_Adjusted / (capm_results$Sigma_e^2)
  
  # Step 3: Construct active portfolio with adjusted alphas
  alpha_var_ratios <- capm_results$Alpha_to_Var_Adj
  names(alpha_var_ratios) <- capm_results$Asset
  active_weights <- alpha_var_ratios / sum(alpha_var_ratios)
  
  alpha_A <- sum(active_weights * capm_results$Alpha_Adjusted)
  beta_A <- sum(active_weights * capm_results$Beta)
  sigma_e_A <- sqrt(sum(active_weights^2 * capm_results$Sigma_e^2))
  
  # Step 4: Calculate optimal active weight
  market_vec <- as.numeric(coredata(market_returns))
  market_excess <- market_vec - rf
  market_return <- mean(market_excess) * 12
  market_vol <- sd(market_excess) * sqrt(12)
  
  sigma_A <- sqrt(beta_A^2 * market_vol^2 + sigma_e_A^2)
  w_A_0 <- (alpha_A / sigma_A^2) / (market_return / market_vol^2)
  w_A_star <- w_A_0 / (1 + (1 - beta_A) * w_A_0)
  
  # Step 5: Apply tracking risk constraint if specified
  if (!is.null(tracking_risk_limit)) {
    tracking_denominator <- sqrt((1 - beta_A)^2 * market_vol^2 + sigma_e_A^2)
    max_w_A <- tracking_risk_limit / tracking_denominator
    w_A_star <- min(w_A_star, max_w_A)
  }
  
  w_M_star <- 1 - w_A_star
  
  # Final portfolio weights
  final_weights <- c(active_weights * w_A_star, Market = w_M_star)
  
  # Portfolio statistics
  tracking_risk <- w_A_star * sqrt((1 - beta_A)^2 * market_vol^2 + sigma_e_A^2)
  
  market_sr <- market_return / market_vol
  sum_ir_sq <- sum(capm_results$Info_Ratio_Adj^2)
  portfolio_sr <- sqrt(market_sr^2 + sum_ir_sq)
  
  list(
    capm_results = capm_results,
    active_weights = active_weights,
    active_alpha = alpha_A,
    active_beta = beta_A,
    active_sigma_e = sigma_e_A,
    w_A_star = w_A_star,
    w_M_star = w_M_star,
    final_weights = final_weights,
    tracking_risk = tracking_risk,
    market_sharpe = market_sr,
    portfolio_sharpe = portfolio_sr,
    sharpe_improvement = portfolio_sr - market_sr
  )
}

Compare Different Scenarios

# Scenario 1: No alpha adjustment, no tracking risk constraint
scenario_1 <- complete_treynor_black(
  stock_returns, market_returns, rf_rate,
  alpha_shrinkage = 1.0,  # Use raw alphas
  tracking_risk_limit = NULL
)

# Scenario 2: 50% alpha shrinkage, no tracking risk constraint  
scenario_2 <- complete_treynor_black(
  stock_returns, market_returns, rf_rate,
  alpha_shrinkage = 0.5,
  tracking_risk_limit = NULL
)

# Scenario 3: 50% alpha shrinkage with 5% tracking risk limit
scenario_3 <- complete_treynor_black(
  stock_returns, market_returns, rf_rate,
  alpha_shrinkage = 0.5,
  tracking_risk_limit = 0.05
)

# Scenario 4: 50% alpha shrinkage with 2% tracking risk limit (very constrained)
scenario_4 <- complete_treynor_black(
  stock_returns, market_returns, rf_rate,
  alpha_shrinkage = 0.5,
  tracking_risk_limit = 0.02
)

# Compare scenarios
scenario_comparison <- data.frame(
  Scenario = c("Raw Alphas, No TE Limit",
               "50% Shrinkage, No TE Limit",
               "50% Shrinkage, 5% TE Limit",
               "50% Shrinkage, 2% TE Limit"),
  Active_Weight = c(scenario_1$w_A_star, scenario_2$w_A_star, 
                    scenario_3$w_A_star, scenario_4$w_A_star),
  Market_Weight = c(scenario_1$w_M_star, scenario_2$w_M_star,
                    scenario_3$w_M_star, scenario_4$w_M_star),
  Tracking_Risk = c(scenario_1$tracking_risk, scenario_2$tracking_risk,
                    scenario_3$tracking_risk, scenario_4$tracking_risk),
  Portfolio_SR = c(scenario_1$portfolio_sharpe, scenario_2$portfolio_sharpe,
                   scenario_3$portfolio_sharpe, scenario_4$portfolio_sharpe),
  SR_Improvement = c(scenario_1$sharpe_improvement, scenario_2$sharpe_improvement,
                     scenario_3$sharpe_improvement, scenario_4$sharpe_improvement)
)

cat("\n=== SCENARIO COMPARISON ===\n\n")
## 
## === SCENARIO COMPARISON ===
kable(scenario_comparison, digits = 4,
      col.names = c("Scenario", "Active Weight", "Market Weight", 
                    "Tracking Risk", "Portfolio SR", "SR Improvement"),
      caption = "Comparison of Different Treynor-Black Implementation Scenarios") %>%
  kable_styling(bootstrap_options = c("striped", "hover"))
Comparison of Different Treynor-Black Implementation Scenarios
Scenario Active Weight Market Weight Tracking Risk Portfolio SR SR Improvement
Raw Alphas, No TE Limit 1.3110 -0.3110 0.2288 1.8155 1.2191
50% Shrinkage, No TE Limit 0.7571 0.2429 0.1321 1.0444 0.4480
50% Shrinkage, 5% TE Limit 0.2865 0.7135 0.0500 1.0444 0.4480
50% Shrinkage, 2% TE Limit 0.1146 0.8854 0.0200 1.0444 0.4480

Commentary: The scenario analysis reveals important trade-offs:

  • Raw alphas lead to more aggressive active positions (potentially unrealistic)
  • Alpha shrinkage reduces active weights and tracking risk
  • Tracking risk constraints force more conservative allocations
  • Sharpe ratio improvement decreases as constraints tighten (trade-off between risk control and performance)
# Visualize scenario comparison
scenario_long <- scenario_comparison %>%
  pivot_longer(cols = c(Active_Weight, Market_Weight),
               names_to = "Allocation_Type",
               values_to = "Weight") %>%
  mutate(Allocation_Type = ifelse(Allocation_Type == "Active_Weight", 
                                   "Active Portfolio", "Market Index"))

ggplot(scenario_long, aes(x = Scenario, y = Weight, fill = Allocation_Type)) +
  geom_col(position = "stack", alpha = 0.9) +
  geom_text(aes(label = sprintf("%.1f%%", Weight * 100)), 
            position = position_stack(vjust = 0.5), size = 3, color = "#1a1a2e") +
  coord_flip() +
  labs(
    title = "Portfolio Allocation Under Different Scenarios",
    subtitle = "How alpha adjustment and tracking risk limits affect allocation",
    x = "",
    y = "Portfolio Weight",
    fill = "Allocation"
  ) +
  scale_y_continuous(labels = percent_format()) +
  scale_fill_manual(values = c("Active Portfolio" = "#00d4ff", 
                               "Market Index" = "#4ecdc4"))

Final Portfolio Summary

# Use Scenario 2 as our recommended portfolio
recommended <- scenario_2

# Build the output as a single string
output <- paste0(
  "\n",
  "```\n",
  "+================================================================+\n",
  "|         RECOMMENDED TREYNOR-BLACK PORTFOLIO                   |\n",
  "|         (50% Alpha Shrinkage, No Tracking Risk Limit)         |\n",
  "+================================================================+\n\n",
  "ALLOCATION SUMMARY\n",
  "------------------------------------------------------------------\n",
  sprintf("Active Portfolio Weight:    %6.2f%%\n", recommended$w_A_star * 100),
  sprintf("Market Index Weight:        %6.2f%%\n", recommended$w_M_star * 100),
  "------------------------------------------------------------------\n\n",
  "PERFORMANCE METRICS\n",
  "------------------------------------------------------------------\n",
  sprintf("Market Sharpe Ratio:        %6.4f\n", recommended$market_sharpe),
  sprintf("Portfolio Sharpe Ratio:     %6.4f\n", recommended$portfolio_sharpe),
  sprintf("Sharpe Ratio Improvement:   %6.4f\n", recommended$sharpe_improvement),
  sprintf("Tracking Risk:              %6.2f%%\n", recommended$tracking_risk * 100),
  "------------------------------------------------------------------\n\n",
  "ACTIVE PORTFOLIO CHARACTERISTICS\n",
  "------------------------------------------------------------------\n",
  sprintf("Alpha (Adjusted):           %6.4f (%0.2f%%)\n", 
          recommended$active_alpha, recommended$active_alpha * 100),
  sprintf("Beta:                       %6.4f\n", recommended$active_beta),
  sprintf("Idiosyncratic Vol:          %6.4f (%0.2f%%)\n", 
          recommended$active_sigma_e, recommended$active_sigma_e * 100),
  "------------------------------------------------------------------\n\n",
  "FINAL PORTFOLIO WEIGHTS\n",
  "------------------------------------------------------------------\n"
)

# Add portfolio weights
final_weights_sorted <- sort(recommended$final_weights, decreasing = TRUE)
for (i in seq_along(final_weights_sorted)) {
  asset <- names(final_weights_sorted)[i]
  weight <- final_weights_sorted[i]
  bar <- paste(rep("#", max(1, round(abs(weight) * 50))), collapse = "")
  if (weight >= 0) {
    output <- paste0(output, sprintf("%-8s %7.2f%% %s\n", asset, weight * 100, bar))
  } else {
    output <- paste0(output, sprintf("%-8s %7.2f%% %s (SHORT)\n", asset, weight * 100, bar))
  }
}
output <- paste0(output, "------------------------------------------------------------------\n", "```\n")

cat(output)
+================================================================+
|         RECOMMENDED TREYNOR-BLACK PORTFOLIO                   |
|         (50% Alpha Shrinkage, No Tracking Risk Limit)         |
+================================================================+

ALLOCATION SUMMARY
------------------------------------------------------------------
Active Portfolio Weight:     75.71%
Market Index Weight:         24.29%
------------------------------------------------------------------

PERFORMANCE METRICS
------------------------------------------------------------------
Market Sharpe Ratio:        0.5964
Portfolio Sharpe Ratio:     1.0444
Sharpe Ratio Improvement:   0.4480
Tracking Risk:               13.21%
------------------------------------------------------------------

ACTIVE PORTFOLIO CHARACTERISTICS
------------------------------------------------------------------
Alpha (Adjusted):           0.1459 (14.59%)
Beta:                       0.7952
Idiosyncratic Vol:          0.1702 (17.02%)
------------------------------------------------------------------

FINAL PORTFOLIO WEIGHTS
------------------------------------------------------------------
MSFT       56.55% ############################
AAPL       37.90% ###################
Market     24.29% ############
PG         14.39% #######
CAT         5.80% ###
NEE         1.16% #
XOM        -0.83% # (SHORT)
JPM        -3.03% ## (SHORT)
AMZN       -3.20% ## (SHORT)
JNJ        -4.25% ## (SHORT)
DIS       -28.78% ############## (SHORT)
------------------------------------------------------------------

Conclusion and Key Takeaways

Summary of Concepts Covered

output <- paste0(
  "\n```\n",
  "+================================================================+\n",
  "|              KEY TAKEAWAYS FROM CHAPTER 9                      |\n",
  "+================================================================+\n\n",
  "1. SENSITIVITY PROBLEM\n",
  "   - Mean-variance optimization is highly sensitive to expected returns\n",
  "   - Small changes in inputs cause large changes in optimal weights\n",
  "   - This makes pure Markowitz optimization impractical\n\n",
  "2. TREYNOR-BLACK MODEL\n",
  "   - Combines active stock picking with passive index investing\n",
  "   - Weight each security by alpha/sigma^2(epsilon) ratio\n",
  "   - Optimal mix maximizes Sharpe ratio: SR^2_P = SR^2_M + Sum(alpha/sigma_epsilon)^2\n\n",
  "3. TRACKING RISK\n",
  "   - Measures deviation from benchmark (TE = R_P - R_M)\n",
  "   - Managers often constrained by tracking risk limits\n",
  "   - Trade-off: Lower tracking risk means less active management\n\n",
  "4. FORECAST PRECISION\n",
  "   - Analysts tend to be overconfident in alpha forecasts\n",
  "   - Adjust forecasts using historical accuracy: alpha_adj = a + b x alpha_raw\n",
  "   - Shrinkage leads to more realistic portfolio allocations\n\n",
  "5. PRACTICAL IMPLEMENTATION\n",
  "   - Always apply alpha shrinkage (50-60% is common)\n",
  "   - Consider tracking risk constraints if benchmark-relative\n",
  "   - The market index provides diversification and reduces extreme positions\n",
  "```\n"
)

cat(output)
+================================================================+
|              KEY TAKEAWAYS FROM CHAPTER 9                      |
+================================================================+

1. SENSITIVITY PROBLEM
   - Mean-variance optimization is highly sensitive to expected returns
   - Small changes in inputs cause large changes in optimal weights
   - This makes pure Markowitz optimization impractical

2. TREYNOR-BLACK MODEL
   - Combines active stock picking with passive index investing
   - Weight each security by alpha/sigma^2(epsilon) ratio
   - Optimal mix maximizes Sharpe ratio: SR^2_P = SR^2_M + Sum(alpha/sigma_epsilon)^2

3. TRACKING RISK
   - Measures deviation from benchmark (TE = R_P - R_M)
   - Managers often constrained by tracking risk limits
   - Trade-off: Lower tracking risk means less active management

4. FORECAST PRECISION
   - Analysts tend to be overconfident in alpha forecasts
   - Adjust forecasts using historical accuracy: alpha_adj = a + b x alpha_raw
   - Shrinkage leads to more realistic portfolio allocations

5. PRACTICAL IMPLEMENTATION
   - Always apply alpha shrinkage (50-60% is common)
   - Consider tracking risk constraints if benchmark-relative
   - The market index provides diversification and reduces extreme positions

Session Information

sessionInfo()
## R version 4.4.3 (2025-02-28 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26100)
## 
## Matrix products: default
## 
## 
## locale:
## [1] C
## system code page: 65001
## 
## time zone: Asia/Shanghai
## tzcode source: internal
## 
## attached base packages:
## [1] stats     graphics  grDevices datasets  utils     methods   base     
## 
## other attached packages:
##  [1] rmdformats_1.0.4           corrplot_0.95             
##  [3] kableExtra_1.4.0           knitr_1.50                
##  [5] quadprog_1.5-8             PerformanceAnalytics_2.0.8
##  [7] quantmod_0.4.28            TTR_0.24.4                
##  [9] xts_0.14.1                 zoo_1.8-14                
## [11] scales_1.4.0               lubridate_1.9.4           
## [13] forcats_1.0.1              stringr_1.6.0             
## [15] dplyr_1.1.4                purrr_1.2.0               
## [17] readr_2.1.6                tidyr_1.3.1               
## [19] tibble_3.3.0               tidyverse_2.0.0           
## [21] ggplot2_4.0.1             
## 
## loaded via a namespace (and not attached):
##  [1] sass_0.4.10        generics_0.1.4     renv_1.0.7         xml2_1.5.1        
##  [5] stringi_1.8.7      lattice_0.22-6     hms_1.1.4          digest_0.6.39     
##  [9] magrittr_2.0.4     evaluate_1.0.5     grid_4.4.3         timechange_0.3.0  
## [13] RColorBrewer_1.1-3 bookdown_0.46      fastmap_1.2.0      jsonlite_2.0.0    
## [17] viridisLite_0.4.2  textshaping_1.0.4  jquerylib_0.1.4    cli_3.6.5         
## [21] rlang_1.1.6        withr_3.0.2        cachem_1.1.0       yaml_2.3.11       
## [25] tools_4.4.3        tzdb_0.5.0         curl_7.0.0         vctrs_0.6.5       
## [29] R6_2.6.1           lifecycle_1.0.4    pkgconfig_2.0.3    pillar_1.11.1     
## [33] bslib_0.9.0        gtable_0.3.6       glue_1.8.0         systemfonts_1.3.1 
## [37] xfun_0.54          tidyselect_1.2.1   rstudioapi_0.17.1  farver_2.1.2      
## [41] htmltools_0.5.9    labeling_0.4.3     svglite_2.2.2      rmarkdown_2.30    
## [45] compiler_4.4.3     S7_0.2.1

This workbook was created as a practical companion to Chapter 9: Optimal Portfolio Construction. All examples use real market data and demonstrate the full implementation of the Treynor-Black model with practical adjustments for forecast precision and tracking risk constraints.