| Step | AI Prompt Used | AI Output / Action Taken |
|---|---|---|
| 1. Macro Screening | "Which US sectors showed the most persistent momentum exposure from 2015 to 2025?" | Identified Technology & Healthcare as primary momentum sectors 2015–2025 |
| 2. Universe Filtering | "Screen for stocks: consistent 12-1M momentum signal, low idiosyncratic vol, market cap > $100B, max sector = 35%" | Produced candidate list of 15 stocks; we selected top 10 with sector diversification |
| 3. Risk Flagging | "What is momentum crash risk? How does Daniel & Moskowitz (2016) define it and how should I monitor it?" | Flagged 2020 COVID rebound and 2022 rate-shock as high crash-risk periods → added MDD monitoring |
| 4. Weight Optimisation | "How do I construct a minimum-variance portfolio in R using quadprog with weight bounds of 5–30%?" | Provided complete R code for Markowitz optimisation with constraint matrix setup |
AI-Assisted Momentum Portfolio
Construction, Optimisation & 10-Year Backtesting (2015–2025)
1 Part I: Investment Thesis & Logic
1.1 Strategy Description
Our portfolio is built on the 12-1 Month Momentum Factor — one of the most robust and replicated anomalies in empirical asset pricing. First documented by @jegadeesh1993 , momentum stocks (highest past 12-month return, excluding last month) continue to outperform over the next 3–12 months.
Source of Alpha
| Mechanism | Description |
|---|---|
| Behavioural underreaction | Investors underreact to positive news; prices adjust gradually |
| Institutional herding | Fund managers chase winners, creating self-reinforcing trends |
| Anchoring bias | Investors anchor to past prices, causing delayed repricing |
Why Momentum Outperforms
- Academic consensus: Fama-French 3-factor model cannot explain momentum returns
- Carhart (1997) added momentum as the 4th factor, confirming it as a systematic risk premium
- Daniel & Moskowitz (2016): momentum earns ~1% per month on average but is subject to crash risk during sharp market reversals
1.2 AI Collaboration Process
We used Claude (Anthropic) throughout the research process as an active analytical collaborator, not merely a writing tool:
2 Part II: Portfolio Construction
2.1 Asset Selection
Show code
tickers <- c("NVDA","AAPL","MSFT","META","AMZN","LLY","UNH","AVGO","MA","COST")
benchmark <- "SPY"
asset_meta <- tibble(
Ticker = tickers,
Company = c("NVIDIA","Apple","Microsoft","Meta Platforms","Amazon",
"Eli Lilly","UnitedHealth","Broadcom","Mastercard","Costco"),
Sector = c("Technology","Technology","Technology","Communication",
"Consumer Disc.","Healthcare","Healthcare",
"Technology","Financials","Consumer Staples"),
Rationale = c(
"AI/GPU demand; top 12-1M momentum signal 2023–24",
"Services pivot; consistent mega-cap momentum",
"Azure + Copilot AI; enterprise cloud momentum",
"Ad revenue recovery + Threads; multiple expansion",
"AWS re-acceleration; Prime flywheel effect",
"GLP-1 structural mega-trend (Ozempic/Mounjaro)",
"Defensive momentum; healthcare cost tailwinds",
"VMware acquisition; diversified semiconductor exposure",
"Recurring network fees; premium consumer resilience",
"Membership flywheel; global expansion momentum"
)
)
kable(asset_meta, col.names = c("Ticker","Company","Sector","AI Selection Rationale"),
caption = "Table 2: Portfolio Asset List — AI-Screened Momentum Stocks") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = TRUE, font_size = 13) %>%
column_spec(1, bold = TRUE, color = BLUE) %>%
column_spec(4, italic = TRUE) %>%
row_spec(0, background = "#1E40AF", color = "white")| Ticker | Company | Sector | AI Selection Rationale |
|---|---|---|---|
| NVDA | NVIDIA | Technology | AI/GPU demand; top 12-1M momentum signal 2023–24 |
| AAPL | Apple | Technology | Services pivot; consistent mega-cap momentum |
| MSFT | Microsoft | Technology | Azure + Copilot AI; enterprise cloud momentum |
| META | Meta Platforms | Communication | Ad revenue recovery + Threads; multiple expansion |
| AMZN | Amazon | Consumer Disc. | AWS re-acceleration; Prime flywheel effect |
| LLY | Eli Lilly | Healthcare | GLP-1 structural mega-trend (Ozempic/Mounjaro) |
| UNH | UnitedHealth | Healthcare | Defensive momentum; healthcare cost tailwinds |
| AVGO | Broadcom | Technology | VMware acquisition; diversified semiconductor exposure |
| MA | Mastercard | Financials | Recurring network fees; premium consumer resilience |
| COST | Costco | Consumer Staples | Membership flywheel; global expansion momentum |
2.2 Data Download
Show code
all_tickers <- c(tickers, benchmark)
prices_raw <- tq_get(all_tickers, from = "2015-01-01", to = "2025-01-01",
get = "stock.prices")
monthly_returns <- prices_raw %>%
group_by(symbol) %>%
tq_transmute(select = adjusted, mutate_fun = periodReturn,
period = "monthly", col_rename = "return") %>%
ungroup()
glue::glue("✓ Loaded {nrow(monthly_returns)} monthly observations | ",
"{format(min(monthly_returns$date))} → {format(max(monthly_returns$date))}")✓ Loaded 1320 monthly observations | 2015-01-30 → 2024-12-31
2.3 Weight Optimisation via Mean-Variance
Show code
portfolio_returns <- monthly_returns %>%
filter(symbol != benchmark) %>%
pivot_wider(names_from = symbol, values_from = return) %>%
drop_na()
ret_matrix <- as.matrix(portfolio_returns[, -1])
mu <- colMeans(ret_matrix) * 12
Sigma <- cov(ret_matrix) * 12
n <- length(tickers)
# ── Quadprog: min w'Σw s.t. Σw=1, 0.05≤w≤0.30 ───────────────────────────────
Dmat <- 2 * Sigma
dvec <- rep(0, n)
Amat <- cbind(matrix(1, n, 1), diag(n), -diag(n))
bvec <- c(1, rep(0.05, n), rep(-0.30, n))
opt <- solve.QP(Dmat, dvec, Amat, bvec, meq = 1)
weights_mv <- opt$solution / sum(opt$solution)
names(weights_mv) <- tickers
weight_df <- tibble(
Ticker = tickers,
Company = asset_meta$Company,
Sector = asset_meta$Sector,
Weight = weights_mv
) %>% arrange(desc(Weight))
# ── Display table ─────────────────────────────────────────────────────────────
weight_df %>%
mutate(`Weight (%)` = percent(Weight, accuracy = 0.01)) %>%
select(-Weight) %>%
kable(caption = "Table 3: Mean-Variance Optimised Portfolio Weights") %>%
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE, font_size = 13) %>%
column_spec(1, bold = TRUE, color = BLUE) %>%
column_spec(4, bold = TRUE) %>%
row_spec(0, background = "#1E40AF", color = "white")| Ticker | Company | Sector | Weight (%) |
|---|---|---|---|
| UNH | UnitedHealth | Healthcare | 30.00% |
| LLY | Eli Lilly | Healthcare | 21.26% |
| MA | Mastercard | Financials | 9.65% |
| COST | Costco | Consumer Staples | 9.09% |
| NVDA | NVIDIA | Technology | 5.00% |
| AMZN | Amazon | Consumer Disc. | 5.00% |
| MSFT | Microsoft | Technology | 5.00% |
| META | Meta Platforms | Communication | 5.00% |
| AAPL | Apple | Technology | 5.00% |
| AVGO | Broadcom | Technology | 5.00% |
Show code
p_weights <- weight_df %>%
mutate(Ticker = fct_reorder(Ticker, Weight)) %>%
ggplot(aes(x = Ticker, y = Weight, fill = Sector)) +
geom_col(width = 0.65) +
geom_text(aes(label = percent(Weight, accuracy = 0.1)),
hjust = -0.15, size = 3.5, color = SLATE) +
geom_hline(yintercept = 0.05, linetype = "dashed",
color = AMBER, linewidth = 0.6) +
annotate("text", x = 0.7, y = 0.055, label = "Min 5%",
size = 3, color = AMBER) +
coord_flip(ylim = c(0, 0.36)) +
scale_y_continuous(labels = percent_format()) +
scale_fill_brewer(palette = "Set2") +
labs(title = "Portfolio Weight Allocation",
subtitle = "Mean-Variance Optimisation · 5%–30% weight bounds",
x = NULL, y = "Portfolio Weight") +
theme(legend.position = "right")
print(p_weights)2.4 Benchmark Justification
We use SPY (SPDR S&P 500 ETF) as our benchmark because:
- It is the most liquid and widely-tracked US equity proxy (AUM > $550B)
- Momentum strategies are conventionally evaluated against broad market beta
- All 10 selected stocks are S&P 500 constituents → Alpha attribution is meaningful
- SPY has near-zero tracking error vs. the S&P 500 index
3 Part III: Backtesting & Performance Analysis
Show code
# ── Construct portfolio & benchmark return series ─────────────────────────────
port_ret <- xts(ret_matrix %*% weights_mv,
order.by = portfolio_returns$date)
colnames(port_ret) <- "Momentum_Portfolio"
bench_xts <- monthly_returns %>%
filter(symbol == benchmark, date %in% portfolio_returns$date) %>%
arrange(date) %>%
{ xts(.$return, order.by = .$date) }
colnames(bench_xts) <- "SPY"
combined_xts <- merge(port_ret, bench_xts)
rf_monthly <- 0.03 / 12
excess_port <- port_ret - rf_monthly
excess_bench <- bench_xts - rf_monthly3.1 Cumulative Return
Show code
cum_df <- cumprod(1 + combined_xts) %>%
as.data.frame() %>%
rownames_to_column("date") %>%
mutate(date = as.Date(date)) %>%
pivot_longer(-date, names_to = "Series", values_to = "WealthIndex") %>%
mutate(Series = factor(Series, levels = c("Momentum_Portfolio","SPY")))
# Key annotation points
end_vals <- cum_df %>% group_by(Series) %>% slice_max(date, n = 1)
ggplot(cum_df, aes(x = date, y = WealthIndex, color = Series)) +
# Recession shading – 2020 COVID, 2022 rate shock
annotate("rect", xmin = as.Date("2020-02-01"), xmax = as.Date("2020-04-30"),
ymin = -Inf, ymax = Inf, fill = "#FEF3C7", alpha = 0.6) +
annotate("rect", xmin = as.Date("2022-01-01"), xmax = as.Date("2022-12-31"),
ymin = -Inf, ymax = Inf, fill = "#FEE2E2", alpha = 0.4) +
annotate("text", x = as.Date("2020-03-15"), y = 1.05,
label = "COVID\ncrash", size = 2.8, color = AMBER, fontface = "bold") +
annotate("text", x = as.Date("2022-07-01"), y = 1.05,
label = "Rate\nshock", size = 2.8, color = RED, fontface = "bold") +
geom_line(linewidth = 1.1) +
geom_point(data = end_vals, size = 3) +
geom_label_repel(data = end_vals,
aes(label = paste0(Series, "\n", round((WealthIndex-1)*100,0), "%")),
size = 3.2, fontface = "bold", show.legend = FALSE,
nudge_x = 60, segment.color = "grey60") +
geom_hline(yintercept = 1, linetype = "dashed", color = "gray60", linewidth = 0.4) +
scale_y_continuous(labels = function(x) paste0(round((x-1)*100), "%"),
breaks = seq(0, 15, by = 1)) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
scale_color_manual(values = c("Momentum_Portfolio" = BLUE, "SPY" = RED),
labels = c("Momentum Portfolio","SPY (S&P 500)")) +
labs(title = "Cumulative Return: Momentum Portfolio vs. S&P 500 (SPY)",
subtitle = "Wealth index (Jan 2015 = 0%) · Shaded = major drawdown periods",
x = NULL, y = "Cumulative Return",
caption = "Source: Yahoo Finance via tidyquant · Monthly rebalancing assumed")3.3 Rolling 12-Month Sharpe Ratio
Show code
roll_sharpe <- function(x, n = 12, rf = rf_monthly) {
rollapply(x, width = n,
FUN = function(r) {
er <- mean(r - rf)
sd_r <- sd(r - rf)
if (sd_r == 0) return(NA)
er / sd_r * sqrt(12)
}, fill = NA, align = "right")
}
rs_port <- roll_sharpe(port_ret)
rs_bench <- roll_sharpe(bench_xts)
roll_df <- merge(rs_port, rs_bench) %>%
as.data.frame() %>%
setNames(c("Momentum_Portfolio","SPY")) %>%
rownames_to_column("date") %>%
mutate(date = as.Date(date)) %>%
pivot_longer(-date, names_to = "Series", values_to = "RollingSharpe") %>%
drop_na()
ggplot(roll_df, aes(x = date, y = RollingSharpe, color = Series)) +
geom_hline(yintercept = 0, color = "gray70", linewidth = 0.5) +
geom_hline(yintercept = 1, linetype = "dashed", color = TEAL,
linewidth = 0.5, alpha = 0.8) +
annotate("text", x = as.Date("2016-06-01"), y = 1.08,
label = "Sharpe = 1.0 threshold", size = 3, color = TEAL) +
geom_line(linewidth = 0.9, alpha = 0.9) +
scale_color_manual(values = c("Momentum_Portfolio" = BLUE, "SPY" = RED),
labels = c("Momentum Portfolio","SPY (S&P 500)")) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
labs(title = "Rolling 12-Month Sharpe Ratio",
subtitle = "Annualised · Rf = 3% p.a. · A higher, more stable line = more consistent risk-adj. returns",
x = NULL, y = "Rolling Sharpe Ratio",
caption = "Source: Yahoo Finance via tidyquant")3.4 Maximum Drawdown (MDD)
Show code
mdd_port <- maxDrawdown(port_ret)
mdd_bench <- maxDrawdown(bench_xts)
tibble(
Series = c("Momentum Portfolio", "SPY (Benchmark)"),
`Max Drawdown` = percent(c(mdd_port, mdd_bench), accuracy = 0.01),
`Interpretation` = c(
"Maximum peak-to-trough loss over the backtest period",
"S&P 500 worst drawdown (primarily 2022 rate-shock)"
)
) %>%
kable(caption = "Table 5: Maximum Drawdown (MDD)") %>%
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE, font_size = 13) %>%
column_spec(2, bold = TRUE, color = "darkred") %>%
row_spec(0, background = "#1E40AF", color = "white")| Series | Max Drawdown | Interpretation |
|---|---|---|
| Momentum Portfolio | 12.45% | Maximum peak-to-trough loss over the backtest period |
| SPY (Benchmark) | 23.93% | S&P 500 worst drawdown (primarily 2022 rate-shock) |
Figure 4: Drawdown Analysis — Momentum Portfolio vs SPY
Show code
# Drawdown chart
dd_df <- merge(Drawdowns(port_ret), Drawdowns(bench_xts)) %>%
as.data.frame() %>%
setNames(c("Momentum_Portfolio","SPY")) %>%
rownames_to_column("date") %>%
mutate(date = as.Date(date)) %>%
pivot_longer(-date, names_to = "Series", values_to = "Drawdown")
ggplot(dd_df, aes(x = date, y = Drawdown, fill = Series, color = Series)) +
geom_area(alpha = 0.20, position = "identity") +
geom_line(linewidth = 0.7) +
geom_hline(yintercept = 0, color = "gray70", linewidth = 0.4) +
scale_y_continuous(labels = percent_format()) +
scale_x_date(date_breaks = "1 year", date_labels = "%Y") +
scale_fill_manual(values = c("Momentum_Portfolio" = BLUE, "SPY" = RED)) +
scale_color_manual(values = c("Momentum_Portfolio" = BLUE, "SPY" = RED),
labels = c("Momentum Portfolio","SPY (S&P 500)")) +
labs(title = "Drawdown Analysis (2015–2025)",
subtitle = "Shaded area = underwater period from prior peak · Momentum MDD = 12.45% vs SPY 23.93%",
x = NULL, y = "Drawdown from Peak",
caption = "Source: Yahoo Finance via tidyquant") +
guides(fill = "none")3.5 Alpha & Beta (CAPM Regression)
Show code
capm_model <- lm(as.numeric(excess_port) ~ as.numeric(excess_bench))
alpha_m <- coef(capm_model)[1]
beta <- coef(capm_model)[2]
alpha_ann <- (1 + alpha_m)^12 - 1
r_sq <- summary(capm_model)$r.squared
capm_pval <- summary(capm_model)$coefficients[1, 4]
tibble(
Metric = c("Alpha (monthly)", "Alpha (annualised)", "Beta", "R-squared", "Alpha p-value"),
Value = c(percent(alpha_m, 0.001),
percent(alpha_ann, 0.01),
round(beta, 4),
round(r_sq, 4),
formatC(capm_pval, format = "e", digits = 3)),
Interpretation = c(
"Monthly excess return above CAPM prediction",
"Compound annualised abnormal return",
"Portfolio less volatile than S&P 500 (β < 1)",
"69.2% of portfolio variance explained by market",
"Alpha is statistically significant (p < 0.05)"
)
) %>%
kable(caption = "Table 6: CAPM Alpha & Beta (Monthly OLS Regression, 2015–2025)") %>%
kable_styling(bootstrap_options = c("striped","hover"), full_width = TRUE, font_size = 13) %>%
column_spec(2, bold = TRUE) %>%
column_spec(3, italic = TRUE) %>%
row_spec(2, background = "#EFF6FF") %>%
row_spec(0, background = "#1E40AF", color = "white")| Metric | Value | Interpretation |
|---|---|---|
| Alpha (monthly) | 1.316% | Monthly excess return above CAPM prediction |
| Alpha (annualised) | 16.98% | Compound annualised abnormal return |
| Beta | 0.8103 | Portfolio less volatile than S&P 500 (β < 1) |
| R-squared | 0.6921 | 69.2% of portfolio variance explained by market |
| Alpha p-value | 3.772e-08 | Alpha is statistically significant (p < 0.05) |
Figure 5: CAPM Security Characteristic Line
Show code
# Security Characteristic Line
scl_df <- data.frame(
x = as.numeric(excess_bench),
y = as.numeric(excess_port)
)
ggplot(scl_df, aes(x = x, y = y)) +
geom_point(color = BLUE, alpha = 0.4, size = 2) +
geom_smooth(method = "lm", color = RED, linewidth = 1.1,
fill = "#FEE2E2", alpha = 0.25, se = TRUE) +
geom_hline(yintercept = 0, color = "gray60", linewidth = 0.4) +
geom_vline(xintercept = 0, color = "gray60", linewidth = 0.4) +
annotate("label",
x = min(scl_df$x) * 0.85,
y = max(scl_df$y) * 0.90,
label = sprintf("α = %.2f%% p.a.\nβ = %.4f\nR² = %.4f",
alpha_ann * 100, beta, r_sq),
hjust = 0, size = 3.8, fill = "white",
color = "#0F172A", label.padding = unit(0.4,"lines")) +
scale_x_continuous(labels = percent_format()) +
scale_y_continuous(labels = percent_format()) +
labs(title = "CAPM Security Characteristic Line",
subtitle = "Each point = one month · Red line = OLS fit · Shaded = 95% CI",
x = "SPY Excess Return (Market Factor)",
y = "Portfolio Excess Return",
caption = "Source: Yahoo Finance via tidyquant · Rf = 3% p.a.")3.6 Performance Summary Dashboard
Show code
ann_ret_p <- Return.annualized(port_ret)[1]
ann_ret_b <- Return.annualized(bench_xts)[1]
ann_vol_p <- StdDev.annualized(port_ret)[1]
ann_vol_b <- StdDev.annualized(bench_xts)[1]
calmar_p <- ann_ret_p / mdd_port
calmar_b <- ann_ret_b / mdd_bench
tibble(
Metric = c(
"Annualised Return", "Annualised Volatility", "Sharpe Ratio (Rf=3%)",
"Maximum Drawdown", "Calmar Ratio", "CAPM Alpha (p.a.)", "CAPM Beta"
),
`Momentum Portfolio` = c(
percent(ann_ret_p, 0.01), percent(ann_vol_p, 0.01),
round(sharpe_port, 3), percent(mdd_port, 0.01),
round(calmar_p, 3), percent(alpha_ann, 0.01), round(beta, 4)
),
`SPY (Benchmark)` = c(
percent(ann_ret_b, 0.01), percent(ann_vol_b, 0.01),
round(sharpe_bench, 3), percent(mdd_bench, 0.01),
round(calmar_b, 3), "—", "1.0000"
),
`Portfolio Edge` = c(
paste0("+" , percent(ann_ret_p - ann_ret_b, 0.01)),
percent(ann_vol_p - ann_vol_b, 0.01),
paste0("+", round(sharpe_port - sharpe_bench, 3)),
percent(mdd_port - mdd_bench, 0.01),
paste0("+", round(calmar_p - calmar_b, 3)),
percent(alpha_ann, 0.01), round(beta - 1, 4)
)
) %>%
kable(caption = "Table 7: Full Performance Summary (2015–2025)") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = TRUE, font_size = 13) %>%
column_spec(2, bold = TRUE, background = "#EFF6FF") %>%
column_spec(4, bold = TRUE, color = "#059669") %>%
row_spec(0, background = "#1E40AF", color = "white") %>%
row_spec(c(1,3,6), background = "#F0FDF4")| Metric | Momentum Portfolio | SPY (Benchmark) | Portfolio Edge |
|---|---|---|---|
| Annualised Return | 29.55% | 13.00% | +16.55% |
| Annualised Volatility | 14.93% | 15.33% | -0.40% |
| Sharpe Ratio (Rf=3%) | 1.728 | 0.632 | +1.096 |
| Maximum Drawdown | 12.45% | 23.93% | -11.47% |
| Calmar Ratio | 2.373 | 0.543 | +1.83 |
| CAPM Alpha (p.a.) | 16.98% | — | 16.98% |
| CAPM Beta | 0.8103 | 1.0000 | -0.1897 |
4 Part IV: Critical Reflection
4.1 AI Insights Beyond Traditional Analysis
4.2 Do Backtesting Results Align with Hypothesis?
Our hypothesis — that a momentum strategy generates statistically significant Alpha over a 10-year horizon — is strongly supported by the backtesting evidence:
- CAPM Alpha of 16.98% p.a. (p-value < 0.05) confirms abnormal returns beyond market exposure
- Sharpe Ratio of 1.728 vs. 0.632 confirms superior risk-adjusted performance
- Beta of 0.81 shows the portfolio achieved this with less systematic risk than SPY — largely due to AI-advised sector diversification
- The rolling Sharpe chart shows the portfolio consistently outperformed SPY throughout the period, with the gap widening during the AI/tech investment boom of 2023–2024
Potential sources of bias and discrepancy:
| Bias | Description | Likely Impact |
|---|---|---|
| Survivorship bias | Stocks selected with hindsight; live implementation requires dynamic rebalancing | Overestimates returns |
| Look-ahead bias | Static 10-stock selection differs from a rolling monthly momentum signal | Overestimates returns |
| Transaction costs | Momentum has high turnover; round-trip costs not modelled | Would reduce net returns ~2–3% p.a. |
| Liquidity assumption | Monthly rebalancing assumes perfect execution at month-end prices | Minor for large-cap stocks |
4.3 Conclusion
The 10-stock AI-assisted momentum portfolio generated compelling evidence of market-beating Alpha over the 2015–2025 period. AI collaboration was integral to three key decisions that materially improved the portfolio: (1) identifying sector themes beyond raw price signals, (2) flagging momentum crash and concentration risk before optimisation, and (3) providing academically-grounded optimisation code. Future extensions should implement a dynamic rolling 12-1M momentum signal with monthly rebalancing and explicit transaction cost modelling to simulate a live, investable strategy.