Given:
alpha_hat <- 0.0017
se_alpha <- 0.0020
beta_hat <- 0.98
se_beta <- 0.17
R2 <- 0.50
mkt_premium <- 0.0070 # E[R_m - R_f] monthly
t_crit <- 1.98
t_beta <- beta_hat / se_beta
cat("t-statistic for beta:", round(t_beta, 4), "\n")
## t-statistic for beta: 5.765
cat("Critical value: ±", t_crit, "\n")
## Critical value: ± 1.98
cat("Reject H0 (beta = 0)?", abs(t_beta) > t_crit, "\n")
## Reject H0 (beta = 0)? TRUE
Formula: \(t_\beta = \hat{\beta} / SE(\hat{\beta}) = 0.98 / 0.17\)
Interpretation: β measures the fund’s sensitivity to market movements (systematic risk). A β of 0.98 means the fund moves approximately 1-for-1 with the market. Since |t| >> 1.98, we reject H₀; β is highly statistically significant.
t_beta1 <- (beta_hat - 1) / se_beta
cat("t-statistic for H0: beta = 1:", round(t_beta1, 4), "\n")
## t-statistic for H0: beta = 1: -0.1176
cat("Reject H0 (beta = 1)?", abs(t_beta1) > t_crit, "\n")
## Reject H0 (beta = 1)? FALSE
Formula: \(t = (\hat{\beta} - 1) / SE(\hat{\beta}) = (0.98 - 1) / 0.17\)
Interpretation: We fail to reject H₀: β = 1. The fund’s systematic risk is statistically indistinguishable from that of the market — it has average market risk.
t_alpha <- alpha_hat / se_alpha
cat("t-statistic for alpha:", round(t_alpha, 4), "\n")
## t-statistic for alpha: 0.85
cat("Reject H0 (alpha = 0)?", abs(t_alpha) > t_crit, "\n")
## Reject H0 (alpha = 0)? FALSE
Formula: \(t_\alpha = \hat{\alpha} / SE(\hat{\alpha}) = 0.0017 / 0.0020\)
Conclusion: We fail to reject H₀: α = 0. Although α is positive (0.17% per month), it is not statistically significant at the 5% level. The marketing team’s claim of “positive risk-adjusted performance” is not statistically justified — the positive alpha may simply be due to sampling variation.
systematic_pct <- R2 * 100
diversifiable_pct <- (1 - R2) * 100
cat("Systematic (market-explained) variation:", systematic_pct, "%\n")
## Systematic (market-explained) variation: 50 %
cat("Diversifiable (idiosyncratic) variation:", diversifiable_pct, "%\n")
## Diversifiable (idiosyncratic) variation: 50 %
Interpretation: \(R^2 = 0.50\) means 50% of the fund’s return variation is explained by market movements (systematic risk). The remaining 50% is idiosyncratic (diversifiable) risk that is specific to the fund’s holdings.
E_excess_return <- beta_hat * mkt_premium
cat("CAPM-implied expected monthly excess return:", round(E_excess_return, 4),
"(", round(E_excess_return * 100, 4), "% )\n")
## CAPM-implied expected monthly excess return: 0.0069 ( 0.686 % )
Formula: \(E[R_i - R_f] = \hat{\beta} \times E[R_m - R_f] = 0.98 \times 0.0070\)
Given (144 monthly observations):
coefs <- c(alpha = 0.0029, b = 0.97, s = 0.75, h = -0.13)
ses <- c(alpha = 0.0018, b = 0.08, s = 0.11, h = 0.13)
t_stats <- coefs / ses
sig <- abs(t_stats) > t_crit
results <- data.frame(
Coefficient = names(coefs),
Estimate = coefs,
Std_Error = ses,
t_stat = round(t_stats, 4),
Significant = sig
)
print(results, row.names = FALSE)
## Coefficient Estimate Std_Error t_stat Significant
## alpha 0.0029 0.0018 1.611 FALSE
## b 0.9700 0.0800 12.125 TRUE
## s 0.7500 0.1100 6.818 TRUE
## h -0.1300 0.1300 -1.000 FALSE
Summary: MKT (b), SMB (s) are clearly significant. α is borderline — check its t-stat. HML (h) is not significant (|t| < 1.98).
cat("SMB loading (s):", coefs["s"], "→ Positive and large → SMALL-cap tilt\n")
## SMB loading (s): 0.75 → Positive and large → SMALL-cap tilt
cat("HML loading (h):", coefs["h"], "→ Negative (small) → Slight GROWTH tilt\n")
## HML loading (h): -0.13 → Negative (small) → Slight GROWTH tilt
cat("\nStyle: Small-cap, slight growth orientation\n")
##
## Style: Small-cap, slight growth orientation
Interpretation: - s = 0.75 (positive, large): The fund tilts strongly toward small-cap stocks. Small stocks outperform large stocks in the SMB factor; a high positive loading means the fund behaves like small-cap holdings. - h = −0.13 (negative, insignificant): A negative HML loading indicates a slight growth tilt (growth stocks have low book-to-market). However, since this is not significant, the growth tilt is weak and uncertain.
t_alpha_ff <- t_stats["alpha"]
cat("Alpha:", coefs["alpha"], "\n")
## Alpha: 0.0029
cat("t-statistic:", round(t_alpha_ff, 4), "\n")
## t-statistic: 1.611
cat("Significant at 5%?", abs(t_alpha_ff) > t_crit, "\n")
## Significant at 5%? FALSE
Interpretation: α = 0.0029 (≈ 0.29%/month). The t-statistic is 1.6111.
R2_capm <- 0.75
R2_ff <- 0.92
adj_R2 <- 0.918
n <- 144
cat("R2 increase:", R2_ff - R2_capm, "\n")
## R2 increase: 0.17
cat("Adjusted R2 (FF):", adj_R2, "\n")
## Adjusted R2 (FF): 0.918
# Manual adjusted R2 check for FF (k=3 predictors)
k_ff <- 3
adj_R2_check <- 1 - (1 - R2_ff) * (n - 1) / (n - k_ff - 1)
cat("Manually computed Adjusted R2:", round(adj_R2_check, 4), "\n")
## Manually computed Adjusted R2: 0.9183
Explanation: - The rise from R² = 0.75 (CAPM) to R² = 0.92 (FF) indicates the SMB and HML factors explain an additional 17% of the fund’s return variation. The fund has meaningful size and style exposures that CAPM misses entirely. - Why Adjusted R²? Adding any predictor, even a random noise variable, mechanically increases R². Adjusted R² penalizes for the number of predictors: \(\bar{R}^2 = 1 - \frac{(1-R^2)(n-1)}{n-k-1}\). Since Adjusted R² = 0.918 ≈ R² = 0.92, the added factors are genuinely informative, not just inflating fit.
Given:
b0 <- -0.02; b1 <- 5.4; b2 <- -0.38
r_lag <- 0.010
dVIX <- 1.5
logit_val <- b0 + b1 * r_lag + b2 * dVIX
prob_up <- 1 / (1 + exp(-logit_val))
pred_class <- ifelse(prob_up >= 0.5, "Up", "Down")
cat("Log-odds (logit):", round(logit_val, 4), "\n")
## Log-odds (logit): -0.536
cat("P(Up):", round(prob_up, 4), "\n")
## P(Up): 0.3691
cat("Predicted class (threshold = 0.5):", pred_class, "\n")
## Predicted class (threshold = 0.5): Down
Formula: \(\text{logit} = -0.02 + 5.4(0.010) + (-0.38)(1.5)\) \(P(Up) = \frac{1}{1 + e^{-\text{logit}}}\)
cat("beta1 =", b1, ": Positive → Momentum effect\n")
## beta1 = 5.4 : Positive → Momentum effect
cat(" A positive lagged return increases the log-odds of an Up day.\n")
## A positive lagged return increases the log-odds of an Up day.
cat(" Captures short-term price momentum.\n\n")
## Captures short-term price momentum.
cat("beta2 =", b2, ": Negative → Fear/volatility effect\n")
## beta2 = -0.38 : Negative → Fear/volatility effect
cat(" A rise in VIX (increasing fear) decreases the log-odds of an Up day.\n")
## A rise in VIX (increasing fear) decreases the log-odds of an Up day.
cat(" Captures the negative relationship between volatility spikes and returns.\n")
## Captures the negative relationship between volatility spikes and returns.
# Confusion matrix
TP <- 67 # Predicted Up, Actual Up
FP <- 44 # Predicted Up, Actual Down
FN <- 33 # Predicted Down, Actual Up
TN <- 56 # Predicted Down, Actual Down
N <- 200
accuracy <- (TP + TN) / N
sensitivity <- TP / (TP + FN) # True Positive Rate for "Up"
specificity <- TN / (TN + FP) # True Negative Rate for "Down"
precision <- TP / (TP + FP) # Precision for "Up" predictions
cat("Accuracy: ", round(accuracy, 4), "\n")
## Accuracy: 0.615
cat("Sensitivity: ", round(sensitivity, 4), "\n")
## Sensitivity: 0.67
cat("Specificity: ", round(specificity, 4), "\n")
## Specificity: 0.56
cat("Precision: ", round(precision, 4), "\n")
## Precision: 0.6036
Formulas: - Accuracy = (TP + TN) / N - Sensitivity (Recall for “Up”) = TP / (TP + FN) - Specificity = TN / (TN + FP) - Precision = TP / (TP + FP)
# Both classes = 100, so either class is majority; both equal → 50% naive accuracy
majority_class_count <- max(100, 100) # Both are 100
naive_accuracy <- majority_class_count / N
cat("Naive rule accuracy:", round(naive_accuracy, 4), "\n")
## Naive rule accuracy: 0.5
cat("Model accuracy: ", round(accuracy, 4), "\n")
## Model accuracy: 0.615
cat("Model beats naive rule?", accuracy > naive_accuracy, "\n")
## Model beats naive rule? TRUE
Why accuracy alone is inadequate for a trading system:
In a trading context, the cost of a false positive (going long on a Down day) and a false negative (missing an Up day) differ in economic terms. A strategy may have decent accuracy but poor risk-adjusted returns — e.g., it correctly predicts easy, low-return Up days but misses large Up days. A more economically relevant criterion is profit-weighted accuracy or the Sharpe ratio of the strategy implied by the predictions, which accounts for the magnitude of returns, not just directional correctness.
Given: Mean monthly return = 0.70%, SD = 5.50%, n = 48 months
mu_monthly <- 0.0070 # 0.70%
sd_monthly <- 0.0550 # 5.50%
n_months <- 48
sharpe_monthly <- mu_monthly / sd_monthly
scaling_factor <- sqrt(12) # annualize monthly Sharpe
sharpe_annual <- sharpe_monthly * scaling_factor
cat("Monthly Sharpe ratio:", round(sharpe_monthly, 4), "\n")
## Monthly Sharpe ratio: 0.1273
cat("Scaling factor: ", round(scaling_factor, 4), "(= sqrt(12))\n")
## Scaling factor: 3.464 (= sqrt(12))
cat("Annualized Sharpe: ", round(sharpe_annual, 4), "\n")
## Annualized Sharpe: 0.4409
Formula: \(SR_{monthly} = \bar{r}/\hat{\sigma}\); \(SR_{annual} = SR_{monthly} \times \sqrt{12}\)
The scaling factor is \(\sqrt{12}\) because returns are i.i.d. monthly: variance scales by 12, so SD scales by \(\sqrt{12}\), and Sharpe scales by \(\sqrt{12}\).
set.seed(42)
# Simulate a plausible monthly return series matching the given stats
set.seed(42)
r_sim <- rnorm(n_months, mean = mu_monthly, sd = sd_monthly)
# --- Standard i.i.d. bootstrap ---
B <- 10000
sharpe_boot_iid <- numeric(B)
for (i in 1:B) {
boot_sample <- sample(r_sim, size = n_months, replace = TRUE)
sharpe_boot_iid[i] <- mean(boot_sample) / sd(boot_sample)
}
se_iid <- sd(sharpe_boot_iid)
cat("Bootstrap SE (i.i.d.):", round(se_iid, 6), "\n")
## Bootstrap SE (i.i.d.): 0.149
# --- Block bootstrap (stationary/circular) ---
# Appropriate for serially correlated return data
block_size <- 6 # e.g., 6-month blocks
sharpe_boot_block <- numeric(B)
for (i in 1:B) {
n_blocks <- ceiling(n_months / block_size)
starts <- sample(1:(n_months - block_size + 1), n_blocks, replace = TRUE)
boot_idx <- unlist(lapply(starts, function(s) s:(s + block_size - 1)))
boot_sample <- r_sim[boot_idx[1:n_months]]
sharpe_boot_block[i] <- mean(boot_sample) / sd(boot_sample)
}
se_block <- sd(sharpe_boot_block)
cat("Bootstrap SE (block, block_size=6):", round(se_block, 6), "\n")
## Bootstrap SE (block, block_size=6): 0.1686
Bootstrap procedure (step by step):
Why ordinary i.i.d. bootstrap is inappropriate: Monthly returns often exhibit serial correlation (autocorrelation) — e.g., momentum effects, volatility clustering. The i.i.d. bootstrap destroys the time ordering, breaking any autocorrelation structure and understating the true variability of the Sharpe ratio.
Fix: Use the stationary block bootstrap (or circular block bootstrap). It resamples contiguous blocks of returns, preserving local autocorrelation structure.
lambda_min <- 0.030 # 14 factors, minimum CV error
lambda_1se <- 0.065 # 7 factors, one-SE rule
cat("lambda_min:", lambda_min, "→ 14 factors retained\n")
## lambda_min: 0.03 → 14 factors retained
cat("lambda_1se:", lambda_1se, "→ 7 factors retained\n")
## lambda_1se: 0.065 → 7 factors retained
cat("\nRecommended: lambda_1se =", lambda_1se, "\n")
##
## Recommended: lambda_1se = 0.065
Recommendation: Deploy \(\lambda = 0.065\) (one-standard-error rule, 7 factors).
Reasoning: - The minimum-CV solution (\(\lambda = 0.030\), 14 factors) minimizes in-sample cross-validation error, but with 60 candidate factors this risks overfitting — some of the 14 factors may be noise that happened to correlate with returns in the training window. - The one-SE rule selects the most regularized model whose CV error is within one standard error of the minimum. With only 7 factors, the model is simpler, more interpretable, and less likely to suffer data-snooping / look-ahead bias in a live backtest. - In finance, parsimony is especially valuable: each extra factor adds estimation error and transaction costs when rebalancing.
cat("Walk-forward cross-validation outline:\n\n")
## Walk-forward cross-validation outline:
cat("1. Training window: months 1 to T_train (e.g., first 36 months)\n")
## 1. Training window: months 1 to T_train (e.g., first 36 months)
cat("2. Fit lasso on training window; select lambda via nested CV within training only\n")
## 2. Fit lasso on training window; select lambda via nested CV within training only
cat("3. Generate predictions/signals for months T_train+1 to T_train+h (test window, e.g., 12 months)\n")
## 3. Generate predictions/signals for months T_train+1 to T_train+h (test window, e.g., 12 months)
cat("4. Record out-of-sample returns for those h months\n")
## 4. Record out-of-sample returns for those h months
cat("5. Expand (or roll) the training window forward by h months\n")
## 5. Expand (or roll) the training window forward by h months
cat("6. Repeat steps 2–5 until end of data\n")
## 6. Repeat steps 2–5 until end of data
cat("7. Aggregate all out-of-sample periods to compute the final Sharpe ratio\n\n")
## 7. Aggregate all out-of-sample periods to compute the final Sharpe ratio
cat("Why standard k-fold CV is unsafe:\n")
## Why standard k-fold CV is unsafe:
cat(" Random k-fold splits allow FUTURE data to appear in the training fold\n")
## Random k-fold splits allow FUTURE data to appear in the training fold
cat(" and PAST data to appear in the validation fold — a look-ahead bias.\n")
## and PAST data to appear in the validation fold — a look-ahead bias.
cat(" This artificially inflates performance: the model has 'seen' the future.\n")
## This artificially inflates performance: the model has 'seen' the future.
cat(" Walk-forward CV strictly enforces that training always precedes testing in time.\n")
## Walk-forward CV strictly enforces that training always precedes testing in time.
Walk-Forward Scheme (illustrated):
total_months <- 48
train_start <- 1
train_end <- 36
step <- 4 # expand by 4 months each fold
fold <- 1
while ((train_end + step) <= total_months) {
test_start <- train_end + 1
test_end <- min(train_end + step, total_months)
cat(sprintf("Fold %d: Train [%d–%d], Test [%d–%d]\n",
fold, train_start, train_end, test_start, test_end))
train_end <- test_end
fold <- fold + 1
}
## Fold 1: Train [1–36], Test [37–40]
## Fold 2: Train [1–40], Test [41–44]
## Fold 3: Train [1–44], Test [45–48]
Why random k-fold CV is unsafe for time-series: Standard k-fold randomly assigns observations to folds, so a model trained on data from month 40 can be validated on month 10 — effectively training on the future. This introduces look-ahead bias, inflating the apparent Sharpe ratio. In a real deployment, such future information is unavailable, so the backtest is an unreliable estimate of live performance. ```