# 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)

1 Part I: Investment Thesis & Logic

1.1 Strategy Description

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.

1.1.1 Screening Criteria

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

1.1.2 Theoretical Basis for Abnormal Returns

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.

1.2 AI Collaboration Process

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.


2 Part II: Portfolio Construction

2.1 Asset Selection

# ── 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)
Selected Portfolio Assets
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%

2.2 Benchmark Selection

The S&P 500 (SPY) is selected as the benchmark because:

  1. All 10 portfolio stocks are US large-cap equities — the same universe SPY tracks.
  2. SPY is the standard benchmark for US equity strategies in both academic and practitioner settings.
  3. It provides a liquid, cost-effective comparison for a buy-and-hold strategy.

2.3 Data Download

# 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
cat("Tickers downloaded:", paste(unique(prices_raw$symbol), collapse = ", "), "\n")
## Tickers downloaded: BRK-B, JPM, CVX, JNJ, MRK, ABBV, MMM, LMT, WFC, VZ, SPY

2.4 Portfolio Optimization (Maximum Sharpe Ratio)

# 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)
Maximum Sharpe Ratio Portfolio Weights
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())


3 Part III: Backtesting & Performance Analysis

3.1 Compute Portfolio Returns

# Quarterly rebalancing to optimized weights
portfolio_ret <- Return.portfolio(
  returns_wide,
  weights      = weights,
  rebalance_on = "quarters"
)
colnames(portfolio_ret) <- "Value_Portfolio"

# Combine with benchmark, align dates
combined <- merge.xts(portfolio_ret, benchmark_ret, join = "inner")

3.2 Cumulative Return vs Benchmark

charts.PerformanceSummary(
  combined,
  main      = "Value Portfolio vs S&P 500 (SPY) — 3-Year Backtest",
  colorset  = c("#2563EB", "#9CA3AF"),
  legend.loc = "topleft"
)

3.3 Performance Metrics Table

# 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)
3-Year Backtest Performance Summary
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

3.4 Sharpe Ratio — Interpretation

sr_port  <- as.numeric(SharpeRatio.annualized(portfolio_ret, Rf = rf_daily))
sr_bench <- as.numeric(SharpeRatio.annualized(benchmark_ret, Rf = rf_daily))

cat(sprintf(
  "Portfolio Sharpe: %.3f | Benchmark Sharpe: %.3f | Difference: %+.3f\n",
  sr_port, sr_bench, sr_port - sr_bench
))
## Portfolio Sharpe: 1.386 | Benchmark Sharpe: 1.138 | Difference: +0.248

A Sharpe ratio above the benchmark indicates better risk-adjusted returns per unit of volatility — the key metric for evaluating whether Alpha is real or merely leverage in disguise.

3.5 Rolling 12-Month Sharpe Ratio

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"
)

3.6 Drawdown Analysis

chart.Drawdown(
  combined,
  main     = "Drawdown — Value Portfolio vs Benchmark",
  colorset = c("#2563EB", "#9CA3AF"),
  legend.loc = "bottomleft"
)

3.7 Alpha & Beta — CAPM Interpretation

# 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.


4 Part IV: Critical Reflection

4.1 What AI Surfaced That Traditional Analysis Might Miss

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:

  1. 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.

  2. 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.

4.2 Do the Results Align with the Initial Hypothesis?

[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.”

4.3 Backtesting Limitations

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

5 Session Info

sessionInfo()
## 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