Skip to content
QuantWright

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.py Strategy class, 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's window.start_later.first_bar names 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 only run_held_out_test reads 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's hold_out_estimate predicts 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 costs argument 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's settings.commission and settings.slippage, which the ledger keeps per run.
  • Account and sizing. The product's default account, US$100,000 (account_usd not sent), with no leverage. Every strategy enters with a bare self.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 allows size=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_side is 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

#FileStrategyRule as implemented
101_rsi2.pyRSI(2) 10/90Long when RSI(2) closes below 10; exit when RSI(2) closes above 90.
202_rsi14.pyRSI(14) 30/70Long when RSI(14) closes below 30; exit when RSI(14) closes above 70.
303_macd.pyMACD(12, 26, 9) crossLong when the MACD line crosses above the signal line; exit when it crosses below.
404_golden_cross.pyGolden cross 50/200Long when SMA(50) crosses above SMA(200); exit when it crosses below.
505_ema_9_21.pyEMA 9/21 crossLong when EMA(9) crosses above EMA(21); exit when it crosses below.
606_bollinger.pyBollinger(20, 2) mean reversionLong when the close is below the lower band; exit when the close is above the middle band.
707_donchian.pyDonchian / Turtle System 1Long 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.
808_supertrend.pySupertrend(10, 3)Long when Supertrend flips to up; exit when it flips to down.
909_parabolic_sar.pyParabolic SAR(0.02, 0.2)Long when the SAR flips below price (trend to long); exit when it flips above price.
1010_stochastic.pyStochastic(14, 3, 3) 20/80Long when slow %K crosses above 20 from below; exit when it crosses below 80 from above.
1111_keltner.pyKeltner breakoutLong when the close is above EMA(20) + 2 × ATR(10); exit when the close is below EMA(20).
1212_tsmom_12_1.pyTime-series momentum 12-1On 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_SAR computes 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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 ta library's RSI. EMA converges to pandas ewm. 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_estimate is 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.

#StrategyTradesWin rateMax drawdownMade money after costsBeat holding QQQGrade (score, trials at the time)
01RSI(2) 10/9019273%72.4%yesnoD (49, 1)
02RSI(14) 30/702181%77.7%yesnoD (35, 2)
03MACD 12/26/9 cross26245%78.2%yesnoD (37, 3)
04Golden cross 50/2001663%28.4%yesyesC (52, 4)
05EMA 9/21 cross13839%45.8%yesnoC (62, 5)
06Bollinger 20/2 reversion11075%58.3%yesnoD (47, 6)
07Donchian / Turtle 20/1011153%43.3%yesnoD (49, 7)
08Supertrend 10/310150%46.0%yesnoD (49, 8)
09Parabolic SAR 0.02/0.231747%59.9%yesnoD (44, 9)
10Stochastic 14/3/3 20/8011463%74.3%yesnoD (35, 10)
11Keltner breakout 20/2×ATR1010049%35.1%yesnoD (49, 11)
12Time-series momentum 12-11580%48.3%yesnoD (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.

#StrategyThe product's counted test?TradesWin rateMax drawdownMade moneyBeat holdingGrade (score, trials)
05EMA 9/21 crossyes1225%12.5%yesnoD (39), 1 trial
01RSI(2) 10/90no1776%16.0%yesnoD (35), 12 trials
03MACD 12/26/9 crossno1729%18.3%yesnoF (11), 12 trials
09Parabolic SAR 0.02/0.2no2138%14.5%yesnoF (14), 12 trials
02RSI(14) 30/70no2Too few trades to show figures
04Golden cross 50/200no1Too few trades to show figures
06Bollinger 20/2 reversionno8Too few trades to show figures
07Donchian / Turtle 20/10no9Too few trades to show figures
08Supertrend 10/3no7Too few trades to show figures
10Stochastic 14/3/3 20/80no8Too few trades to show figures
11Keltner breakout 20/2×ATR10no6Too few trades to show figures
12Time-series momentum 12-1no1Too 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).
TierRule (short)Strategies
Smade money and beat holding in both windows, enough tradesnone
Amade money in both windows, enough trades, didn't beat holdingRSI(2), MACD cross, EMA 9/21 cross, Parabolic SAR
Bmade money 1999–2024 with enough trades; hold-out too short to judgeBollinger, Donchian/Turtle, Supertrend, Stochastic, Keltner
Cmade money 1999–2024, lost on the hold-outnone
Dmade money, but on fewer than 30 trades in 26 yearsRSI(14) 30/70, golden cross, momentum 12-1
Flost money 1999–2024none

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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.py

SHA-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

Study 1: 12 famous strategies, one protocol — QuantWright