# Install any missing packages by uncommenting the line below:
# install.packages(c("tidyquant","PortfolioAnalytics","ROI",
# "ROI.plugin.glpk","ROI.plugin.quadprog",
# "PerformanceAnalytics","ggplot2","dplyr",
# "tidyr","timetk","scales","knitr","kableExtra"))
library(tidyquant)
library(PortfolioAnalytics)
library(PerformanceAnalytics)
library(ROI)
library(ROI.plugin.glpk)
library(ROI.plugin.quadprog)
library(ggplot2)
library(dplyr)
library(tidyr)
library(timetk)
library(scales)
library(knitr)
library(kableExtra)This portfolio follows a Deep Value with Quality Screen strategy. The core philosophy is that equity markets systematically overprice high-growth stocks and underprice fundamentally sound companies that are temporarily out of favour. The expected source of Alpha is mean reversion: undervalued stocks with strong fundamentals eventually get repriced upward as their earnings power becomes undeniable.
Stocks were selected by filtering for the following fundamental thresholds:
| Factor | Threshold | Rationale |
|---|---|---|
| Price-to-Book (P/B) | < 1.5 | Identifies assets trading below replacement cost |
| Price-to-Earnings (P/E) | < 15 | Screens for cheap earnings relative to price |
| Free Cash Flow Yield | > 5% | Confirms real cash generation (avoids accounting tricks) |
| Debt-to-Equity | < 0.5 | Quality filter — avoids value traps with leverage risk |
The value premium is one of the most replicated findings in empirical finance. Fama and French (1992) demonstrated that high book-to-market stocks earn persistent excess returns over growth stocks. Behavioural explanations (Lakonishok et al., 1994) suggest investors systematically extrapolate recent poor performance too far into the future, creating mispricings that revert over 2–5 year horizons.
The following prompts were used with Claude to develop this strategy:
Prompt 1 — Strategy ideation: “Act as a quantitative researcher. Explain why deep value stocks filtered by P/B < 1.5, P/E < 15, and FCF yield > 5% should generate abnormal returns over a 3-year horizon. Use the Fama-French factor framework.”
Prompt 2 — Stock filtering: “I am building a 10-stock US value portfolio. From the sectors: financials, energy, healthcare, industrials, and telecom — which specific large-cap stocks currently show the strongest combination of low P/B, low P/E, and high FCF yield? Explain the reasoning for each pick.”
Prompt 3 — Code generation: “Write an R script using PortfolioAnalytics and tidyquant to calculate Maximum Sharpe Ratio portfolio weights for 10 US tickers. Apply a box constraint capping each stock at 20%, use 3 years of daily returns, and assume a 4.5% annualised risk-free rate.”
Key AI insight: Claude flagged that 3M (MMM) carries significant legal liability overhangs that simple P/B screens do not capture — a nuance that informed the decision to apply the D/E < 0.5 quality filter, which a naive value screen would have omitted.
# ── Asset universe ────────────────────────────────────────────────────────
tickers <- c("BRK-B", "JPM", "CVX", "JNJ", "MRK",
"ABBV", "MMM", "LMT", "WFC", "VZ")
benchmark <- "SPY"
start_date <- Sys.Date() - lubridate::years(3)
rf_daily <- 0.045 / 252 # ~4.5% annualised risk-free rate
# Display asset rationale table
asset_table <- data.frame(
Ticker = tickers,
Company = c("Berkshire Hathaway", "JPMorgan Chase", "Chevron",
"Johnson & Johnson", "Merck", "AbbVie",
"3M", "Lockheed Martin", "Wells Fargo", "Verizon"),
Sector = c("Financials","Financials","Energy",
"Healthcare","Healthcare","Healthcare",
"Industrials","Defense","Financials","Telecom"),
Rationale = c("Diversified value, low P/B",
"P/E ~10, high ROE, strong FCF",
"FCF yield >8%, low debt",
"Stable FCF, defensive growth",
"Low P/E, strong drug pipeline",
"High FCF yield, dividend stability",
"Deep discount, restructuring upside",
"Steady govt. contracts, low P/E",
"P/B < 1.3, recovering fundamentals",
"P/E ~8, FCF yield ~10%")
)
kable(asset_table, caption = "Selected Portfolio Assets") %>%
kable_styling(bootstrap_options = c("striped","hover","condensed"),
full_width = FALSE)| Ticker | Company | Sector | Rationale |
|---|---|---|---|
| BRK-B | Berkshire Hathaway | Financials | Diversified value, low P/B |
| JPM | JPMorgan Chase | Financials | P/E ~10, high ROE, strong FCF |
| CVX | Chevron | Energy | FCF yield >8%, low debt |
| JNJ | Johnson & Johnson | Healthcare | Stable FCF, defensive growth |
| MRK | Merck | Healthcare | Low P/E, strong drug pipeline |
| ABBV | AbbVie | Healthcare | High FCF yield, dividend stability |
| MMM | 3M | Industrials | Deep discount, restructuring upside |
| LMT | Lockheed Martin | Defense | Steady govt. contracts, low P/E |
| WFC | Wells Fargo | Financials | P/B < 1.3, recovering fundamentals |
| VZ | Verizon | Telecom | P/E ~8, FCF yield ~10% |
The S&P 500 (SPY) is selected as the benchmark because:
# Download prices for portfolio stocks and benchmark
prices_raw <- tq_get(
c(tickers, benchmark),
from = start_date,
get = "stock.prices"
)
cat("Date range:", format(min(prices_raw$date)), "to", format(max(prices_raw$date)), "\n")## Date range: 2023-05-26 to 2026-05-22
## Tickers downloaded: BRK-B, JPM, CVX, JNJ, MRK, ABBV, MMM, LMT, WFC, VZ, SPY
# Daily returns — portfolio stocks only (wide xts format)
returns_wide <- prices_raw %>%
filter(symbol %in% tickers) %>%
group_by(symbol) %>%
tq_transmute(select = adjusted,
mutate_fun = periodReturn,
period = "daily",
col_rename = "return") %>%
pivot_wider(names_from = symbol, values_from = return) %>%
tk_xts(date_col = date, silent = TRUE)
# Benchmark daily returns
benchmark_ret <- prices_raw %>%
filter(symbol == benchmark) %>%
tq_transmute(select = adjusted,
mutate_fun = periodReturn,
period = "daily",
col_rename = "Benchmark") %>%
tk_xts(date_col = date, silent = TRUE)# Portfolio specification
port_spec <- portfolio.spec(assets = tickers) %>%
add.constraint(type = "full_investment") %>% # weights must sum to 1
add.constraint(type = "long_only") %>% # no short selling
add.constraint(type = "box", # 2%–20% per stock
min = 0.02, max = 0.20) %>%
add.objective(type = "return", name = "mean") %>%
add.objective(type = "risk", name = "StdDev")
# Optimize for Maximum Sharpe Ratio
opt <- optimize.portfolio(
R = returns_wide,
portfolio = port_spec,
optimize_method = "ROI",
maxSR = TRUE
)
weights <- extractWeights(opt)
# Display weights table
weights_df <- data.frame(
Ticker = names(weights),
Weight = as.numeric(weights)
) %>% arrange(desc(Weight)) %>%
mutate(Weight_pct = percent(Weight, accuracy = 0.1))
kable(weights_df[, c("Ticker","Weight_pct")],
col.names = c("Ticker", "Optimized Weight"),
caption = "Maximum Sharpe Ratio Portfolio Weights") %>%
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE)| Ticker | Optimized Weight |
|---|---|
| JNJ | 20.0% |
| VZ | 20.0% |
| JPM | 20.0% |
| ABBV | 12.0% |
| MMM | 9.0% |
| WFC | 7.2% |
| LMT | 5.4% |
| BRK-B | 2.3% |
| MRK | 2.0% |
| CVX | 2.0% |
ggplot(weights_df, aes(x = reorder(Ticker, Weight), y = Weight)) +
geom_col(fill = "#2563EB", alpha = 0.85, width = 0.65) +
geom_text(aes(label = percent(Weight, accuracy = 0.1)),
hjust = -0.1, size = 3.5, color = "#1e3a5f") +
coord_flip() +
scale_y_continuous(labels = percent, expand = expansion(mult = c(0, 0.15))) +
labs(title = "Optimized Portfolio Weights — Maximum Sharpe Ratio",
subtitle = "Box constraint: 2%–20% per stock",
x = NULL, y = "Weight") +
theme_minimal(base_size = 13) +
theme(panel.grid.major.y = element_blank())charts.PerformanceSummary(
combined,
main = "Value Portfolio vs S&P 500 (SPY) — 3-Year Backtest",
colorset = c("#2563EB", "#9CA3AF"),
legend.loc = "topleft"
)# Annualized returns, volatility, Sharpe
ann <- table.AnnualizedReturns(combined, Rf = rf_daily)
# Max drawdown
mdd <- maxDrawdown(combined)
# Alpha & Beta
alpha <- CAPM.alpha(portfolio_ret, benchmark_ret, Rf = rf_daily)
beta <- CAPM.beta(portfolio_ret, benchmark_ret, Rf = rf_daily)
# Compile summary
metrics <- data.frame(
Metric = c("Annualized Return",
"Annualized Volatility",
"Sharpe Ratio (annualized)",
"Maximum Drawdown",
"CAPM Alpha (annualized)",
"CAPM Beta"),
Value_Portfolio = c(
percent(as.numeric(ann[1, "Value_Portfolio"]), accuracy = 0.01),
percent(as.numeric(ann[2, "Value_Portfolio"]), accuracy = 0.01),
round(as.numeric(ann[3, "Value_Portfolio"]), 3),
percent(as.numeric(mdd["Value_Portfolio"]), accuracy = 0.01),
percent(as.numeric(alpha) * 252, accuracy = 0.01),
round(as.numeric(beta), 3)
),
Benchmark_SPY = c(
percent(as.numeric(ann[1, "Benchmark"]), accuracy = 0.01),
percent(as.numeric(ann[2, "Benchmark"]), accuracy = 0.01),
round(as.numeric(ann[3, "Benchmark"]), 3),
percent(as.numeric(mdd["Benchmark"]), accuracy = 0.01),
"—",
"1.000"
)
)
kable(metrics,
col.names = c("Metric", "Value Portfolio", "S&P 500 (SPY)"),
caption = "3-Year Backtest Performance Summary") %>%
kable_styling(bootstrap_options = c("striped","hover"),
full_width = FALSE) %>%
row_spec(which(metrics$Metric %in% c("Sharpe Ratio (annualized)",
"CAPM Alpha (annualized)")),
bold = TRUE)| Metric | Value Portfolio | S&P 500 (SPY) |
|---|---|---|
| Annualized Return | 24.13% | 22.85% |
| Annualized Volatility | 12.96% | 15.13% |
| Sharpe Ratio (annualized) | 1.441 | 1.153 |
| Maximum Drawdown | NA | NA |
| CAPM Alpha (annualized) | 10.52% | — |
| CAPM Beta | 0.432 | 1.000 |
chart.RollingPerformance(
combined,
width = 252,
FUN = "SharpeRatio.annualized",
Rf = rf_daily,
main = "Rolling 12-Month Sharpe Ratio — Portfolio vs Benchmark",
colorset = c("#2563EB", "#9CA3AF"),
legend.loc = "bottomleft"
)chart.Drawdown(
combined,
main = "Drawdown — Value Portfolio vs Benchmark",
colorset = c("#2563EB", "#9CA3AF"),
legend.loc = "bottomleft"
)# Full CAPM regression
capm_fit <- lm(
as.numeric(portfolio_ret) ~ as.numeric(benchmark_ret)
)
summary(capm_fit)##
## Call:
## lm(formula = as.numeric(portfolio_ret) ~ as.numeric(benchmark_ret))
##
## Residuals:
## Min 1Q Median 3Q Max
## -0.038807 -0.003978 -0.000265 0.004223 0.029736
##
## Coefficients:
## Estimate Std. Error t value Pr(>|t|)
## (Intercept) 0.0005190 0.0002586 2.007 0.0451 *
## as.numeric(benchmark_ret) 0.4321806 0.0270360 15.985 <2e-16 ***
## ---
## Signif. codes: 0 '***' 0.001 '**' 0.01 '*' 0.05 '.' 0.1 ' ' 1
##
## Residual standard error: 0.007053 on 748 degrees of freedom
## Multiple R-squared: 0.2546, Adjusted R-squared: 0.2536
## F-statistic: 255.5 on 1 and 748 DF, p-value: < 2.2e-16
In CAPM: Alpha (intercept) represents returns unexplained by market movement. A positive, statistically significant Alpha suggests genuine stock-picking skill. Beta < 1 implies lower sensitivity to market swings — desirable for a defensive value strategy.
Traditional value screening is purely quantitative — it ranks stocks by P/B or P/E and picks the cheapest. AI collaboration added two qualitative layers:
Contextual risk identification: Claude flagged MMM’s legal overhang as a potential value trap, prompting the addition of the D/E < 0.5 filter. A naive screener would have included it based on P/B alone.
Macro regime awareness: Claude noted that value stocks underperformed significantly during 2020–2022 when the Fed held rates near zero (growth stocks dominate in low-rate environments). This set realistic expectations for the backtest period and is important context for interpreting results.
[Complete this section after running the backtest with your actual results. Use the template below as a guide:]
If outperformance is observed: > “The portfolio generated an annualized Alpha of X%, consistent with the > Fama-French value premium hypothesis. The Sharpe ratio of Y exceeds the > benchmark’s Z, confirming superior risk-adjusted performance. The lower Beta > (< 1) aligns with the defensive characteristic expected from deep value stocks.”
If underperformance is observed (common in this period): > “The portfolio underperformed the benchmark over this specific 3-year window. > This is consistent with the literature: the value premium is cyclical. The > 2022–2024 period included a rapid rate-hiking cycle and subsequent tech-led > rally that disproportionately rewarded growth stocks (e.g., AI theme). Fama > and French themselves acknowledge that value underperforms for extended > sub-periods. A longer horizon (10+ years) is required to fully evaluate the > strategy.”
The following biases should be acknowledged in any honest backtest:
| Limitation | Description | Impact |
|---|---|---|
| Survivorship bias | Only stocks that existed for the full 3 years are in the dataset | Overstates returns |
| Look-ahead bias | Fundamental filters applied using data that would not have been available at the start date | Overstates returns |
| No transaction costs | Real trades incur commissions, bid-ask spreads, and market impact | Overstates returns |
| Quarterly rebalancing | Assumes perfect execution at quarterly close prices | Minor distortion |
| Optimization overfitting | Max Sharpe weights are tuned to the historical sample | Out-of-sample performance may differ significantly |
## R version 4.5.3 (2026-03-11 ucrt)
## Platform: x86_64-w64-mingw32/x64
## Running under: Windows 11 x64 (build 26200)
##
## Matrix products: default
## LAPACK version 3.12.1
##
## locale:
## [1] LC_COLLATE=English_United States.utf8
## [2] LC_CTYPE=English_United States.utf8
## [3] LC_MONETARY=English_United States.utf8
## [4] LC_NUMERIC=C
## [5] LC_TIME=English_United States.utf8
##
## time zone: Asia/Ulaanbaatar
## tzcode source: internal
##
## attached base packages:
## [1] stats graphics grDevices utils datasets methods base
##
## other attached packages:
## [1] kableExtra_1.4.0 knitr_1.51
## [3] scales_1.4.0 timetk_2.9.1
## [5] tidyr_1.3.2 dplyr_1.2.0
## [7] ggplot2_4.0.2 ROI.plugin.quadprog_1.0-1
## [9] ROI.plugin.glpk_1.0-0 ROI_1.0-2
## [11] PortfolioAnalytics_2.1.2 foreach_1.5.2
## [13] PerformanceAnalytics_2.1.0 quantmod_0.4.28
## [15] TTR_0.24.4 xts_0.14.2
## [17] zoo_1.8-15 tidyquant_1.0.12
##
## loaded via a namespace (and not attached):
## [1] rlang_1.1.7 magrittr_2.0.4
## [3] furrr_0.3.1 otel_0.2.0
## [5] compiler_4.5.3 systemfonts_1.3.2
## [7] vctrs_0.7.1 lhs_1.2.1
## [9] quadprog_1.5-8 stringr_1.6.0
## [11] tune_2.0.1 pkgconfig_2.0.3
## [13] fastmap_1.2.0 backports_1.5.0
## [15] labeling_0.4.3 rmarkdown_2.30
## [17] prodlim_2026.03.11 purrr_1.2.1
## [19] xfun_0.56 cachem_1.1.0
## [21] jsonlite_2.0.0 recipes_1.3.1
## [23] parallel_4.5.3 R6_2.6.1
## [25] bslib_0.10.0 rsample_1.3.2
## [27] stringi_1.8.7 RColorBrewer_1.1-3
## [29] parallelly_1.46.1 rpart_4.1.24
## [31] lubridate_1.9.5 jquerylib_0.1.4
## [33] numDeriv_2016.8-1.1 Rcpp_1.1.1
## [35] dials_1.4.2 iterators_1.0.14
## [37] future.apply_1.20.2 Matrix_1.7-4
## [39] splines_4.5.3 nnet_7.3-20
## [41] timechange_0.4.0 tidyselect_1.2.1
## [43] rstudioapi_0.18.0 yaml_2.3.12
## [45] timeDate_4052.112 codetools_0.2-20
## [47] curl_7.0.0 ROI.plugin.symphony_1.0-0
## [49] listenv_0.10.1 lattice_0.22-9
## [51] tibble_3.3.1 withr_3.0.2
## [53] S7_0.2.1 evaluate_1.0.5
## [55] future_1.70.0 survival_3.8-6
## [57] xml2_1.5.2 pillar_1.11.1
## [59] checkmate_2.3.4 generics_0.1.4
## [61] globals_0.19.1 class_7.3-23
## [63] glue_1.8.0 slam_0.1-55
## [65] mco_1.17 GenSA_1.1.15
## [67] lazyeval_0.2.3 tools_4.5.3
## [69] data.table_1.18.2.1 gower_1.0.2
## [71] registry_0.5-1 grid_4.5.3
## [73] yardstick_1.3.2 RobStatTM_1.0.11
## [75] ipred_0.9-15 cli_3.6.5
## [77] DiceDesign_1.10 textshaping_1.0.5
## [79] workflows_1.3.0 parsnip_1.4.1
## [81] viridisLite_0.4.3 pso_1.0.4
## [83] svglite_2.2.2 lava_1.8.2
## [85] Rsymphony_0.1-33 gtable_0.3.6
## [87] GPfit_1.0-9 sass_0.4.10
## [89] digest_0.6.39 farver_2.1.2
## [91] htmltools_0.5.9 Rglpk_0.6-5.1
## [93] lifecycle_1.0.5 hardhat_1.4.2
## [95] MASS_7.3-65