PortfolioTesteR makes quantitative investing accessible through intuitive, English-like syntax. This vignette walks through five strategies from beginner to advanced, all using the included sample data so it builds quickly and reliably.
Note: Some functions also support external data (e.g.,
yahoo_adapter()
), but for CRAN-friendly vignettes we use the bundled datasets.
Buy the stocks with the highest 12-week returns, weight them equally, and backtest.
# Load included weekly prices
data(sample_prices_weekly)
# 1) Momentum signal
momentum <- calc_momentum(sample_prices_weekly, lookback = 12)
# 2) Select top 10 by momentum
selected <- filter_top_n(momentum, n = 10)
# 3) Equal weights
weights <- weight_equally(selected)
# 4) Backtest
result1 <- run_backtest(
prices = sample_prices_weekly,
weights = weights,
initial_capital = 100000,
name = "Simple Momentum"
)
# 5) Results
print(result1)
#> Backtest Result: Simple Momentum
#> =====================================
#> Period: 2017-01-06 to 2019-12-31 (158 observations)
#> Initial Capital: $1e+05
#> Final Value: $166,806
#> Total Return: 66.81%
#> Transactions: 1490
#>
#> Annualized Return: 18.34%
#> Annualized Volatility: 13.03%
#> Sharpe Ratio: 1.41
#> Max Drawdown: -16.61%
summary(result1)
#>
#> Detailed Summary: Simple Momentum
#> =====================================
#>
#> Position Statistics:
#> Average positions held: 10.0
#> Max positions held: 10
#> Periods fully invested: 158 (100.0%)
#>
#> Transaction Summary:
#> Total trades: 1490
#> Avg trades per period: 9.4
#> Annual turnover: 728.8%
#>
#> Return Distribution:
#> Mean return: 0.341%
#> Median return: 0.520%
#> Best period: 5.19%
#> Worst period: -6.86%
#> Positive periods: 97 (61.4%)
Combine momentum (good = high) and stability (good = low volatility). Select stocks that rank well on both, then combine weights.
# Need daily data for volatility
data(sample_prices_daily)
# A) Momentum (12-week)
momentum <- calc_momentum(sample_prices_weekly, lookback = 12)
# B) Daily volatility → align weekly → invert (low vol = high score)
daily_vol <- calc_rolling_volatility(sample_prices_daily, lookback = 20)
weekly_vol <- align_to_timeframe(
high_freq_data = daily_vol,
low_freq_dates = sample_prices_weekly$Date,
method = "forward_fill"
)
stability_signal <- invert_signal(weekly_vol)
# Select top 20 for each signal
m_sel <- filter_top_n(momentum, n = 20)
s_sel <- filter_top_n(stability_signal, n = 20)
# AND-combine the selections
both <- combine_filters(m_sel, s_sel, op = "and")
# Weight each way then blend 60/40
w_mom <- weight_by_signal(both, momentum)
w_stab <- weight_by_signal(both, stability_signal)
weights2 <- combine_weights(list(w_mom, w_stab), weights = c(0.6, 0.4))
# Backtest
result2 <- run_backtest(
prices = sample_prices_weekly,
weights = weights2,
initial_capital = 100000,
name = "Momentum + Low Vol"
)
print(result2)
#> Backtest Result: Momentum + Low Vol
#> =====================================
#> Warmup Period: 4 observations (no trading)
#> Active Period: 2017-02-03 to 2019-12-31 (154 observations)
#> Initial Capital: $1e+05
#> Final Value: $165,430
#> Total Return (active period): 65.43%
#> Total Return (full period): 65.43%
#> Transactions: 2579
#>
#> Annualized Return: 18.53%
#> Annualized Volatility: 12.65%
#> Sharpe Ratio: 1.46
#> Max Drawdown: -13.99%
summary(result2)
#>
#> Detailed Summary: Momentum + Low Vol
#> =====================================
#>
#> Position Statistics:
#> Average positions held: 19.5
#> Max positions held: 20
#> Periods fully invested: 62 (39.2%)
#>
#> Transaction Summary:
#> Total trades: 2579
#> Avg trades per period: 16.3
#> Annual turnover: 574.1%
#>
#> Return Distribution:
#> Mean return: 0.334%
#> Median return: 0.557%
#> Best period: 5.45%
#> Worst period: -5.93%
#> Positive periods: 98 (62.0%)
Add a 15% stop loss monitored on daily prices. (We compare the same strategy with and without stops.)
# Signals and selection
momentum <- calc_momentum(sample_prices_weekly, lookback = 12)
sel <- filter_top_n(momentum, n = 10)
weights_mom <- weight_by_signal(sel, momentum)
# With 15% stop-loss (daily monitoring)
result3_with <- run_backtest(
prices = sample_prices_weekly,
weights = weights_mom,
initial_capital = 100000,
name = "Momentum with 15% Stop Loss",
stop_loss = 0.15,
stop_monitoring_prices = sample_prices_daily
)
# Without stop-loss
result3_no <- run_backtest(
prices = sample_prices_weekly,
weights = weights_mom,
initial_capital = 100000,
name = "Momentum without Stop Loss"
)
cat("WITH Stop Loss:\n")
#> WITH Stop Loss:
print(result3_with)
#> Backtest Result: Momentum with 15% Stop Loss
#> =====================================
#> Warmup Period: 12 observations (no trading)
#> Active Period: 2017-03-31 to 2019-12-31 (146 observations)
#> Initial Capital: $1e+05
#> Final Value: $162,260
#> Total Return (active period): 62.26%
#> Total Return (full period): 62.26%
#> Transactions: 1513
#>
#> Annualized Return: 18.81%
#> Annualized Volatility: 13.60%
#> Sharpe Ratio: 1.38
#> Max Drawdown: -12.28%
cat("\nWITHOUT Stop Loss:\n")
#>
#> WITHOUT Stop Loss:
print(result3_no)
#> Backtest Result: Momentum without Stop Loss
#> =====================================
#> Warmup Period: 12 observations (no trading)
#> Active Period: 2017-03-31 to 2019-12-31 (146 observations)
#> Initial Capital: $1e+05
#> Final Value: $162,260
#> Total Return (active period): 62.26%
#> Total Return (full period): 62.26%
#> Transactions: 1513
#>
#> Annualized Return: 18.81%
#> Annualized Volatility: 13.60%
#> Sharpe Ratio: 1.38
#> Max Drawdown: -12.28%
Detect market volatility regimes using SPY’s rolling volatility. Use defensive weights in high-vol regimes and aggressive weights in low-vol regimes.
# Extract SPY for regime detection
spy_prices <- sample_prices_weekly[, .(Date, SPY)]
# Trading universe (exclude SPY)
trading_symbols <- setdiff(names(sample_prices_weekly), c("Date", "SPY"))
trading_prices <- sample_prices_weekly[, c("Date", trading_symbols), with = FALSE]
trading_daily <- sample_prices_daily[, c("Date", trading_symbols), with = FALSE]
# SPY weekly returns & 20-week rolling volatility (annualized)
spy_returns <- c(NA, diff(spy_prices$SPY) / head(spy_prices$SPY, -1))
spy_vol <- zoo::rollapply(spy_returns, width = 20, FUN = sd, fill = NA, align = "right") * sqrt(52)
# High-vol regime = above median
vol_threshold <- median(spy_vol, na.rm = TRUE)
high_vol <- spy_vol > vol_threshold
# Selection by momentum
mom <- calc_momentum(trading_prices, lookback = 12)
sel <- filter_top_n(mom, n = 15)
# Defensive (prefer low vol) vs Aggressive (prefer high vol) weights
w_def <- weight_by_volatility(
selected_df = sel,
vol_timeframe_data = trading_daily,
strategy_timeframe_data = trading_prices,
lookback_periods = 20,
low_vol_preference = TRUE,
vol_method = "std"
)
w_agg <- weight_by_volatility(
selected_df = sel,
vol_timeframe_data = trading_daily,
strategy_timeframe_data = trading_prices,
lookback_periods = 20,
low_vol_preference = FALSE,
vol_method = "std"
)
# Switch weights by regime (defensive when high-vol is TRUE)
weights4 <- switch_weights(
weights_a = w_agg, # used when condition is FALSE (low vol)
weights_b = w_def, # used when condition is TRUE (high vol)
use_b_condition = high_vol
)
result4 <- run_backtest(
prices = trading_prices,
weights = weights4,
initial_capital = 100000,
name = "Regime-Adaptive Strategy"
)
print(result4)
#> Backtest Result: Regime-Adaptive Strategy
#> =====================================
#> Warmup Period: 4 observations (no trading)
#> Active Period: 2017-02-03 to 2019-12-31 (154 observations)
#> Initial Capital: $1e+05
#> Final Value: $144,325
#> Total Return (active period): 44.32%
#> Total Return (full period): 44.32%
#> Transactions: 2273
#>
#> Annualized Return: 13.19%
#> Annualized Volatility: 12.91%
#> Sharpe Ratio: 1.02
#> Max Drawdown: -18.46%
summary(result4)
#>
#> Detailed Summary: Regime-Adaptive Strategy
#> =====================================
#>
#> Position Statistics:
#> Average positions held: 14.6
#> Max positions held: 15
#> Periods fully invested: 130 (82.3%)
#>
#> Transaction Summary:
#> Total trades: 2273
#> Avg trades per period: 14.4
#> Annual turnover: 940.3%
#>
#> Return Distribution:
#> Mean return: 0.248%
#> Median return: 0.404%
#> Best period: 4.41%
#> Worst period: -5.85%
#> Positive periods: 92 (58.2%)
Combine momentum and stability signals, then enforce a max positions limit to control concentration. Weight 70% by momentum strength and 30% by stability.
# Signals
momentum <- calc_momentum(sample_prices_weekly, lookback = 12)
daily_vol <- calc_rolling_volatility(sample_prices_daily, lookback = 20)
weekly_vol <- align_to_timeframe(daily_vol, sample_prices_weekly$Date, method = "forward_fill")
stability <- invert_signal(weekly_vol)
# Selection & position cap
top30 <- filter_top_n(momentum, n = 30)
sel15 <- limit_positions(top30, momentum, max_positions = 15)
# Weights and blend (70/30)
w_m <- weight_by_signal(sel15, momentum)
w_s <- weight_by_signal(sel15, stability)
weights5 <- combine_weights(list(w_m, w_s), weights = c(0.7, 0.3))
# Backtest
result5 <- run_backtest(
prices = sample_prices_weekly,
weights = weights5,
initial_capital = 100000,
name = "Multi-Factor with Position Limits"
)
print(result5)
#> Backtest Result: Multi-Factor with Position Limits
#> =====================================
#> Warmup Period: 4 observations (no trading)
#> Active Period: 2017-02-03 to 2019-12-31 (154 observations)
#> Initial Capital: $1e+05
#> Final Value: $163,629
#> Total Return (active period): 63.63%
#> Total Return (full period): 63.63%
#> Transactions: 2281
#>
#> Annualized Return: 18.09%
#> Annualized Volatility: 12.92%
#> Sharpe Ratio: 1.40
#> Max Drawdown: -13.56%
summary(result5)
#>
#> Detailed Summary: Multi-Factor with Position Limits
#> =====================================
#>
#> Position Statistics:
#> Average positions held: 14.6
#> Max positions held: 15
#> Periods fully invested: 115 (72.8%)
#>
#> Transaction Summary:
#> Total trades: 2281
#> Avg trades per period: 14.4
#> Annual turnover: 781.0%
#>
#> Return Distribution:
#> Mean return: 0.328%
#> Median return: 0.480%
#> Best period: 5.34%
#> Worst period: -6.19%
#> Positive periods: 99 (62.7%)
Advanced Strategy: StochRSI Acceleration + Inverse-Vol Risk Parity - Gate to high StochRSI (>= 0.80), then select top-12 by acceleration - Allocate by inverse-volatility risk parity using DAILY prices - Backtest on the weekly grid (bundled datasets only; CRAN-friendly)
# Data
data(sample_prices_weekly)
data(sample_prices_daily)
# Exclude broad ETFs from stock-selection universe
symbols_all <- setdiff(names(sample_prices_weekly), "Date")
stock_symbols <- setdiff(symbols_all, c("SPY", "TLT"))
weekly_stocks <- sample_prices_weekly[, c("Date", stock_symbols), with = FALSE]
daily_stocks <- sample_prices_daily[, c("Date", stock_symbols), with = FALSE]
# StochRSI "acceleration" signal (weekly)
stochrsi <- calc_stochrsi(weekly_stocks, length = 14) # in [0,1]
stochrsi_ma <- calc_moving_average(stochrsi, window = 5)
accel <- calc_distance(stochrsi, stochrsi_ma) # positive = rising
# Gate to high StochRSI zone, then take top-12 by acceleration
high_zone <- filter_above(stochrsi, value = 0.80)
sel <- filter_top_n_where(
signal_df = accel,
n = 12,
condition_df = high_zone,
min_qualified = 8,
ascending = FALSE
)
# Allocation: inverse-volatility risk parity (DAILY prices)
w_ivol <- weight_by_risk_parity(
selected_df = sel,
prices_df = daily_stocks,
method = "inverse_vol",
lookback_periods = 126, # ~6 months
min_periods = 60
)
# Backtest on the weekly grid
res_stochrsi <- run_backtest(
prices = weekly_stocks,
weights = w_ivol,
initial_capital = 100000,
name = "StochRSI Accel + InvVol RP"
)
print(res_stochrsi)
#> Backtest Result: StochRSI Accel + InvVol RP
#> =====================================
#> Warmup Period: 30 observations (no trading)
#> Active Period: 2017-08-04 to 2019-12-31 (128 observations)
#> Initial Capital: $1e+05
#> Final Value: $115,642
#> Total Return (active period): 15.64%
#> Total Return (full period): 15.64%
#> Transactions: 682
#>
#> Annualized Return: 6.08%
#> Annualized Volatility: 6.29%
#> Sharpe Ratio: 0.97
#> Max Drawdown: -6.15%
summary(res_stochrsi)
#>
#> Detailed Summary: StochRSI Accel + InvVol RP
#> =====================================
#>
#> Position Statistics:
#> Average positions held: 3.3
#> Max positions held: 12
#> Periods fully invested: 50 (31.6%)
#>
#> Transaction Summary:
#> Total trades: 682
#> Avg trades per period: 4.3
#> Annual turnover: 703.0%
#>
#> Return Distribution:
#> Mean return: 0.095%
#> Median return: 0.000%
#> Best period: 3.13%
#> Worst period: -3.72%
#> Positive periods: 32 (20.3%)
Below is a minimal example that fetches prices from Yahoo Finance and
runs the same “calculate → filter → weight → backtest” pipeline. The
code is disabled inside CRAN/devtools checks. To run it
locally, set Sys.setenv(RUN_LIVE = "true")
before
knitting.
library(PortfolioTesteR)
# Fetch weekly data for a small set of tickers
tickers <- c("AAPL","MSFT","AMZN","GOOGL","META")
px_weekly <- yahoo_adapter(
symbols = tickers,
frequency = "weekly"
)
# Simple momentum: top-3 by 12-week return, equal weight
mom <- calc_momentum(px_weekly, lookback = 12)
sel <- filter_top_n(mom, n = 3)
w_eq <- weight_equally(sel)
res_yh <- run_backtest(
prices = px_weekly,
weights = w_eq,
initial_capital = 100000,
name = "Yahoo: Simple Momentum (Top 3)"
)
print(res_yh)
summary(res_yh)
The PortfolioTesteR Pattern
Function Families
calc_momentum()
,
calc_rsi()
, calc_rolling_volatility()
,
calc_moving_average()
filter_top_n()
,
filter_above()
, filter_between()
,
combine_filters()
weight_equally()
,
weight_by_signal()
, weight_by_rank()
,
weight_by_volatility()
, combine_weights()
run_backtest()
align_to_timeframe()
,
invert_signal()
, limit_positions()
,
switch_weights()
analyze_performance()
,
summary()
, plot()
If you use PortfolioTesteR in your research, please cite:
Pallotta, A. (2025). PortfolioTesteR: Test Investment Strategies with English-Like Code. R package version 0.1.0. https://github.com/alb3rtazzo/PortfolioTesteR