Study 1: 12 famous strategies, one protocol
All 12 made money over 1999–2024 after costs; 11 of 12 did worse than just holding QQQ.
The protocol
- QQQ daily, long only, no leverage, no stops or filters added. One standard definition per strategy, with its textbook parameters and no tuning.
- Decisions on each bar's close, fills at the next bar's open.
- Product default costs: 0.5 bp commission per round trip, 1 bp slippage per fill, US$100,000 account.
- Development window 10 Mar 1999 – 31 Dec 2024. Hold-out 2 Jan 2025 – 27 Oct 2026, set once when the study was created, never seen by the development runs. Each hold-out run started flat after a 300-bar warm-up.
- One version per strategy. All 12 are counted by the product, on the same account and market.
Study id: chat_mvsk8vzy_fff32efd82
Full protocol
Study 1, wave 1: twelve famous strategies on QQQ, one fixed protocol
Frozen before any run, 2026-10-28. The strategy files are fixed by the SHA-256 hashes at the bottom of this page. Nothing below changes after the first run. A change would be a new study with a new protocol.
What is tested
Twelve textbook strategies, each in one standard form with its standard parameters. No parameter is tuned, and every strategy runs once on the development window. The product's held-out test runs each one once on the hold-out, in an order chosen and recorded after the development runs.
The fixed protocol
- Product and contract. QuantWright Agent API (MCP, https://quantwright.com/api/mcp), code contract as of 28 Oct 2026. The full text is saved in
describe_data.json. Each strategy is one backtesting.pyStrategyclass, run in the product's sandbox. - Market. QQQ, daily bars (
symbol: "QQQ",timeframe: "1d"). The product describes its daily US stock and ETF data as "split-adjusted, as total returns (cash dividends credited on each ex-date)". Prices are split-adjusted, not dividend-adjusted. A position held into an ex-date is credited the dividend in cash. All indicators are computed on these split-adjusted prices. - Development window. Asked as
{"from": "1999-03-10", "to": "2024-12-31"}. describe_data does not state QQQ's first daily bar. If the data starts later, each result'swindow.start_later.first_barnames the first bar, and the run uses it. - Hold-out. From 2025-01-01 to the latest bar. It is set once, in
create_study, and onlyrun_held_out_testreads it. The product counts only the first held-out test per account and market; later ones run and are labelled as not the one that counts. The order of the twelve held-out tests is chosen after the development runs and recorded with the results. A held-out run with fewer than 10 trades has its figures withheld by the product (trade floor), and each development run'shold_out_estimatepredicts that count. - Every window starts flat. On the development window there are no earlier bars, so each indicator warms up inside the window: a strategy cannot trade until its indicators exist. The golden cross needs 200 bars and momentum 12-1 needs 253. On the hold-out, the product first runs earlier bars so the indicators have history (
window.warmup), but it places no order on them and the account enters the window flat. A strategy whose entry is a cross, flip or month-end decision that happened before the window waits for its next entry signal inside the window. - Signals and fills. Each decision is made at a bar's close (backtesting.py
next(), which sees only bars up to the current one). The order fills at the next bar's open, for entries and exits alike. The contract says: "self.buy(), self.sell() and self.position.close() fill at the OPEN of the bar after the one whose next() placed them". A position still open at the end of a window is closed on its last bar (the trade list says "open at the end"). - Costs. The product's defaults. No
costsargument is sent. The contract describes costs as "the commission charged once per round trip on the value at entry, the slippage on each fill". The default for US stocks and ETFs on daily bars is a 0.5 bp commission (0.00005) and 1 bp slippage per fill (0.0001), the product's published defaults. Both are debited from cash at the fill, and fills are at the raw next-bar open. The record is each result'ssettings.commissionandsettings.slippage, which the ledger keeps per run. - Account and sizing. The product's default account, US$100,000 (
account_usdnot sent), with no leverage. Every strategy enters with a bareself.buy(), the same for all twelve. That is backtesting.py's default size, all of the equity: as many whole shares as the cash buys at the fill price. The contract also allowssize=0.99; the default is used instead. - Long only, one position. A strategy enters only when flat and exits only when long. There is no shorting, no pyramiding (
max_entries_per_sideis not set) and no leverage. - No additions. No stop, target, filter or time limit is added to any rule.
- Settings in the code. Every rule number (periods, levels, multipliers) is a class-level setting, as the contract requires, at the standard value given below.
- Crossing. A crosses above B on a bar when A > B on that bar and A <= B on the bar before; crossing below is the mirror. This is checked on closed bars only. Levels work the same way (for example, %K crossing above 20).
- No look-ahead. No negative shift, and no read of any bar after the current one. Every indicator is computed from the current and earlier bars only. Local tests confirm it by cutting the data short (see "Local verification").
The twelve definitions
| # | File | Strategy | Rule as implemented |
|---|---|---|---|
| 1 | 01_rsi2.py | RSI(2) 10/90 | Long when RSI(2) closes below 10; exit when RSI(2) closes above 90. |
| 2 | 02_rsi14.py | RSI(14) 30/70 | Long when RSI(14) closes below 30; exit when RSI(14) closes above 70. |
| 3 | 03_macd.py | MACD(12, 26, 9) cross | Long when the MACD line crosses above the signal line; exit when it crosses below. |
| 4 | 04_golden_cross.py | Golden cross 50/200 | Long when SMA(50) crosses above SMA(200); exit when it crosses below. |
| 5 | 05_ema_9_21.py | EMA 9/21 cross | Long when EMA(9) crosses above EMA(21); exit when it crosses below. |
| 6 | 06_bollinger.py | Bollinger(20, 2) mean reversion | Long when the close is below the lower band; exit when the close is above the middle band. |
| 7 | 07_donchian.py | Donchian / Turtle System 1 | Long when the close is above the highest high of the previous 20 bars; exit when the close is below the lowest low of the previous 10 bars. |
| 8 | 08_supertrend.py | Supertrend(10, 3) | Long when Supertrend flips to up; exit when it flips to down. |
| 9 | 09_parabolic_sar.py | Parabolic SAR(0.02, 0.2) | Long when the SAR flips below price (trend to long); exit when it flips above price. |
| 10 | 10_stochastic.py | Stochastic(14, 3, 3) 20/80 | Long when slow %K crosses above 20 from below; exit when it crosses below 80 from above. |
| 11 | 11_keltner.py | Keltner breakout | Long when the close is above EMA(20) + 2 × ATR(10); exit when the close is below EMA(20). |
| 12 | 12_tsmom_12_1.py | Time-series momentum 12-1 | On each month's last trading day, long for the next month if close[t−21] / close[t−252] − 1 > 0, else flat. |
Indicator formulas
- SMA(n): the mean of the last n closes, the current one included.
- EMA(n): out[t] = α·x[t] + (1 − α)·out[t−1], with α = 2/(n+1). It is seeded on the n-th valid value with the simple mean of the first n values, and is undefined before that.
- Wilder smoothing (RMA(n)): the same recursion with α = 1/n, which is avg[t] = (avg[t−1]·(n−1) + x[t]) / n, seeded with the simple mean of the first n values.
- RSI(n), Wilder: change[t] = close[t] − close[t−1]. Gain = max(change, 0) and loss = max(−change, 0), each smoothed with RMA(n), seeded over changes 1 to n. RSI = 100 − 100 / (1 + avg gain / avg loss), and 100 when the average loss is 0. Checked against StockCharts' published 14-period worked example (all 19 values match to 0.01).
- MACD(12, 26, 9): MACD line = EMA(12) − EMA(26) of the close. Signal = EMA(9) of the MACD line, seeded on its first 9 valid values.
- Bollinger(20, 2): middle = SMA(20). Lower = middle − 2·σ, where σ is the population standard deviation (divisor n) of the last 20 closes, as John Bollinger specifies.
- Donchian: highest high of bars t−20 … t−1 and lowest low of bars t−10 … t−1. The current bar is left out, and its close is compared with the levels.
- True range: max(high − low, |high − previous close|, |low − previous close|), and high − low on the first bar.
- ATR(n), Wilder: RMA(n) of the true range, seeded with the mean of the first n true ranges.
- Supertrend(10, 3) (TradingView's construction):
- Bands: basic upper = (high + low)/2 + 3·ATR(10), and basic lower = (high + low)/2 − 3·ATR(10).
- Final upper band: the basic upper band if it is below the previous final upper band, or if the previous close was above that band; otherwise the previous final upper band. The final lower band mirrors this.
- Trend: it starts down on the first bar with an ATR. A down trend turns up when the close is above the final upper band; an up trend turns down when the close is below the final lower band.
- Parabolic SAR(0.02, 0.2) (Wilder's, as TA-Lib's
TA_SARcomputes it):- Acceleration factor (AF): starts at 0.02, rises 0.02 with each new extreme point (EP), and is capped at 0.2.
- First trend (bar 1): short if bar 1's down move (previous low − low) is positive and larger than its up move (high − previous high), else long. A long starts with SAR = bar 0's low and EP = bar 1's high; a short with SAR = bar 0's high and EP = bar 1's low.
- Flips: a long flips short when the low touches or crosses the SAR (low ≤ SAR), and a short flips long when the high touches or crosses it (high ≥ SAR). On a flip, the SAR restarts at the prior EP, kept beyond the current and previous bar's range.
- Next SAR = SAR + AF·(EP − SAR), kept beyond the current and previous bar's range: at or below their lows while long, at or above their highs while short.
- Stochastic(14, 3, 3): fast %K = 100·(close − lowest low of 14) / (highest high of 14 − lowest low of 14), the current bar included. Slow %K = SMA(3) of fast %K. Slow %D (the second 3) is not used by the rule.
- Keltner: middle = EMA(20) of the close; upper = middle + 2·ATR(10).
- Momentum 12-1: close[t−21] / close[t−252] − 1, in trading days, on split-adjusted closes.
Where a definition could not be implemented exactly as written
- Momentum 12-1, the month's last trading day. The code has no exchange calendar, and it must not read the next bar's date (a look-ahead). It therefore takes a month's last trading day to be its last weekday (Mon–Fri), from each bar's own date. When that weekday is an exchange holiday there is no bar on it. The decision is then made on the first bar of the new month instead: signal at that bar's close, fill at the next open, one trading day late. Between 1999-03 and 2026-12 this happens in eight months, all in the development window and none in the hold-out:
- Good Friday: 2002-03-29, 2013-03-29, 2018-03-30, 2024-03-29.
- Memorial Day: 1999-05-31, 2004-05-31, 2010-05-31, 2021-05-31.
- Momentum 12-1, the return. The signal is a price return on split-adjusted closes. Dividends are not in the price series, so they are not in the signal; they are still credited to the position's cash. QQQ's dividend yield is small, so only a return very close to zero could change sign.
- Turtle System 1 is the stated close rule only. The original system's 2N stop, unit sizing, skip-after-a-winning-breakout filter and 55-day failsafe are not part of the definition and are not added.
- Parabolic SAR, Supertrend and EMA have several published variants: the first trend, whether a touch counts as a flip, and the EMA seed. Each choice above follows a named reference (TA-Lib, TradingView, and the TA-Lib SMA seed). The choices only change the first bars and rare exact ties.
- Helpers. The contract offers indicator helpers (
ta,qw_features.technical). They are not used: every formula is written in plain numpy/pandas in the strategy file, so the published code is the whole definition.
How to read the results
- Grade and trials. All twelve development runs are in one study on QQQ 1d. The product counts each run as a trial, plus this account's other recent QQQ 1d runs (
grade.search), when it grades a result. Strategies run later in the file order (01 → 12) are therefore graded against more trials. The ledger gives the trial count each result reports (grade.trials). - Withheld figures. The product withholds figures from any run of fewer than 10 trades, and always withholds the buy-and-hold return (market-data licence). The ledger writes "withheld: <reason>" where a figure is withheld, and "not reported" where a field is absent.
Local verification (before any run)
All of it was run on synthetic data, seeded random walks with no market data, using a local Python environment (backtesting.py 0.3.3, pandas 2.2.3, numpy 2.1.3). There are 72 tests in tests/:
- The product's gate. Every file passes the product's own AST guard, RestrictedPython compile and sandbox namespace.
- The product's engine. Every file runs through the product's own engine with the default equity costs. Its trades fall on the same bars as plain backtesting.py, and each trade's cost is 0.5 bp + 1 bp + 1 bp of the trade value.
- Fills and positions. Every strategy trades, goes long only, holds one position at a time, and fills at the next bar's open.
- No look-ahead. With the data cut at three points, every indicator value and every decision before the cut is unchanged.
- Rule replay. For strategies 1–11, the trades equal an independent replay of the stated rule from the same indicators. For strategy 12, decisions fall on each month's last trading day, including the holiday fallback, and the position follows the sign of the momentum.
- Indicator values. RSI matches StockCharts' published example and converges to the
talibrary's RSI. EMA converges to pandasewm. ATR, Bollinger, Donchian, Stochastic, Supertrend and Parabolic SAR match hand-computed values, including the SAR flip boundary and the clamp after a flip. - The tests can fail. A
.shift(-1)on the 20-bar high, a centred rolling mean, a changed exit level and an order delayed one bar were each caught.
Fixed before any run (2026-10-28, 22:20 EET): grading pass, held-out order, tier rule
- Second grading pass. The product grades each run against the trials counted so far, so in one pass strategy 01 would be graded against 1 trial and strategy 12 against 12. After all twelve development runs, each one is run again, unchanged. A byte-for-byte repeat is not a new candidate in the product, so the count stays at twelve, and every strategy is graded against the full search. The ledger uses the second pass. The first pass is kept on file. If the second pass shows a different trial count from the first pass's last run, that is reported.
- Held-out order. The first held-out test is the one the product counts (once per account and market). It goes to the strategy with the highest Honesty Score in the second pass, among those whose
hold_out_estimateis at least 10 trades, so the counted test is not spent on a hold-out too short to show figures. Ties go to more development trades, then to the lower file number. The other eleven follow in file order and are reported as "not the counted test". Picking among them afterwards would be a second search on the same months. - Tier rule (for the tier list). It uses the facts each result states, never taste or a figure the protocol keeps out of public text. Terms:
- made money = the run's net result after costs is above zero;
- beat holding = the result's own buy-and-hold comparison says it did better;
- enough trades = at least 30 development trades and at least 10 held-out trades (the product's floor for showing figures).
The tiers, checked from F upwards:
- F: did not make money over the development window.
- D: made money over the development window, but on fewer than 30 trades (too thin to judge).
- C: made money over the development window with enough trades, then lost money on the hold-out.
- B: made money over the development window with enough trades; the hold-out had too few trades to judge.
- A: made money in both windows with enough trades, but did not beat holding in both.
- S: made money and beat holding in both windows, with enough trades in both.
The tier is not the product's grade. Every strategy's grade stays at C or below except the one counted held-out test. Public pieces say "tier" and "grade" separately.
SHA-256 of each strategy file
918eccd6851cf015b3c4592fae89380061eee29994046365f54a1cdbae1f37c0 strategies/01_rsi2.py 34ee64a8a3700054e0d0812d520edd8e5133de42f5bf489474c5114fa285ed80 strategies/02_rsi14.py eba0bfaaaa764f332883c9b21e79a4f7563d50123b14bce9a2265a5c480d591a strategies/03_macd.py 4a6f5086a9acd1a393e13b3ea5eb63b894712034936593f60a79849dea74dd34 strategies/04_golden_cross.py 3f894d90a4dd5dd6cb03bfa642d1b6e2ca6226f7f4121c420b111e28ad48ac05 strategies/05_ema_9_21.py c4a0d4f804e3b33cc3fb51c7723790c0e722ac61e4f83d5bb7e9b268870b257f strategies/06_bollinger.py a6e8e7290be5f34b4c1ddc6af857603ae70739d77f32c9579c7084fb5172860b strategies/07_donchian.py b23f395d20bbe9d4fa9d25cdaf23af956998fc1ef5c6a543dc543a805566d3a9 strategies/08_supertrend.py a7cdc5128deef3c54100b7e37cdf489af9189c1217763fc6d46a71b24685e1af strategies/09_parabolic_sar.py 8146121862a610b083a6684e67c1caedf3aadef4a0cafbce6a1d3032ad31cb3e strategies/10_stochastic.py 95b39462b2495e871631ec687de0c22ef5bac4a2d696db1ec783b8a2c71f3ef4 strategies/11_keltner.py 17f75e5c715906fecc370093c1648b2776f5577b62ddc353f183ea20e48e9c02 strategies/12_tsmom_12_1.py
After the runs (2026-10-28, 22:45 EET): what the second pass did
The second pass changed nothing. The product replays an identical run from its first result (replayed_from), and the replay keeps that run's grade and trial count. The grades in the ledger are therefore as the product computed them at the time: strategy 01 against 1 trial, up to strategy 12 against 12. The tier rule does not use the grade, so the tiers are unaffected. Every public piece that quotes a grade gives its trial count with it. The held-out order followed the rule: 05 first (score 62 with an expected 10 held-out trades), then 01–04 and 06–12.
Development window, 1999–2024
Development window, 1999–2024 (25.8 years). QQQ daily, long only, default costs, one version each, all counted.
| # | Strategy | Trades | Win rate | Max drawdown | Made money after costs | Beat holding QQQ | Grade (score, trials at the time) |
|---|---|---|---|---|---|---|---|
| 01 | RSI(2) 10/90 | 192 | 73% | 72.4% | yes | no | D (49, 1) |
| 02 | RSI(14) 30/70 | 21 | 81% | 77.7% | yes | no | D (35, 2) |
| 03 | MACD 12/26/9 cross | 262 | 45% | 78.2% | yes | no | D (37, 3) |
| 04 | Golden cross 50/200 | 16 | 63% | 28.4% | yes | yes | C (52, 4) |
| 05 | EMA 9/21 cross | 138 | 39% | 45.8% | yes | no | C (62, 5) |
| 06 | Bollinger 20/2 reversion | 110 | 75% | 58.3% | yes | no | D (47, 6) |
| 07 | Donchian / Turtle 20/10 | 111 | 53% | 43.3% | yes | no | D (49, 7) |
| 08 | Supertrend 10/3 | 101 | 50% | 46.0% | yes | no | D (49, 8) |
| 09 | Parabolic SAR 0.02/0.2 | 317 | 47% | 59.9% | yes | no | D (44, 9) |
| 10 | Stochastic 14/3/3 20/80 | 114 | 63% | 74.3% | yes | no | D (35, 10) |
| 11 | Keltner breakout 20/2×ATR10 | 100 | 49% | 35.1% | yes | no | D (49, 11) |
| 12 | Time-series momentum 12-1 | 15 | 80% | 48.3% | yes | no | D (43, 12) |
Grades are as the product computed them at the time: strategy 01 against 1 trial, up to strategy 12 against 12.
Hold-out, 2025–2026
Hold-out, 2 Jan 2025 – 27 Oct 2026 (run once each). The product counts one held-out test per account and market; the rule that chose it was fixed before the runs.
| # | Strategy | The product's counted test? | Trades | Win rate | Max drawdown | Made money | Beat holding | Grade (score, trials) |
|---|---|---|---|---|---|---|---|---|
| 05 | EMA 9/21 cross | yes | 12 | 25% | 12.5% | yes | no | D (39), 1 trial |
| 01 | RSI(2) 10/90 | no | 17 | 76% | 16.0% | yes | no | D (35), 12 trials |
| 03 | MACD 12/26/9 cross | no | 17 | 29% | 18.3% | yes | no | F (11), 12 trials |
| 09 | Parabolic SAR 0.02/0.2 | no | 21 | 38% | 14.5% | yes | no | F (14), 12 trials |
| 02 | RSI(14) 30/70 | no | 2 | Too few trades to show figures | ||||
| 04 | Golden cross 50/200 | no | 1 | Too few trades to show figures | ||||
| 06 | Bollinger 20/2 reversion | no | 8 | Too few trades to show figures | ||||
| 07 | Donchian / Turtle 20/10 | no | 9 | Too few trades to show figures | ||||
| 08 | Supertrend 10/3 | no | 7 | Too few trades to show figures | ||||
| 10 | Stochastic 14/3/3 20/80 | no | 8 | Too few trades to show figures | ||||
| 11 | Keltner breakout 20/2×ATR10 | no | 6 | Too few trades to show figures | ||||
| 12 | Time-series momentum 12-1 | no | 1 | Too few trades to show figures | ||||
The held-out grades of 01, 03 and 09 are against the full search of 12. The counted test (05) is deflated only by held-out attempts on the market, so its trial count is 1.
The tier list
The rule was fixed before the runs. It uses only facts each result states, never taste and never a figure kept out of public text. The tiers are checked from F upwards.
- Made money: the run's net result after costs is above zero.
- Beat holding: the result's own buy-and-hold comparison says it did better.
- Enough trades: at least 30 development trades and at least 10 held-out trades (the product's floor for showing figures).
| Tier | Rule (short) | Strategies |
|---|---|---|
| S | made money and beat holding in both windows, enough trades | none |
| A | made money in both windows, enough trades, didn't beat holding | RSI(2), MACD cross, EMA 9/21 cross, Parabolic SAR |
| B | made money 1999–2024 with enough trades; hold-out too short to judge | Bollinger, Donchian/Turtle, Supertrend, Stochastic, Keltner |
| C | made money 1999–2024, lost on the hold-out | none |
| D | made money, but on fewer than 30 trades in 26 years | RSI(14) 30/70, golden cross, momentum 12-1 |
| F | lost money 1999–2024 | none |
The tier is not QuantWright's grade. Every grade is C or below, and only the EMA 9/21 held-out test is the counted one.
What it shows
- All 12 made money over 26 years, and 11 of 12 did worse than just holding QQQ. On a market that rose a lot, "made money" is easy. The real comparison is holding.
- The only one that beat holding (the golden cross) did it on 16 trades in 26 years. Too few to tell skill from luck, which is why it lands in tier D.
- Big win rates came with deep drawdowns. RSI(2) won 73% of 192 trades, but its account fell 72.4% from a high. The win rate never shows that.
- Of the four with enough held-out trades to judge, none beat holding. The other eight had too few trades in 22 months to show figures, so whether they beat holding isn't known. Short hold-outs can't judge slow strategies.
- The counted held-out test went to the EMA 9/21 cross. Over 2025–2026 it won 25% of 12 trades and did worse than holding, grade D.
The code
The 12 strategy files, as they are. Each is one backtesting.py Strategy class. The SHA-256 of each file was fixed in the protocol before any run.
01 RSI(2) 10/90
01_rsi2.pySHA-256: 918eccd6851cf015b3c4592fae89380061eee29994046365f54a1cdbae1f37c0
"""RSI(2) 10/90, long only.
Long when RSI(2) closes below 10; exit when RSI(2) closes above 90.
RSI is Wilder's: the average gain and the average loss are smoothed with alpha = 1/n,
each seeded with the simple mean of the first n changes; RSI = 100 - 100 / (1 + gain / loss),
and 100 when the average loss is 0. Orders fill at the next bar's open.
"""
import numpy as np
from backtesting import Strategy
def smooth(values, n, alpha):
"""out[i] = alpha * x[i] + (1 - alpha) * out[i-1], seeded with the mean of the first n values (leading NaNs skipped)."""
x = np.asarray(values, dtype=float)
out = np.full(len(x), np.nan)
first = int(np.argmax(~np.isnan(x)))
seed = first + n - 1
if seed >= len(x):
return out
out[seed] = x[first:seed + 1].mean()
for i in range(seed + 1, len(x)):
out[i] = alpha * x[i] + (1 - alpha) * out[i - 1]
return out
def rsi(close, n):
change = np.diff(np.asarray(close, dtype=float), prepend=np.nan)
gain = smooth(np.clip(change, 0, None), n, 1.0 / n)
loss = smooth(np.clip(-change, 0, None), n, 1.0 / n)
return np.where(loss == 0, 100.0, 100.0 - 100.0 / (1.0 + gain / loss))
class Rsi2(Strategy):
rsi_period = 2
entry_below = 10
exit_above = 90
def init(self):
self.rsi = self.I(rsi, self.data.Close, self.rsi_period, name='RSI')
def next(self):
if not self.position:
if self.rsi[-1] < self.entry_below:
self.buy()
elif self.rsi[-1] > self.exit_above:
self.position.close()
02 RSI(14) 30/70
02_rsi14.pySHA-256: 34ee64a8a3700054e0d0812d520edd8e5133de42f5bf489474c5114fa285ed80
"""RSI(14) 30/70, long only.
Long when RSI(14) closes below 30; exit when RSI(14) closes above 70.
RSI is Wilder's: the average gain and the average loss are smoothed with alpha = 1/n,
each seeded with the simple mean of the first n changes; RSI = 100 - 100 / (1 + gain / loss),
and 100 when the average loss is 0. Orders fill at the next bar's open.
"""
import numpy as np
from backtesting import Strategy
def smooth(values, n, alpha):
"""out[i] = alpha * x[i] + (1 - alpha) * out[i-1], seeded with the mean of the first n values (leading NaNs skipped)."""
x = np.asarray(values, dtype=float)
out = np.full(len(x), np.nan)
first = int(np.argmax(~np.isnan(x)))
seed = first + n - 1
if seed >= len(x):
return out
out[seed] = x[first:seed + 1].mean()
for i in range(seed + 1, len(x)):
out[i] = alpha * x[i] + (1 - alpha) * out[i - 1]
return out
def rsi(close, n):
change = np.diff(np.asarray(close, dtype=float), prepend=np.nan)
gain = smooth(np.clip(change, 0, None), n, 1.0 / n)
loss = smooth(np.clip(-change, 0, None), n, 1.0 / n)
return np.where(loss == 0, 100.0, 100.0 - 100.0 / (1.0 + gain / loss))
class Rsi14(Strategy):
rsi_period = 14
entry_below = 30
exit_above = 70
def init(self):
self.rsi = self.I(rsi, self.data.Close, self.rsi_period, name='RSI')
def next(self):
if not self.position:
if self.rsi[-1] < self.entry_below:
self.buy()
elif self.rsi[-1] > self.exit_above:
self.position.close()
03 MACD 12/26/9 cross
03_macd.pySHA-256: eba0bfaaaa764f332883c9b21e79a4f7563d50123b14bce9a2265a5c480d591a
"""MACD(12, 26, 9) cross, long only.
Long when the MACD line crosses above the signal line; exit when it crosses below.
MACD line = EMA(12) - EMA(26) of the close; signal line = EMA(9) of the MACD line.
EMA: alpha = 2 / (n + 1), seeded with the simple mean of the first n values.
A crosses above B on a bar when A > B on that bar and A <= B on the bar before (below: the mirror).
Orders fill at the next bar's open.
"""
import numpy as np
from backtesting import Strategy
def smooth(values, n, alpha):
"""out[i] = alpha * x[i] + (1 - alpha) * out[i-1], seeded with the mean of the first n values (leading NaNs skipped)."""
x = np.asarray(values, dtype=float)
out = np.full(len(x), np.nan)
first = int(np.argmax(~np.isnan(x)))
seed = first + n - 1
if seed >= len(x):
return out
out[seed] = x[first:seed + 1].mean()
for i in range(seed + 1, len(x)):
out[i] = alpha * x[i] + (1 - alpha) * out[i - 1]
return out
def ema(values, n):
return smooth(values, n, 2.0 / (n + 1))
class MacdCross(Strategy):
fast_period = 12
slow_period = 26
signal_period = 9
def init(self):
self.macd = self.I(lambda c: ema(c, self.fast_period) - ema(c, self.slow_period), self.data.Close, name='MACD')
self.signal = self.I(lambda m: ema(m, self.signal_period), self.macd, name='Signal')
def next(self):
m, s = self.macd, self.signal
if not self.position:
if m[-2] <= s[-2] and m[-1] > s[-1]:
self.buy()
elif m[-2] >= s[-2] and m[-1] < s[-1]:
self.position.close()
04 Golden cross 50/200
04_golden_cross.pySHA-256: 4a6f5086a9acd1a393e13b3ea5eb63b894712034936593f60a79849dea74dd34
"""Golden cross 50/200, long only.
Long when SMA(50) of the close crosses above SMA(200); exit when it crosses below.
A crosses above B on a bar when A > B on that bar and A <= B on the bar before (below: the mirror).
Orders fill at the next bar's open.
"""
import pandas as pd
from backtesting import Strategy
def sma(values, n):
return pd.Series(values).rolling(n).mean().to_numpy()
class GoldenCross(Strategy):
fast_period = 50
slow_period = 200
def init(self):
self.fast = self.I(sma, self.data.Close, self.fast_period, name='SMA50')
self.slow = self.I(sma, self.data.Close, self.slow_period, name='SMA200')
def next(self):
f, s = self.fast, self.slow
if not self.position:
if f[-2] <= s[-2] and f[-1] > s[-1]:
self.buy()
elif f[-2] >= s[-2] and f[-1] < s[-1]:
self.position.close()
05 EMA 9/21 cross
05_ema_9_21.pySHA-256: 3f894d90a4dd5dd6cb03bfa642d1b6e2ca6226f7f4121c420b111e28ad48ac05
"""EMA 9/21 cross, long only.
Long when EMA(9) of the close crosses above EMA(21); exit when it crosses below.
EMA: alpha = 2 / (n + 1), seeded with the simple mean of the first n values.
A crosses above B on a bar when A > B on that bar and A <= B on the bar before (below: the mirror).
Orders fill at the next bar's open.
"""
import numpy as np
from backtesting import Strategy
def smooth(values, n, alpha):
"""out[i] = alpha * x[i] + (1 - alpha) * out[i-1], seeded with the mean of the first n values (leading NaNs skipped)."""
x = np.asarray(values, dtype=float)
out = np.full(len(x), np.nan)
first = int(np.argmax(~np.isnan(x)))
seed = first + n - 1
if seed >= len(x):
return out
out[seed] = x[first:seed + 1].mean()
for i in range(seed + 1, len(x)):
out[i] = alpha * x[i] + (1 - alpha) * out[i - 1]
return out
def ema(values, n):
return smooth(values, n, 2.0 / (n + 1))
class EmaCross(Strategy):
fast_period = 9
slow_period = 21
def init(self):
self.fast = self.I(ema, self.data.Close, self.fast_period, name='EMA9')
self.slow = self.I(ema, self.data.Close, self.slow_period, name='EMA21')
def next(self):
f, s = self.fast, self.slow
if not self.position:
if f[-2] <= s[-2] and f[-1] > s[-1]:
self.buy()
elif f[-2] >= s[-2] and f[-1] < s[-1]:
self.position.close()
06 Bollinger 20/2 reversion
06_bollinger.pySHA-256: c4a0d4f804e3b33cc3fb51c7723790c0e722ac61e4f83d5bb7e9b268870b257f
"""Bollinger(20, 2) mean reversion, long only.
Long when the close is below the lower band; exit when the close is above the middle band.
Middle band = SMA(20) of the close; lower band = middle - 2 x the population standard
deviation (divisor n) of the last 20 closes. Orders fill at the next bar's open.
"""
import pandas as pd
from backtesting import Strategy
class BollingerReversion(Strategy):
period = 20
width = 2
def init(self):
close = pd.Series(self.data.Close)
mid = close.rolling(self.period).mean()
lower = mid - self.width * close.rolling(self.period).std(ddof=0)
self.mid = self.I(lambda: mid.to_numpy(), name='Middle')
self.lower = self.I(lambda: lower.to_numpy(), name='Lower')
def next(self):
close = self.data.Close[-1]
if not self.position:
if close < self.lower[-1]:
self.buy()
elif close > self.mid[-1]:
self.position.close()
07 Donchian / Turtle 20/10
07_donchian.pySHA-256: a6e8e7290be5f34b4c1ddc6af857603ae70739d77f32c9579c7084fb5172860b
"""Donchian / Turtle System 1 breakout, long only (the stated close rule only).
Long when the close is above the highest high of the previous 20 bars; exit when the close
is below the lowest low of the previous 10 bars. "Previous" leaves the current bar out.
Orders fill at the next bar's open.
"""
import pandas as pd
from backtesting import Strategy
class DonchianBreakout(Strategy):
entry_period = 20
exit_period = 10
def init(self):
self.prior_high = self.I(lambda h: pd.Series(h).rolling(self.entry_period).max().shift(1).to_numpy(), self.data.High, name='High20')
self.prior_low = self.I(lambda low: pd.Series(low).rolling(self.exit_period).min().shift(1).to_numpy(), self.data.Low, name='Low10')
def next(self):
close = self.data.Close[-1]
if not self.position:
if close > self.prior_high[-1]:
self.buy()
elif close < self.prior_low[-1]:
self.position.close()
08 Supertrend 10/3
08_supertrend.pySHA-256: b23f395d20bbe9d4fa9d25cdaf23af956998fc1ef5c6a543dc543a805566d3a9
"""Supertrend(10, 3), long only.
Long when Supertrend flips to up; exit when it flips to down.
True range = max(high - low, |high - previous close|, |low - previous close|), and high - low on
the first bar. ATR(10) is Wilder's: alpha = 1/10, seeded with the mean of the first 10 true ranges.
Basic bands: (high + low) / 2 +/- 3 x ATR. Final upper band: the basic upper band if it is below the
previous final upper band or the previous close was above it, else the previous final upper band;
final lower band: the mirror. The trend starts down on the first bar with an ATR. While down it turns
up when the close is above the final upper band; while up it turns down when the close is below the
final lower band. Orders fill at the next bar's open.
"""
import numpy as np
from backtesting import Strategy
def smooth(values, n, alpha):
"""out[i] = alpha * x[i] + (1 - alpha) * out[i-1], seeded with the mean of the first n values (leading NaNs skipped)."""
x = np.asarray(values, dtype=float)
out = np.full(len(x), np.nan)
first = int(np.argmax(~np.isnan(x)))
seed = first + n - 1
if seed >= len(x):
return out
out[seed] = x[first:seed + 1].mean()
for i in range(seed + 1, len(x)):
out[i] = alpha * x[i] + (1 - alpha) * out[i - 1]
return out
def supertrend_direction(high, low, close, n, mult):
"""+1 while the trend is up, -1 while it is down, NaN before the first ATR."""
high = np.asarray(high, dtype=float)
low = np.asarray(low, dtype=float)
close = np.asarray(close, dtype=float)
prev_close = np.concatenate(([np.nan], close[:-1]))
true_range = np.fmax(high - low, np.fmax(np.abs(high - prev_close), np.abs(low - prev_close)))
atr = smooth(true_range, n, 1.0 / n)
upper = (high + low) / 2 + mult * atr
lower = (high + low) / 2 - mult * atr
direction = np.full(len(close), np.nan)
start = int(np.argmax(~np.isnan(atr)))
if np.isnan(atr[start]):
return direction
direction[start] = -1.0
for i in range(start + 1, len(close)):
if not (upper[i] < upper[i - 1] or close[i - 1] > upper[i - 1]):
upper[i] = upper[i - 1]
if not (lower[i] > lower[i - 1] or close[i - 1] < lower[i - 1]):
lower[i] = lower[i - 1]
if direction[i - 1] < 0:
direction[i] = 1.0 if close[i] > upper[i] else -1.0
else:
direction[i] = -1.0 if close[i] < lower[i] else 1.0
return direction
class Supertrend(Strategy):
atr_period = 10
multiplier = 3
def init(self):
self.direction = self.I(supertrend_direction, self.data.High, self.data.Low, self.data.Close,
self.atr_period, self.multiplier, name='Direction')
def next(self):
d = self.direction
if not self.position:
if d[-2] < 0 and d[-1] > 0:
self.buy()
elif d[-2] > 0 and d[-1] < 0:
self.position.close()
09 Parabolic SAR 0.02/0.2
09_parabolic_sar.pySHA-256: a7cdc5128deef3c54100b7e37cdf489af9189c1217763fc6d46a71b24685e1af
"""Parabolic SAR(0.02 step, 0.2 max), long only.
Long when price flips above the SAR; exit when it flips below.
Wilder's Parabolic SAR as TA-Lib computes it (TA_SAR): the acceleration factor starts at 0.02,
rises 0.02 with each new extreme point, and is capped at 0.2. The first trend (bar 1) is short when
bar 1's down move (previous low - low) is positive and larger than its up move (high - previous high),
else long. A long flips short when the low touches or crosses the SAR (low <= SAR); a short flips long
when the high touches or crosses it (high >= SAR). On a flip the SAR restarts at the prior extreme point.
Each next SAR = SAR + AF x (EP - SAR), kept beyond the current and previous bar's range (below their
lows while long, above their highs while short). Orders fill at the next bar's open.
"""
import numpy as np
from backtesting import Strategy
def sar_trend(high, low, step, max_step):
"""+1 while the SAR trend is long, -1 while short, after each bar's flip check; NaN on bar 0."""
high = np.asarray(high, dtype=float)
low = np.asarray(low, dtype=float)
trend = np.full(len(high), np.nan)
if len(high) < 2:
return trend
down_move = low[0] - low[1]
up_move = high[1] - high[0]
is_long = not (down_move > 0 and down_move > up_move)
if is_long:
ep, sar = high[1], low[0]
else:
ep, sar = low[1], high[0]
af = step
new_high, new_low = high[1], low[1]
for i in range(1, len(high)):
prev_high, prev_low = new_high, new_low
new_high, new_low = high[i], low[i]
if is_long:
if new_low <= sar:
is_long = False
sar = max(ep, prev_high, new_high)
af = step
ep = new_low
sar = max(sar + af * (ep - sar), prev_high, new_high)
else:
if new_high > ep:
ep = new_high
af = min(af + step, max_step)
sar = min(sar + af * (ep - sar), prev_low, new_low)
else:
if new_high >= sar:
is_long = True
sar = min(ep, prev_low, new_low)
af = step
ep = new_high
sar = min(sar + af * (ep - sar), prev_low, new_low)
else:
if new_low < ep:
ep = new_low
af = min(af + step, max_step)
sar = max(sar + af * (ep - sar), prev_high, new_high)
trend[i] = 1.0 if is_long else -1.0
return trend
class ParabolicSar(Strategy):
step = 0.02
max_step = 0.2
def init(self):
self.trend = self.I(sar_trend, self.data.High, self.data.Low, self.step, self.max_step, name='SAR trend')
def next(self):
t = self.trend
if not self.position:
if t[-2] < 0 and t[-1] > 0:
self.buy()
elif t[-2] > 0 and t[-1] < 0:
self.position.close()
10 Stochastic 14/3/3 20/80
10_stochastic.pySHA-256: 8146121862a610b083a6684e67c1caedf3aadef4a0cafbce6a1d3032ad31cb3e
"""Stochastic(14, 3, 3) 20/80, long only.
Long when the slow %K crosses above 20 from below; exit when it crosses below 80 from above.
Fast %K = 100 x (close - lowest low of 14 bars) / (highest high of 14 bars - lowest low of 14 bars),
the current bar included; slow %K = SMA(3) of fast %K. (The second 3, the slow %D, is not used by the rule.)
Crosses above 20: slow %K > 20 on the bar and <= 20 on the bar before; crosses below 80: slow %K < 80
on the bar and >= 80 on the bar before. Orders fill at the next bar's open.
"""
import pandas as pd
from backtesting import Strategy
def slow_k(high, low, close, n, smoothing):
highest = pd.Series(high).rolling(n).max()
lowest = pd.Series(low).rolling(n).min()
fast = 100 * (pd.Series(close) - lowest) / (highest - lowest)
return fast.rolling(smoothing).mean().to_numpy()
class Stochastic(Strategy):
k_period = 14
k_smoothing = 3
entry_level = 20
exit_level = 80
def init(self):
self.k = self.I(slow_k, self.data.High, self.data.Low, self.data.Close, self.k_period, self.k_smoothing, name='Slow %K')
def next(self):
k = self.k
if not self.position:
if k[-2] <= self.entry_level and k[-1] > self.entry_level:
self.buy()
elif k[-2] >= self.exit_level and k[-1] < self.exit_level:
self.position.close()
11 Keltner breakout 20/2×ATR10
11_keltner.pySHA-256: 95b39462b2495e871631ec687de0c22ef5bac4a2d696db1ec783b8a2c71f3ef4
"""Keltner breakout, long only.
Middle line EMA(20) of the close; upper band = middle + 2 x ATR(10).
Long when the close is above the upper band; exit when the close is below the middle line.
EMA: alpha = 2 / (n + 1), seeded with the simple mean of the first n values.
True range = max(high - low, |high - previous close|, |low - previous close|), and high - low on
the first bar; ATR(10) is Wilder's: alpha = 1/10, seeded with the mean of the first 10 true ranges.
Orders fill at the next bar's open.
"""
import numpy as np
from backtesting import Strategy
def smooth(values, n, alpha):
"""out[i] = alpha * x[i] + (1 - alpha) * out[i-1], seeded with the mean of the first n values (leading NaNs skipped)."""
x = np.asarray(values, dtype=float)
out = np.full(len(x), np.nan)
first = int(np.argmax(~np.isnan(x)))
seed = first + n - 1
if seed >= len(x):
return out
out[seed] = x[first:seed + 1].mean()
for i in range(seed + 1, len(x)):
out[i] = alpha * x[i] + (1 - alpha) * out[i - 1]
return out
def atr(high, low, close, n):
high = np.asarray(high, dtype=float)
low = np.asarray(low, dtype=float)
close = np.asarray(close, dtype=float)
prev_close = np.concatenate(([np.nan], close[:-1]))
true_range = np.fmax(high - low, np.fmax(np.abs(high - prev_close), np.abs(low - prev_close)))
return smooth(true_range, n, 1.0 / n)
class KeltnerBreakout(Strategy):
ema_period = 20
atr_period = 10
atr_multiple = 2
def init(self):
d = self.data
mid = smooth(d.Close, self.ema_period, 2.0 / (self.ema_period + 1))
upper = mid + self.atr_multiple * atr(d.High, d.Low, d.Close, self.atr_period)
self.mid = self.I(lambda: mid, name='Middle')
self.upper = self.I(lambda: upper, name='Upper')
def next(self):
close = self.data.Close[-1]
if not self.position:
if close > self.upper[-1]:
self.buy()
elif close < self.mid[-1]:
self.position.close()
12 Time-series momentum 12-1
12_tsmom_12_1.pySHA-256: 17f75e5c715906fecc370093c1648b2776f5577b62ddc353f183ea20e48e9c02
"""Time-series momentum 12-1, long only.
At each month's last trading day, be long for the next month if the return from 252 trading days ago
to 21 trading days ago (close[t-21] / close[t-252] - 1) is positive, else flat. Orders fill at the next
bar's open, the first trading day of the next month.
The month's last trading day is found from each bar's own date: the bar is the last weekday (Mon-Fri)
of its month. When that weekday is an exchange holiday (Good Friday or Memorial Day at a month's end)
there is no such bar, and the decision is made on the first bar of the new month instead, one day late.
"""
import pandas as pd
from backtesting import Strategy
class TimeSeriesMomentum(Strategy):
lookback = 252
skip = 21
def init(self):
days = self.data.index
month = (days.year * 12 + days.month).to_numpy(dtype=float)
last_weekday = ((days + pd.offsets.BDay(1)).month != days.month).astype(float)
close = pd.Series(self.data.Close)
momentum = (close.shift(self.skip) / close.shift(self.lookback) - 1).to_numpy()
self.month = self.I(lambda: month, name='Month')
self.last_weekday = self.I(lambda: last_weekday, name='Last weekday')
self.momentum = self.I(lambda: momentum, name='Momentum 12-1')
self.decided_for = 0
def next(self):
month = self.month[-1]
if self.last_weekday[-1] > 0:
self.decide()
self.decided_for = month + 1
elif 0 < self.decided_for < month:
self.decide()
self.decided_for = month
def decide(self):
if self.momentum[-1] > 0:
if not self.position:
self.buy()
elif self.position:
self.position.close()
Found a mistake?
If we coded a strategy wrong, tell us and we'll rerun it; every rerun is counted and logged on /corrections. Write to support@quantwright.com.
Research software, not financial advice. Backtested results are hypothetical. Data: Tiingo, US stocks and ETFs on daily bars, split-adjusted, with cash dividends credited on each ex-date (total returns). All tested strategies: /tested.
See also: Legal & disclaimers · Terms · Privacy · Support