When the conditions for entry are met, the strategy will exit either when the price drops to the 10-day low or when a trailing stop is reached, set at twice the Average True Range (ATR). The ATR is a measure of market volatility, providing a dynamic stop that adjusts based on recent price fluctuations.
| Component | Description |
|---|---|
| Indicator | 20-day high represents the highest price in the past 20 days; 10-day EMA is the average price over the last 10 days, weighted toward recent prices; 30-day EMA is the average over the last 30 days. |
| Signal | Enter long when the price breaks above the 20-day high and the 10-day EMA is greater than the 30-day EMA. |
| Rule | Exit the position when the price reaches the 10-day low or hits a trailing stop set at twice the ATR. |
One significant caveat of this backtest is that while it generated a positive overall return, the strategy exhibited a maximum drawdown of 25.63%. This decline was notably less severe compared to the 60.66% maximum drawdown of the equal-weight buy-and-hold basket and 35.12% of the QQQ index, indicating better risk management in this regard. Despite this, the strategy’s overall performance did not match the benchmarks, highlighting the importance of evaluating risk alongside return.
| Side-by-Side Comparison | |||
| Benchmark CAGR and drawdown are computed from daily closes over the same window; Sharpe is omitted because the methodologies differ | |||
| Metric | Strategy | Equal-weight buy & hold basket (7 symbols) | Buy & hold QQQ |
|---|---|---|---|
| Total return | +107.9% | +244286.4% | +399.4% |
| Annualized return (CAGR) | +8.5% | +138.1% | +19.6% |
| Max drawdown | -25.6% | -60.7% | -35.1% |
| Ending equity | $207,892 | $244,386,399 | $499,433 |
| Backtest Summary | |
| Every figure computed from this run’s saved results | |
| Metric | Value |
|---|---|
| Starting capital | $100,000 |
| Ending capital | $207,892 |
| Total return | +107.89% |
| Annualized return (CAGR) | +8.47% |
| Closed trades | 152 |
| Sharpe ratio (annualized) | 1.34 |
| Calmar ratio | 1.08 |
| Max drawdown | -25.63% |
| Equal-weight buy & hold basket (7 symbols) | +244286.40% |
| Strategy vs. Equal-weight buy & hold basket (7 symbols) | -244178.51 pp |
| Buy & hold QQQ | +399.43% |
| Strategy vs. Buy & hold QQQ | -291.54 pp |
The strategy executed a total of 152 trades during the backtest period from January 1, 2016, to January 1, 2025. It achieved a total return of 107.89%, which translates to a compounded annual growth rate (CAGR) of 8.47%. However, this performance lagged behind the benchmark of an equal-weight buy-and-hold basket, which returned 244,286.40%, indicating the strategy underperformed by a significant margin.
The Sharpe ratio stands at 1.34, which means that the returns earned were 1.34 times the volatility taken on. In other words, for each unit of risk, the strategy generated a positive return, suggesting a reasonable risk-reward balance. At the trade level, the absence of closed trades listed means that there are no trade details to analyze; thus, it is hard to gauge specific patterns in profitability or consistency.
The monthly returns reveal that the strategy had 22 positive months compared to 13 negative months, indicating a tendency for more frequent gains. The average return during positive months was higher than the average loss during negative months, specifically demonstrating that gains were stronger on average, this pattern aligns with the strategy’s design to capitalize on upward momentum while managing risk through its exit criteria.
| Risk-Adjusted Return Metrics | ||
| Every number here comes straight from this backtest’s quantstr.at report | ||
| Metric | Value | What It Measures |
|---|---|---|
| Annualized Return | 27.79% | Return scaled to a one-year rate |
| Annualized Volatility | 20.74% | How much returns swing year to year |
| Sharpe Ratio | 1.340 | Return per unit of total volatility — the classic risk-adjusted return measure |
| Sortino Ratio | 0.130 | Return per unit of downside volatility only — ignores upside swings that Sharpe penalizes unfairly |
| Calmar Ratio | 1.085 | Return relative to the worst peak-to-trough drawdown |
| Omega Ratio | 0.290 | Probability-weighted ratio of gains to losses — doesn’t assume a normal return distribution the way Sharpe does |
| Downside Deviation | 0.811% | Volatility of losing periods only |
| Upside Potential Ratio | 0.756 | Upside capture relative to downside risk — a Sortino-style ratio facing the other direction |
| Probability Sharpe > 0 | 99.15% | Statistical confidence the strategy’s TRUE Sharpe ratio (not just this one sample) is greater than zero |
| Min. Track Record Needed | 358 | Minimum number of return observations needed for that confidence level to be meaningful |
| Sharpe Statistically Significant? | Yes | Whether this backtest has enough history for its Sharpe ratio to be statistically real, not noise |

A raw return figure can be misleading because it doesn’t account for the risk taken to achieve those returns. For example, a strategy might return 20% by taking significant risks, but a more modest 10% return could be achieved with much less volatility.
In this backtest, the Sharpe ratio of 1.34 indicates that the strategy generated returns 1.34 times the total volatility, suggesting a reasonable risk-reward balance. The Sortino ratio of 0.13, however, focuses only on downside volatility, revealing that the risk of losing is not well compensated by the upside, which contrasts starkly with the higher Sharpe. The Calmar ratio of 1.08 compares the return to maximum drawdown, further underscoring the strategy’s susceptibility to significant declines.
The "Probability Sharpe > 0" at 99.15% suggests strong statistical confidence that the true Sharpe ratio is positive, which is meaningful. Additionally, since this backtest’s Sharpe ratio is statistically significant, we can trust it provides insightful data. For your own reports, start by comparing the Sharpe and Sortino ratios, followed by the Calmar ratio for a comprehensive risk assessment.
The trade-level significance test indicates that the results should be interpreted with caution. Since there were multiple trades across a few symbols and the p-value reflects the statistical relevance of the results, it is important to note that the nature of overlapping trades means the p-value serves as an indicative measure rather than conclusive evidence.
| Performance by Symbol | ||||
| 7 symbols, closed trades only | ||||
| Symbol | Trades | Net P&L | Win rate | Profit factor |
|---|---|---|---|---|
| QQQ | 37 | +$51,119 | 54.1% | 2.95 |
| MSFT | 37 | +$35,846 | 45.9% | 2.19 |
| NVDA | 41 | +$15,656 | 29.3% | 1.58 |
| AAPL | 37 | +$6,664 | 40.5% | 1.28 |
| XLF | 0 | – | – | – |
| XLP | 0 | – | – | – |
| XLE | 0 | – | – | – |
In terms of risk-adjusted return metrics, the Sharpe ratio is 1.34, suggesting that the returns generated were relatively favorable compared to the level of risk taken. However, the Sortino ratio of 0.13 indicates a substantial disparity between upside potential and downside risk. This divergence highlights a caveat: although the total returns are positive, they may not adequately compensate for the risks involved, especially given that the average drawdown length was 17.82 days. This risk profile should give potential traders pause, as it signals that while profits exist, they come with significant volatility.
The integrity audit failed due to two key issues: first, the trade list did not reconcile with the per-symbol trade statistics, indicating a potential mismatch in reported trades. Second, there was no verification of the gross long exposure relative to the portfolio equity, leaving uncertainty about the strategy’s true risk exposure. These failures suggest that the reported performance might not fully reflect reliable trade execution or risk management.
| Monthly Returns Matrix (%) | |||||||||||||
| Month-by-month and YTD cumulative performance summary | |||||||||||||
| Year | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec | YTD |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2023 | 0.0% | 2.2% | 5.8% | 2.9% | 11.4% | 2.5% | 4.9% | -5.7% | -2.5% | 1.0% | 13.4% | 2.1% | 43.1% |
| 2024 | 4.1% | 0.4% | -0.6% | -2.8% | 2.8% | 26.5% | -1.4% | -0.6% | 1.3% | -3.9% | 0.5% | 6.2% | 33.7% |
| 2025 | -9.5% | -0.6% | -6.7% | -5.1% | 13.4% | 7.9% | 11.4% | 0.7% | 3.1% | 7.2% | -8.1% | -2.1% | 8.6% |
Over the tracking period from January 2023 to January 2025, the strategy experienced a mix of monthly returns, with 22 positive months and 13 negative months. The best month was June 2024, which had a return of 24.48%. Conversely, the worst month was January 2025, with a return of -6.33%.
The most significant drawdown occurred between July 11, 2024, and April 16, 2025, where it reached a depth of -25.63%. This episode aligns with the overall trend of returns, showing that significant declines occurred during a challenging market period.
The primary caveat to consider is the considerable maximum drawdown of 25.63%, which indicates potential for substantial losses during the backtest period. Moreover, the integrity audit highlighted issues, particularly regarding the reconciliation of trade statistics, suggesting that the reported figures may not fully reflect reliable trade execution. Always remember, this walkthrough demonstrates past performance and does not predict future results. If you’re interested, feel free to explore your own strategy ideas using quantstr.at.
The strategy’s maximum drawdown of 25.63% indicates significant risk exposure, which could make it challenging for traders during downturns. Consider adjusting the exit conditions to include a stop-loss to potentially limit losses and manage risk more effectively.
Enter long when the price breaks above the 20-day high and the 10-day EMA is greater than the 30-day EMA. Exit when the price reaches the 10-day low or hits a trailing stop set at twice the ATR, or if the price falls below a stop-loss of 1.5% from the entry price.
| Backtest Setup | |
| Read from this run’s saved configuration | |
| Parameter | Setting / Value |
|---|---|
| Asset universe | 7 symbols (XLF, XLP, XLE, AAPL, MSFT, NVDA, QQQ) |
| Time window | 2016-01-01 to 2025-01-01 (9.0 years) |
| Starting equity | $100,000 |
| Benchmarks | Equal-weight buy & hold basket (7 symbols) (+244286.4%); Buy & hold QQQ (+399.4%) |
| Trade Statistics by Symbol | |||||||||||||||||||||||||||
| Detailed performance and risk metrics breakdown per traded asset | |||||||||||||||||||||||||||
| Symbol | Trades | Net P&L | Win Rate | Loss Rate | Max Equity | Min Equity | Gross Profits | Gross Losses | Max Drawdown | Transactions | Avg Daily P&L | Avg Trade P&L | Ending Equity | Largest Winner | Largest Loser | Profit Factor | Avg Winning Trade | Avg Losing Trade | Median Trade P&L | Annualized Sharpe | Daily P&L Std Dev | Trade P&L Std Dev | Avg Win/Loss Ratio | Median Losing Trade | Median Winning Trade | Median Win/Loss Ratio | Profit / Max Drawdown |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| XLF | 0 | $0 | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
| XLP | 0 | $0 | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
| XLE | 0 | $0 | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — | — |
| AAPL | 37 | $6,664 | 40.5% | 59.5% | $15,684 | -$1,542 | $30,859 | -$24,195 | -$17,226 | 74 | $180 | $180 | $6,664 | $6,807 | -$5,467 | 1.28 | $2,057 | -$1,100 | -$229 | 1.27 | $2,243 | $2,243 | 1.87 | -$868 | $1,409 | 1.62 | 0.39 |
| MSFT | 37 | $35,846 | 45.9% | 54.1% | $42,783 | $0 | $66,072 | -$30,227 | -$16,173 | 75 | $969 | $969 | $35,346 | $25,652 | -$4,329 | 2.19 | $3,887 | -$1,511 | -$171 | 3.09 | $4,977 | $4,977 | 2.57 | -$1,195 | $1,403 | 1.17 | 2.19 |
| NVDA | 41 | $15,656 | 29.3% | 70.7% | $26,302 | $0 | $42,555 | -$26,899 | -$18,061 | 83 | $382 | $382 | $16,172 | $22,605 | -$3,469 | 1.58 | $3,546 | -$928 | -$63 | 1.41 | $4,304 | $4,304 | 3.82 | -$948 | $538 | 0.57 | 0.90 |
| QQQ | 37 | $51,119 | 54.1% | 45.9% | $59,632 | -$1,585 | $77,361 | -$26,243 | -$11,237 | 75 | $1,382 | $1,382 | $49,711 | $18,476 | -$4,199 | 2.95 | $3,868 | -$1,544 | $190 | 4.97 | $4,414 | $4,414 | 2.51 | -$1,776 | $1,472 | 0.83 | 4.42 |
The strategy does not use a separate stock-level indicator. It assigns 100% of the allotted capital to the stock when the market condition is satisfied, with no leverage, and moves to cash otherwise. The condition is evaluated at each day’s close, with any resulting action taken at the next day’s open.
| Component | Description |
|---|---|
| Indicator | SPY 100-day simple moving average: the average of SPY’s closing prices over the 100-day lookback period. |
| Signal | A buy or hold signal occurs when SPY closes above its own 100-day SMA. A cash signal occurs when SPY does not close above that average. |
| Rule | At the next day’s open, hold the tested stock with 100% of allotted capital when the buy or hold signal is present. Otherwise, exit the stock position and hold cash. |
| How the Rule Was Chosen (Training Data Only) | |||||
| 9 variants evaluated on 214 training tickers, 2006-01-01 to 2014-12-31; none of this data is in the test below | |||||
| Variant | Sharpe (strategy) | Sharpe (buy & hold) | Max drawdown (strategy) | Max drawdown (buy & hold) | Selected |
|---|---|---|---|---|---|
| Own-trend filter, 100-day | 0.63 | 0.76 | 28.63% | 52.58% | |
| Market-trend filter, 100-day | 1.07 | 0.76 | 19.96% | 52.58% | Yes |
| Own + market trend filter, 100-day | 0.88 | 0.76 | 12.16% | 52.58% | |
| Own-trend filter, 150-day | 0.63 | 0.76 | 26.88% | 52.58% | |
| Market-trend filter, 150-day | 0.84 | 0.76 | 27.52% | 52.58% | |
| Own + market trend filter, 150-day | 0.80 | 0.76 | 16.01% | 52.58% | |
| Own-trend filter, 200-day | 0.64 | 0.76 | 26.42% | 52.58% | |
| Market-trend filter, 200-day | 0.80 | 0.76 | 27.70% | 52.58% | |
| Own + market trend filter, 200-day | 0.74 | 0.76 | 21.32% | 52.58% | |
The main caveat is that the tickers were current S&P 500, S&P 400, and S&P 600 constituents, so companies removed from those indexes during the window are absent, creating survivorship bias. Also, the 989 tickers were not independent: their mean pairwise return correlation was 0.29, and the portfolio excess-return interval ran from -12.42 to 2.67 percentage points, which includes zero.
| Headline Results | |
| Every ticker is its own backtest, compared with buying and holding that ticker | |
| Metric | Value |
|---|---|
| Tickers tested | 989 |
| Strategy beat buy-and-hold (CAGR) | 306 of 989 (30.9%) |
| Mean excess CAGR | -2.40 pp |
| Median excess CAGR | -2.68 pp |
| Bootstrap 95% interval for the mean | -2.86 to -1.93 pp |
| Smaller max drawdown than buy-and-hold | 81.5% of tickers |
| Median max drawdown (strategy vs buy-and-hold) | 51.83% vs 62.86% |
| Median time in market | 79.1% |


The strategy beat buy-and-hold on an annualized growth rate (CAGR) basis for 306 of 989 tickers, or 30.9%. The excess CAGR versus buy-and-hold had a mean of -2.40 percentage points, a median of -2.68 percentage points, a 10th percentile of -9.15 percentage points, and a 90th percentile of 4.37 percentage points. This means the strategy lagged on most tickers, although the gap was positive for some.
The strategy had a smaller maximum drawdown, the largest decline from a previous peak, on 81.5% of tickers. Median maximum drawdown was 51.83% for the strategy versus 62.86% for buy-and-hold. Its Sharpe ratio, a return measure adjusted for volatility, was better on 32.2% of tickers, with median values of 0.39 versus 0.45, and median time in the market was 79.1%.
Results were consistent in the broad share of winners across the two sub-periods: the strategy beat buy-and-hold on 33.7% of tickers in the first half and 33.5% in the second half. Median excess CAGR remained negative in both, at -22.83 percentage points and -18.64 percentage points. By sector, Communication Services had the highest beat share at 48.5%, followed by Real Estate at 45.3% and Consumer Discretionary at 41.2%; Utilities stood out on the low end at 2.7%, while every sector listed had a negative median excess CAGR.
| By Sector | |||
| Sectors with at least 15 tickers | |||
| Sector | Tickers | Beat buy-and-hold | Median excess CAGR |
|---|---|---|---|
| Communication Services | 33 | 48.5% | -0.70 pp |
| Consumer Discretionary | 131 | 41.2% | -1.01 pp |
| Consumer Staples | 51 | 19.6% | -2.72 pp |
| Energy | 42 | 28.6% | -3.03 pp |
| Financials | 176 | 38.6% | -1.26 pp |
| Health Care | 109 | 36.7% | -2.03 pp |
| Industrials | 174 | 24.7% | -3.99 pp |
| Information Technology | 124 | 21.0% | -4.76 pp |
| Materials | 48 | 14.6% | -3.91 pp |
| Real Estate | 64 | 45.3% | -0.10 pp |
| Utilities | 37 | 2.7% | -4.19 pp |
| Best and Worst Tickers | ||||
| Ranked by strategy CAGR minus buy-and-hold CAGR | ||||
| Group | Ticker | Strategy CAGR | Buy & hold CAGR | Trades |
|---|---|---|---|---|
| Best 10 (vs buy-and-hold) | DAVE | 61.6% | 0.7% | 21 |
| Best 10 (vs buy-and-hold) | CVNA | 84.1% | 39.8% | 39 |
| Best 10 (vs buy-and-hold) | RELY | 15.2% | -16.5% | 15 |
| Best 10 (vs buy-and-hold) | BE | 71.3% | 39.9% | 33 |
| Best 10 (vs buy-and-hold) | ARLO | 26.2% | -4.8% | 33 |
| Best 10 (vs buy-and-hold) | PTCT | 25.4% | 1.8% | 56 |
| Best 10 (vs buy-and-hold) | KD | -3.7% | -26.0% | 15 |
| Best 10 (vs buy-and-hold) | OMCL | 17.0% | 0.3% | 56 |
| Best 10 (vs buy-and-hold) | SHC | 9.8% | -6.2% | 21 |
| Best 10 (vs buy-and-hold) | OGN | 5.3% | -10.7% | 19 |
| Worst 10 (vs buy-and-hold) | FG | 1.4% | 26.0% | 6 |
| Worst 10 (vs buy-and-hold) | VAL | 1.8% | 26.5% | 21 |
| Worst 10 (vs buy-and-hold) | INSW | 10.1% | 37.0% | 39 |
| Worst 10 (vs buy-and-hold) | CEG | 24.7% | 52.4% | 14 |
| Worst 10 (vs buy-and-hold) | AMR | 25.2% | 58.6% | 21 |
| Worst 10 (vs buy-and-hold) | PLTR | 28.3% | 63.0% | 21 |
| Worst 10 (vs buy-and-hold) | ULS | -4.9% | 30.7% | 4 |
| Worst 10 (vs buy-and-hold) | SITM | 40.4% | 76.9% | 24 |
| Worst 10 (vs buy-and-hold) | PECO | 6.5% | 44.4% | 21 |
| Worst 10 (vs buy-and-hold) | GEV | 54.9% | 133.6% | 4 |
| Significance Tests | ||
| Mean pairwise correlation between tickers: 0.29. Naive tests are optimistic; the adjusted and portfolio tests are the ones to lean on. | ||
| Test | Statistic | p-value |
|---|---|---|
| Share of tickers beating buy-and-hold vs a coin flip | 30.9% | less than 0.0001 |
| Mean excess CAGR, t-test (tickers treated as independent) | t = -10.14 | less than 0.0001 |
| Mean excess CAGR, Wilcoxon signed-rank | – | less than 0.0001 |
| Mean excess CAGR, t-test adjusted for correlation between tickers | t = -0.60 (effective n = 3.5) | 0.5999 |
| Equal-weight portfolio excess return, block bootstrap | 95% interval -12.42 to 2.67 pp/yr | interval includes zero |
| Pooled trades, mean return vs zero | t = 26.93 | less than 0.0001 |
The naive tests point in one direction. The strategy beat buy-and-hold on 306 of 989 tickers, or 30.9%, which is below a 50% coin flip; the exact binomial test gave a p-value of less than 0.0001. A p-value is the probability of seeing a result at least this extreme if there were no real edge. The mean excess CAGR was -2.40 percentage points, with a bootstrap 95% interval of -2.86 to -1.93 percentage points, and the t-test gave t = -10.14 with p = less than 0.0001. On their own, these tests suggest the strategy underperformed buy-and-hold.
Those tests treat the tickers as more independent than they really are. Their mean pairwise correlation was 0.29, making the 989 tickers behave like about 3.5 independent observations; after adjusting for this dependence, the t-statistic was -0.60 and the p-value was 0.5999. That adjusted result is NOT strong evidence of an edge. The equal-weight portfolio comparison also showed lower performance for the strategy, with a CAGR of 11.80% versus 16.33% for buy-and-hold, while the block-bootstrap 95% interval for annualised excess return was -12.42 to 2.67 percentage points, which includes zero and is therefore NOT strong evidence of an edge.
Ticker-level tests found 77 significant tickers at the 5% level, including 76 with a positive mean, while chance alone would produce about 48.6. After the Benjamini-Hochberg false-discovery correction, 0 remained significant, including 0 positive. The pooled-trade test found 50,759 closed trades, a mean return per trade of 2.61%, and p = less than 0.0001, with a 95% interval of 2.42% to 2.80%, but trades overlap in time across tickers, so that p-value is indicative only. The data can show that naive tests and pooled trades look positive in places, but after accounting for ticker dependence and multiple testing, it cannot establish a statistically significant edge over buy-and-hold.
| Ticker-Level Significance vs Chance | |
| With many tickers, some look significant by luck; compare the count with what chance gives | |
| Statistic | Value |
|---|---|
| Tickers with at least 8 trades (testable) | 972 |
| Significant at 5% (own trades) | 77 |
| …of which positive mean | 76 |
| Expected from chance alone | 48.6 |
| Significant after false-discovery correction | 0 |
| …of which positive mean | 0 |

| Equal-Weight Portfolio of All Tickers | |||
| Strategy and both benchmarks measured with identical methodology | |||
| Metric | Strategy | Buy & hold (same tickers) | Buy & hold SPY |
|---|---|---|---|
| Total return | 270.4% | 490.4% | 349.2% |
| CAGR | 11.80% | 16.33% | 13.65% |
| Max drawdown | 17.66% | 40.79% | 33.72% |
| Sharpe ratio | 0.89 | 0.84 | 0.82 |
| Annualized volatility | 13.60% | 20.43% | 17.53% |
The biggest caveat is survivorship bias: the test used current index constituents, so companies removed, acquired, or delisted during the window were absent. This is an educational backtest walkthrough, not investment advice, and past performance in a backtest does not predict future results.
| Test Setup | |
| Read from this run’s saved configuration | |
| Item | Setting |
|---|---|
| Tickers requested | 1000 |
| Available in source lists | 1504 |
| Usable | 989 |
| Excluded | 11 |
| Universe source | Current S&P 500, S&P 400 and S&P 600 index constituents (Wikipedia lists) |
| Evaluation window | 2015-01-01 to 2026-09-30 |
| Execution | signal at bar close, fill at next bar open, long-only, 100% of capital per ticker, no leverage, idle cash earns 0% |
| Slippage / commission | 5 bps per fill / none |
| Parameters | filter = market; lookback = 100 |
| Parameter fitting | The rule’s two free choices (filter type and lookback) were selected from 9 pre-declared variants using a separate set of 300 training tickers over 2006-01-01 to 2014-12-31. They were then frozen. The test tickers and the test period were not used in any selection. |
# =========================================================
# trend_regime_family.R -- PRE-REGISTERED strategy family (written before any
# results were examined).
#
# One rule family, nine variants: be long a stock while a simple moving-average
# trend condition holds, otherwise sit in cash.
# filter = "own" : the stock's close is above its own N-day simple moving average
# filter = "market" : the market's (SPY) close is above SPY's own N-day moving average
# filter = "both" : both conditions hold
# lookback N in {100, 150, 200} trading days
# Entry when the condition becomes true, exit when it becomes false. Signals are
# evaluated at each bar's close and filled at the next open (engine rule).
# The only free choices are `filter` and `lookback`; they are picked on a
# training set of tickers and an earlier period, then frozen and tested on
# different tickers and a later period (see research_trend_regime.R).
# =========================================================
trend_regime_signals <- function(ohlc, params, market_close) {
cl <- as.numeric(quantmod::Cl(ohlc))
n <- params$lookback
own_ok <- cl > TTR::SMA(cl, n)
market_ok <- market_close > TTR::SMA(market_close, n)
cond <- switch(params$filter, own = own_ok, market = market_ok, both = own_ok & market_ok)
list(entry = cond %in% TRUE, exit = (!cond) %in% TRUE) # NA (insufficient history) -> no action
}
trend_regime_description <- function(filter, lookback) {
what <- switch(filter,
own = sprintf("the stock's closing price is above its own %d-day simple moving average", lookback),
market = sprintf("the S&P 500 index fund (SPY) closes above its own %d-day simple moving average", lookback),
both = sprintf("both the stock's closing price is above its own %d-day simple moving average and SPY closes above its own %d-day simple moving average", lookback, lookback))
sprintf("Hold the stock (100%% of the capital allotted to it, no leverage) while %s; otherwise hold cash. The condition is checked at each day's close and acted on at the next day's open.", what)
}
make_variant_spec <- function(filter, lookback) {
list(name = sprintf("Trend filter (%s, %d-day)", filter, lookback),
description = trend_regime_description(filter, lookback),
params = list(filter = filter, lookback = lookback),
needs_market = TRUE,
signals = trend_regime_signals)
}
trend_regime_variants <- function() {
g <- expand.grid(filter = c("own", "market", "both"), lookback = c(100, 150, 200), stringsAsFactors = FALSE)
lapply(seq_len(nrow(g)), function(i) list(id = sprintf("%s_%d", g$filter[i], g$lookback[i]), filter = g$filter[i], lookback = g$lookback[i],
label = sprintf("%s filter, %d-day", c(own = "Own-trend", market = "Market-trend", both = "Own + market trend")[[g$filter[i]]], g$lookback[i])))
}
# FROZEN after selection on training data (see selection.json): filter = "market", lookback = 100
strategy_spec <- make_variant_spec("market", 100)| Component | Description |
|---|---|
| Indicator | Keltner Channel: Comprised of an upper band and a central line (20-period EMA) based on price and volatility. Average Directional Index (ADX): Measures trend strength (14-period). |
| Signal | Enter long when the price breaks above the 20-period upper band and the 14-period ADX is greater than 25. |
| Rule | Exit the position when the price closes below the 20-period EMA center line. |
A significant consideration is that the strategy’s results were based on 97 closed trades (+1 still open), which indicates that performance may depend heavily on a limited number of trades. The maximum drawdown for this strategy was approximately 29.9%, compared to the equal-weight benchmark’s max drawdown of 28.9%, suggesting a slightly worse risk profile.
| Side-by-Side Comparison | |||
| Benchmark CAGR and drawdown are computed from daily closes over the same window; Sharpe is omitted because the methodologies differ | |||
| Metric | Strategy | Equal-weight buy & hold basket (6 symbols) | Buy & hold SPY |
|---|---|---|---|
| Total return | +107.0% | +166.3% | +77.0% |
| Annualized return (CAGR) | +27.5% | +38.7% | +21.0% |
| Max drawdown | -29.9% | -28.9% | -18.8% |
| Ending equity | $207,035 | $266,324 | $177,038 |
| Backtest Summary | |
| Every figure computed from this run’s saved results | |
| Metric | Value |
|---|---|
| Starting capital | $100,000 |
| Ending capital | $207,035 |
| Total return | +107.03% |
| Annualized return (CAGR) | +27.50% |
| Closed trades | 97 (+1 still open at the end) |
| Win rate (closed trades) | 41.2% |
| Profit factor (closed trades) | 2.11 |
| Sharpe ratio (annualized) | 1.12 |
| Calmar ratio | 0.93 |
| Max drawdown | -29.92% |
| Equal-weight buy & hold basket (6 symbols) | +166.32% |
| Strategy vs. Equal-weight buy & hold basket (6 symbols) | -59.29 pp |
| Buy & hold SPY | +77.04% |
| Strategy vs. Buy & hold SPY | 30.00 pp |
The Keltner Channel Volatility Breakout strategy generated a total return of 107% over the 3-year period, resulting in an ending equity value of approximately $207,035 from an initial equity of $100,000. The strategy executed 97 closed trades, with a win rate of about 41%. When compared to the equal-weight buy-and-hold strategy for the same stocks, which achieved a total return of 166%, this strategy lagged significantly by approximately 59%. However, it outperformed the SPY buy-and-hold strategy, which returned 77%, by around 30%.
The Sharpe ratio of 1.12 indicates that the strategy earned 1.12 units of return for every unit of risk taken, which is generally considered acceptable in quantitative trading. At the trade level, the average closed-trade profit and loss was about $1,107. While some trades were profitable, the distribution revealed that a small number of large gains were responsible for a significant portion of the overall profit, indicating a "few big winners" trading pattern.
Monthly returns averaged about 14 positive months versus 20 negative months, with average gains of around 12% during winning months and smaller average losses of about 3%. This suggests that while the strategy struggled to avoid bad months, it was capable of capturing larger upswings, aligning with its volatility breakout approach. However, the strategy faced a maximum drawdown of 30%, indicating considerable risk during its worst performance period.
| Risk-Adjusted Return Metrics | ||
| Every number here comes straight from this backtest’s quantstr.at report | ||
| Metric | Value | What It Measures |
|---|---|---|
| Annualized Return | 27.70% | Return scaled to a one-year rate |
| Annualized Volatility | 24.82% | How much returns swing year to year |
| Sharpe Ratio | 1.116 | Return per unit of total volatility — the classic risk-adjusted return measure |
| Sortino Ratio | 0.114 | Return per unit of downside volatility only — ignores upside swings that Sharpe penalizes unfairly |
| Calmar Ratio | 0.926 | Return relative to the worst peak-to-trough drawdown |
| Omega Ratio | 0.307 | Probability-weighted ratio of gains to losses — doesn’t assume a normal return distribution the way Sharpe does |
| Downside Deviation | 0.953% | Volatility of losing periods only |
| Upside Potential Ratio | 0.736 | Upside capture relative to downside risk — a Sortino-style ratio facing the other direction |
| Probability Sharpe > 0 | 97.94% | Statistical confidence the strategy’s TRUE Sharpe ratio (not just this one sample) is greater than zero |
| Min. Track Record Needed | 487 | Minimum number of return observations needed for that confidence level to be meaningful |
| Sharpe Statistically Significant? | Yes | Whether this backtest has enough history for its Sharpe ratio to be statistically real, not noise |

Understanding raw return figures can be misleading, as they fail to reveal the underlying risks involved. For instance, a strategy returning 20% with high volatility may not be preferable to one generating 10% returns with a smoother ride. In this backtest, the Sharpe ratio of 1.12 indicates that the strategy earned about 1.12 units of return per unit of total volatility, reflecting an acceptable risk-return trade-off. The Sortino ratio, at 0.11, looks specifically at downside volatility, signaling concerns since it shows that returns aren’t proportionate to the risks incurred when the strategy performed poorly. The Calmar ratio of 0.93 compares returns relative to the maximum drawdown, offering a different angle that emphasizes the substantial drawdown of nearly 30%.
A key metric, "Probability Sharpe > 0," sits at 97.94%, indicating high confidence that the strategy’s true Sharpe ratio is greater than zero, although the minimum track record needed to ensure this confidence is 487 observations. In this case, the Sharpe ratio is statistically significant, meaning it likely reflects a real pattern rather than being a result of chance. When reviewing your own quantstr.at report, it’s useful to examine the Sharpe and Sortino ratios together to understand risk-adjusted returns, followed by the Calmar ratio for additional insights into maximum drawdown.
The significance test indicates that the mean return per trade was 12.22%, with a 95% confidence interval for the mean ranging from -8.03% to 32.46%. The t-statistic was 1.20, and the two-sided p-value was 0.2339, suggesting there isn’t strong evidence that the strategy was definitively effective, primarily due to the small sample size of trades. This means that the returns could be due to random chance rather than a robust underlying strategy.
| Performance by Symbol | ||||
| 6 symbols, closed trades only | ||||
| Symbol | Trades | Net P&L | Win rate | Profit factor |
|---|---|---|---|---|
| QQQ | 14 | +$54,264 | 57.1% | 8.10 |
| AMD | 15 | +$43,092 | 66.7% | 2.72 |
| NVDA | 16 | +$21,770 | 31.2% | 2.53 |
| MSFT | 16 | +$5,528 | 37.5% | 1.35 |
| AAPL | 20 | -$1,303 | 35.0% | 0.91 |
| AMZN | 16 | -$15,936 | 25.0% | 0.21 |
| Profit Concentration | |||
| How much of the result depends on the largest winning trades (closed trades only) | |||
| Scenario | Net profit | Total return | Profit removed |
|---|---|---|---|
| Headline result | +$107,035 | +107.0% | – |
| Excluding the single largest winner | +$66,235 | +66.2% | $40,800 |
| Excluding the 5 largest winners | -$14,524 | -14.5% | $121,559 |
| Is the Average Trade Different From Zero? | |
| One-sample t-test on per-trade returns; overlapping trades are not independent, so treat the p-value as indicative | |
| Statistic | Value |
|---|---|
| Closed trades tested | 97 |
| Mean return per trade | +12.22% |
| Standard error | 10.20 pp |
| 95% confidence interval for the mean | -8.03% to +32.46% |
| t-statistic (H0: mean = 0) | 1.20 |
| p-value (two-sided) | 0.2339 |
The risk-adjusted return metrics present a mixed picture. With a Sharpe ratio of 1.12, the strategy earned slightly more than one unit of return for each unit of risk taken, which is generally acceptable. However, the Sortino ratio, at 0.11, indicates that the returns were not proportionate to the downside risk. This suggests that while there were some profitable trades, the good months did not sufficiently outweigh the less favorable ones.
An integrity audit revealed some flaws in the backtesting process, specifically regarding exposure checks. The peak gross long exposure exceeded the actual equity in the account. This means that the reported returns might overstate what a strictly unleveraged account could have achieved. Additionally, the failed percentage checks imply that some of the data’s presentation may lack precision. Hence, while the strategy shows promise, caution is warranted when interpreting its performance metrics due to these inherent risks and issues.
| Monthly Returns Matrix (%) | |||||||||||||
| Month-by-month and YTD cumulative performance summary | |||||||||||||
| Year | Jan | Feb | Mar | Apr | May | Jun | Jul | Aug | Sep | Oct | Nov | Dec | YTD |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 2023 | – | – | – | – | – | – | – | 0.0% | 0.0% | 0.8% | 11.7% | 4.2% | 17.3% |
| 2024 | 6.5% | 0.7% | -3.2% | 0.2% | 0.6% | 22.5% | -3.1% | -3.7% | -2.6% | 0.0% | -1.2% | -1.3% | 13.5% |
| 2025 | -4.8% | -1.1% | -3.9% | -3.9% | 9.9% | 12.9% | 20.6% | -3.8% | -2.2% | 5.8% | -5.9% | -1.3% | 20.3% |
| 2026 | 0.0% | -6.3% | -1.2% | 24.6% | 22.5% | -6.5% | 0.9% | -2.9% | – | – | – | – | 29.3% |
The backtest reported returns across 37 months, with 14 months showing positive returns, 20 months negative, and 3 months flat. The best month was May 2026, achieving a return of 26%, while the worst was July 2026, with a decline of 7%.
Notably, the largest drawdown episode occurred between July 2024 and April 2025, reaching a depth of 30% over 193 days. This episode aligns with several negative monthly returns, particularly during 2025, which included months of losses.
One major caveat to consider is the maximum drawdown of approximately 30%, indicating significant risk during its worst performance phase. This drawdown required about 60 days to recover, highlighting potential stress for traders. It’s important to note that this analysis is purely for educational purposes, not investment advice, and past performance does not guarantee future results. Readers are encouraged to explore their own strategy ideas using quantstr.at.
The strategy’s maximum drawdown of 30% indicates significant risk during adverse market conditions. By adding a stop-loss, you may help limit losses during downturns, enhancing risk management and potentially improving overall performance.
Trade QQQ, NVDA, AAPL, MSFT, AMD, and AMZN using a Keltner Channel Volatility Breakout strategy. Enter long when price breaks above the 20-period upper band and 14-period ADX > 25 (confirming trend strength). Exit when price closes below the 20-period EMA center line. Additionally, set a stop-loss to exit the position if losses reach 5%.
| Backtest Setup | |
| Read from this run’s saved configuration | |
| Parameter | Setting / Value |
|---|---|
| Asset universe | 6 symbols (QQQ, NVDA, AAPL, MSFT, AMD, AMZN) |
| Time window | 2023-08-29 to 2026-08-27 (3.0 years) |
| Starting equity | $100,000 |
| Benchmarks | Equal-weight buy & hold basket (6 symbols) (+166.3%); Buy & hold SPY (+77.0%) |
| Position size (as executed) | 200 units per trade |
| Trade Statistics by Symbol | |||||||||||||||||||||||||||
| Detailed performance and risk metrics breakdown per traded asset | |||||||||||||||||||||||||||
| Symbol | Trades | Net P&L | Win Rate | Loss Rate | Max Equity | Min Equity | Gross Profits | Gross Losses | Max Drawdown | Transactions | Avg Daily P&L | Avg Trade P&L | Ending Equity | Largest Winner | Largest Loser | Profit Factor | Avg Winning Trade | Avg Losing Trade | Median Trade P&L | Annualized Sharpe | Daily P&L Std Dev | Trade P&L Std Dev | Avg Win/Loss Ratio | Median Losing Trade | Median Winning Trade | Median Win/Loss Ratio | Profit / Max Drawdown |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| QQQ | 14 | $54,264 | 57.1% | 42.9% | $58,804 | $0 | $61,903 | -$7,639 | -$11,040 | 28 | $3,876 | $3,876 | $54,264 | $26,405 | -$2,767 | 8.10 | $7,738 | -$1,273 | $718 | 7.28 | $8,455 | $8,455 | 6.08 | -$1,365 | $3,922 | 2.87 | 4.92 |
| NVDA | 16 | $21,770 | 31.2% | 62.5% | $29,810 | $0 | $36,023 | -$14,253 | -$10,478 | 32 | $1,361 | $1,361 | $21,770 | $22,083 | -$2,749 | 2.53 | $7,205 | -$1,425 | -$560 | 3.27 | $6,614 | $6,614 | 5.05 | -$1,560 | $610 | 0.39 | 2.08 |
| AAPL | 20 | -$1,303 | 35.0% | 65.0% | $9,880 | -$1,437 | $13,044 | -$14,347 | -$11,317 | 40 | -$65 | -$65 | -$1,303 | $6,145 | -$4,476 | 0.91 | $1,863 | -$1,104 | -$500 | -0.49 | $2,101 | $2,101 | 1.69 | -$835 | $526 | 0.63 | -0.12 |
| MSFT | 16 | $5,528 | 37.5% | 62.5% | $11,197 | -$8,827 | $21,303 | -$15,775 | -$13,204 | 33 | $346 | $346 | $5,148 | $13,839 | -$3,345 | 1.35 | $3,551 | -$1,577 | -$718 | 1.34 | $4,083 | $4,083 | 2.25 | -$1,494 | $1,110 | 0.74 | 0.39 |
| AMD | 15 | $43,092 | 66.7% | 33.3% | $55,694 | -$310 | $68,094 | -$25,002 | -$21,574 | 30 | $2,873 | $2,873 | $43,092 | $40,800 | -$10,560 | 2.72 | $6,809 | -$5,000 | $594 | 3.92 | $11,649 | $11,649 | 1.36 | -$3,024 | $2,221 | 0.73 | 2.00 |
| AMZN | 16 | -$15,936 | 25.0% | 75.0% | $3,434 | -$16,216 | $4,150 | -$20,086 | -$19,650 | 32 | -$996 | -$996 | -$15,936 | $2,172 | -$4,508 | 0.21 | $1,037 | -$1,674 | -$1,104 | -9.77 | $1,618 | $1,618 | 0.62 | -$1,286 | $959 | 0.75 | -0.81 |
# Load required libraries
library(quantmod)
library(quantstrat)
# Set parameters
start_date <- '2023-08-29'
end_date <- '2026-08-27'
init_equity <- 100000
symbols <- c('QQQ', 'AAPL', 'MSFT', 'NVDA', 'AMD', 'AMZN')
# Get historical data
getSymbols(symbols, from = start_date, to = end_date)
# Initialize the portfolio, account, and orders
initPortf('KeltnerADX', symbols = symbols)
initAcct('KeltnerADX', portfolios = 'KeltnerADX', initEq = init_equity)
initOrders(portfolio = 'KeltnerADX')
# Define the strategy
strategy.st <- 'KeltnerADX'
rm <- 'KeltnerADX'
# Initialize strategy
initStrategy(strategy.st)
# Define indicators
add.indicator(strategy = strategy.st, name = 'EMA', arguments = list(x = quote(Cl(mktdata)), n = 20), label = 'EMA20')
add.indicator(strategy = strategy.st, name = 'KeltnerChannel', arguments = list(HLC = quote(HLC(mktdata)), n = 20, sd = 1.5), label = 'KC')
add.indicator(strategy = strategy.st, name = 'ADX', arguments = list(x = quote(HLC(mktdata)), n = 14), label = 'ADX')
# Define signals
add.signal(strategy = strategy.st, name = 'sigThreshold', arguments = list(column = 'KC.upper', threshold = 0, relationship = 'gt'), label = 'longEntry')
add.signal(strategy = strategy.st, name = 'sigThreshold', arguments = list(column = 'EMA20', threshold = 0, relationship = 'lt'), label = 'longExit')
# Define rules
add.rule(strategy = strategy.st, name = 'ruleSignal', arguments = list(sigcol = 'longEntry', sigval = TRUE, ordertype = 'market', orderside = 'long', replace = FALSE, TxnFees = 0), type = 'enter')
add.rule(strategy = strategy.st, name = 'ruleSignal', arguments = list(sigcol = 'longExit', sigval = TRUE, ordertype = 'market', orderside = 'long', replace = TRUE, TxnFees = 0), type = 'exit')
# Apply the strategy
applyStrategy(strategy = strategy.st, portfolios = 'KeltnerADX')
# Update the portfolio
updatePortf('KeltnerADX')
# Get performance summary
chart.Posn('KeltnerADX', Symbol = 'QQQ')
# Print final portfolio summary
getPortfolio('KeltnerADX')import yfinance as yf
import pandas as pd
import vectorbt as vbt
# Set parameters
symbols = ['QQQ', 'AAPL', 'MSFT', 'NVDA']
start_date = '2023-08-29'
end_date = '2026-08-27'
# Download historical data
prices = yf.download(symbols, start=start_date, end=end_date)['Adj Close']
# Calculate indicators
ema20 = prices.ewm(span=20).mean()
upper_band = prices.rolling(window=20).mean() + (prices.rolling(window=20).std() * 1.5)
lower_band = prices.rolling(window=20).mean() - (prices.rolling(window=20).std() * 1.5)
adx = vbt.ADX.run(prices, window=14).adx
# Define entry and exit signals
long_entry = (prices > upper_band) & (adx > 25)
long_exit = prices < ema20
# Create portfolio
portfolio = vbt.Portfolio.from_signals(prices, long_entry, long_exit, init_cash=100000)
# Print portfolio stats
print(portfolio.stats())---
title: "Strategy Replication Guide: Keltner ADX Volatility Breakout Strategy"
format: html
editor: visual
execute:
eval: false
---
# Overview
This Quarto Markdown notebook provides reproducible R and Python code to replicate the **Keltner ADX Volatility Breakout Strategy** backtest (2023-08-29 to 2026-08-27).
## R Replication (quantstrat)
```{r}
#| label: r-strategy-replication
#| message: false
#| warning: false
# Load required libraries
library(quantmod)
library(quantstrat)
# Set parameters
start_date <- '2023-08-29'
end_date <- '2026-08-27'
init_equity <- 100000
symbols <- c('QQQ', 'AAPL', 'MSFT', 'NVDA', 'AMD', 'AMZN')
# Get historical data
getSymbols(symbols, from = start_date, to = end_date)
# Initialize the portfolio, account, and orders
initPortf('KeltnerADX', symbols = symbols)
initAcct('KeltnerADX', portfolios = 'KeltnerADX', initEq = init_equity)
initOrders(portfolio = 'KeltnerADX')
# Define the strategy
strategy.st <- 'KeltnerADX'
rm <- 'KeltnerADX'
# Initialize strategy
initStrategy(strategy.st)
# Define indicators
add.indicator(strategy = strategy.st, name = 'EMA', arguments = list(x = quote(Cl(mktdata)), n = 20), label = 'EMA20')
add.indicator(strategy = strategy.st, name = 'KeltnerChannel', arguments = list(HLC = quote(HLC(mktdata)), n = 20, sd = 1.5), label = 'KC')
add.indicator(strategy = strategy.st, name = 'ADX', arguments = list(x = quote(HLC(mktdata)), n = 14), label = 'ADX')
# Define signals
add.signal(strategy = strategy.st, name = 'sigThreshold', arguments = list(column = 'KC.upper', threshold = 0, relationship = 'gt'), label = 'longEntry')
add.signal(strategy = strategy.st, name = 'sigThreshold', arguments = list(column = 'EMA20', threshold = 0, relationship = 'lt'), label = 'longExit')
# Define rules
add.rule(strategy = strategy.st, name = 'ruleSignal', arguments = list(sigcol = 'longEntry', sigval = TRUE, ordertype = 'market', orderside = 'long', replace = FALSE, TxnFees = 0), type = 'enter')
add.rule(strategy = strategy.st, name = 'ruleSignal', arguments = list(sigcol = 'longExit', sigval = TRUE, ordertype = 'market', orderside = 'long', replace = TRUE, TxnFees = 0), type = 'exit')
# Apply the strategy
applyStrategy(strategy = strategy.st, portfolios = 'KeltnerADX')
# Update the portfolio
updatePortf('KeltnerADX')
# Get performance summary
chart.Posn('KeltnerADX', Symbol = 'QQQ')
# Print final portfolio summary
getPortfolio('KeltnerADX')
```
## Python Replication (VectorBT)
```{python}
#| label: python-strategy-replication
import yfinance as yf
import pandas as pd
import vectorbt as vbt
# Set parameters
symbols = ['QQQ', 'AAPL', 'MSFT', 'NVDA']
start_date = '2023-08-29'
end_date = '2026-08-27'
# Download historical data
prices = yf.download(symbols, start=start_date, end=end_date)['Adj Close']
# Calculate indicators
ema20 = prices.ewm(span=20).mean()
upper_band = prices.rolling(window=20).mean() + (prices.rolling(window=20).std() * 1.5)
lower_band = prices.rolling(window=20).mean() - (prices.rolling(window=20).std() * 1.5)
adx = vbt.ADX.run(prices, window=14).adx
# Define entry and exit signals
long_entry = (prices > upper_band) & (adx > 25)
long_exit = prices < ema20
# Create portfolio
portfolio = vbt.Portfolio.from_signals(prices, long_entry, long_exit, init_cash=100000)
# Print portfolio stats
print(portfolio.stats())
```