LibraryReference articles
After costs, none of the 7 timing rules earned more a year than simply holding the US stock market from 1993–2026, and none did in Europe. The slow trend rules did cut the worst fall in US stocks, from −54.6% to between −34.2% and −30.5%. On three major currency pairs, 19 of 21 rule results lost money after costs.
We took seven trading rules that books, courses and charting sites teach to beginners, and tested each one with its textbook settings, untuned, on the same data and with the same costs. The yardstick is holding the market throughout. Every number below comes from code you can read and rerun, and every result is in a CSV you can download.
US stock market, 1 February 1993 to 30 September 2026. Costs: 5 basis points a side, plus 0.09% a year fund fee.
| Rule | Yearly return, after costs | Before costs | Sharpe | Worst fall | Years ahead of holding | Switches a year | Time in market |
|---|---|---|---|---|---|---|---|
| Hold throughout | 10.8% | 10.9% | 0.52 | −54.6% | n/a | 0.0 | 100% |
| 200-day average | 8.6% | 9.0% | 0.55 | −30.5% | 16% | 6.3 | 77% |
| 50/200 crossover | 10.4% | 10.6% | 0.62 | −34.2% | 19% | 0.9 | 77% |
| MACD | 4.4% | 5.6% | 0.22 | −34.4% | 22% | 21.5 | 51% |
| RSI 30/70 | 6.8% | 6.9% | 0.35 | −50.8% | 34% | 1.8 | 34% |
| Bollinger | 5.1% | 5.6% | 0.27 | −30.3% | 28% | 8.6 | 19% |
| Donchian | 5.4% | 6.1% | 0.35 | −29.0% | 19% | 12.2 | 51% |
| 12-month momentum | 10.5% | 10.6% | 0.60 | −34.2% | 13% | 0.6 | 81% |
The window starts in February 1993, the first full month of trading of the first US-listed index ETF, so a reader could have run each rule at about these costs. The US series is the whole US stock market with dividends reinvested, not the S&P 500.
European stock market, 1 July 1991 to 30 September 2026. Costs: 10 basis points a side, plus 0.20% a year fund fee.
| Rule | Yearly return, after costs | Before costs | Sharpe | Worst fall | Years ahead of holding | Switches a year | Time in market |
|---|---|---|---|---|---|---|---|
| Hold throughout | 8.1% | 8.3% | 0.38 | −62.2% | n/a | 0.0 | 100% |
| 200-day average | 7.7% | 8.6% | 0.47 | −24.8% | 32% | 7.2 | 71% |
| 50/200 crossover | 7.0% | 7.3% | 0.40 | −36.9% | 29% | 1.3 | 71% |
| MACD | 2.2% | 4.5% | 0.03 | −48.7% | 26% | 21.4 | 51% |
| RSI 30/70 | 2.8% | 3.0% | 0.09 | −60.0% | 32% | 1.5 | 37% |
| Bollinger | 2.2% | 3.2% | 0.03 | −44.3% | 32% | 9.0 | 22% |
| Donchian | 4.2% | 5.6% | 0.21 | −31.8% | 29% | 12.8 | 48% |
| 12-month momentum | 5.9% | 6.2% | 0.31 | −45.8% | 15% | 1.2 | 71% |
The European series covers the developed European markets, the UK included, measured in US dollars with dividends.
| Rule | EUR/USD | GBP/USD | USD/JPY |
|---|---|---|---|
| Hold throughout | −0.6% | −1.7% | 0.6% |
| 200-day average | 0.5% | −1.1% | −1.9% |
| 50/200 crossover | −0.6% | −2.1% | −1.7% |
| MACD | −2.1% | −1.6% | −2.5% |
| RSI 30/70 | −3.6% | −2.6% | −1.7% |
| Bollinger | −0.8% | −0.1% | −0.6% |
| Donchian | −0.3% | −2.9% | −2.6% |
| 12-month momentum | −2.8% | −1.5% | 2.3% |
EUR/USD, 3 January 2000 to 30 October 2026. Costs: 2 basis points a side, plus 1.00% a year financing mark-up.
| Rule | Yearly return, after costs | Before costs | Sharpe | Worst fall | Years with a gain | Switches a year | Time in market |
|---|---|---|---|---|---|---|---|
| Hold throughout | −0.6% | 0.4% | −0.02 | −48.0% | 46% | 0.0 | 100% |
| 200-day average | 0.5% | 1.8% | 0.10 | −30.3% | 58% | 6.7 | 100% |
| 50/200 crossover | −0.6% | 0.4% | −0.02 | −38.6% | 38% | 1.5 | 100% |
| MACD | −2.1% | −0.3% | −0.19 | −59.5% | 42% | 21.2 | 100% |
| RSI 30/70 | −3.6% | −2.6% | −0.36 | −74.2% | 19% | 1.6 | 100% |
| Bollinger | −0.8% | 0.1% | −0.08 | −39.8% | 50% | 18.6 | 55% |
| Donchian | −0.3% | 0.9% | 0.00 | −44.4% | 38% | 21.1 | 71% |
| 12-month momentum | −2.8% | −1.7% | −0.26 | −65.7% | 35% | 1.8 | 100% |
GBP/USD, 3 January 2000 to 30 October 2026. Costs: 2 basis points a side, plus 1.00% a year financing mark-up.
| Rule | Yearly return, after costs | Before costs | Sharpe | Worst fall | Years with a gain | Switches a year | Time in market |
|---|---|---|---|---|---|---|---|
| Hold throughout | −1.7% | −0.7% | −0.14 | −56.3% | 50% | 0.0 | 100% |
| 200-day average | −1.1% | 0.2% | −0.07 | −52.2% | 46% | 8.0 | 100% |
| 50/200 crossover | −2.1% | −1.1% | −0.19 | −54.3% | 42% | 1.7 | 100% |
| MACD | −1.6% | 0.2% | −0.13 | −49.2% | 42% | 19.1 | 100% |
| RSI 30/70 | −2.6% | −1.6% | −0.24 | −67.9% | 35% | 1.6 | 100% |
| Bollinger | −0.1% | 0.8% | 0.02 | −29.0% | 50% | 19.0 | 53% |
| Donchian | −2.9% | −1.7% | −0.33 | −58.6% | 35% | 22.3 | 70% |
| 12-month momentum | −1.5% | −0.5% | −0.12 | −61.8% | 38% | 1.5 | 100% |
USD/JPY, 3 January 2000 to 30 October 2026. Costs: 2 basis points a side, plus 1.00% a year financing mark-up.
| Rule | Yearly return, after costs | Before costs | Sharpe | Worst fall | Years with a gain | Switches a year | Time in market |
|---|---|---|---|---|---|---|---|
| Hold throughout | 0.6% | 1.6% | 0.11 | −49.0% | 42% | 0.0 | 100% |
| 200-day average | −1.9% | −0.5% | −0.15 | −44.2% | 42% | 9.7 | 100% |
| 50/200 crossover | −1.7% | −0.7% | −0.13 | −47.0% | 38% | 1.5 | 100% |
| MACD | −2.5% | −0.7% | −0.21 | −59.3% | 42% | 20.4 | 100% |
| RSI 30/70 | −1.7% | −0.7% | −0.13 | −58.6% | 50% | 1.7 | 100% |
| Bollinger | −0.6% | 0.3% | −0.04 | −37.3% | 38% | 18.1 | 54% |
| Donchian | −2.6% | −1.4% | −0.27 | −57.3% | 38% | 21.7 | 72% |
| 12-month momentum | 2.3% | 3.3% | 0.28 | −21.7% | 58% | 1.2 | 100% |
For currencies, the rules go short as well as long, as forex traders usually do, and the yardstick holds the first-named currency throughout. 19 of the 21 timing-rule results lost money after costs.
The rule. The yardstick. It is in the market every day of the window and never trades again after the first day. For a currency pair it means holding the first-named currency against the second.
Stocks. US stock market 1993–2026: 10.8% a year after costs, worst fall −54.6%, Sharpe 0.52. Europe 1991–2026: 8.1% a year, worst fall −62.2%. US 1927–2026: 10.1% a year, worst fall −84.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD −0.6%, GBP/USD −1.7%, USD/JPY 0.6%.
Every timing rule below is measured against this one on the same data and with the same costs.
The rule. At each close, compare the price with its average over the last 200 trading days. Above the average: in the market for the next day. Below: in cash (stocks) or short (currencies).
Stocks. US stock market 1993–2026: 8.6% a year after costs against 10.8% for holding throughout; worst fall −30.5% against −54.6%; ahead of holding in 16% of full calendar years. Europe 1991–2026: 7.7% against 8.1%; worst fall −24.8% against −62.2%. US 1927–2026: 11.1% against 10.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD 0.5%, GBP/USD −1.1%, USD/JPY −1.9%.
In stocks its job is to step aside in long falls. It pays for that with many small whipsaws when the price hugs the average.
The rule. In the market while the 50-day average is above the 200-day average. Out (stocks) or short (currencies) while it is below.
Stocks. US stock market 1993–2026: 10.4% a year after costs against 10.8% for holding throughout; worst fall −34.2% against −54.6%; ahead of holding in 19% of full calendar years. Europe 1991–2026: 7.0% against 8.1%; worst fall −36.9% against −62.2%. US 1927–2026: 9.1% against 10.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD −0.6%, GBP/USD −2.1%, USD/JPY −1.7%.
It trades far less often than the 200-day filter, so costs barely touch it. It still lags at turning points by design.
The rule. The MACD line is the 12-day exponential average minus the 26-day one; its signal line is a 9-day exponential average of the MACD line. In the market while MACD is above its signal line; out (stocks) or short (currencies) while it is below.
Stocks. US stock market 1993–2026: 4.4% a year after costs against 10.8% for holding throughout; worst fall −34.4% against −54.6%; ahead of holding in 22% of full calendar years. Europe 1991–2026: 2.2% against 8.1%; worst fall −48.7% against −62.2%. US 1927–2026: 9.5% against 10.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD −2.1%, GBP/USD −1.6%, USD/JPY −2.5%.
It switches about twice a month, the most of any rule here, so its costs are the largest. Compare its before-costs and after-costs columns.
The rule. The Relative Strength Index (Wilder, 14 days) runs from 0 to 100. The rule goes in when RSI falls below 30 ("oversold") and steps out when it rises above 70 ("overbought"). For currencies, above 70 it turns short instead of flat.
Stocks. US stock market 1993–2026: 6.8% a year after costs against 10.8% for holding throughout; worst fall −50.8% against −54.6%; ahead of holding in 34% of full calendar years. Europe 1991–2026: 2.8% against 8.1%; worst fall −60.0% against −62.2%. US 1927–2026: 3.9% against 10.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD −3.6%, GBP/USD −2.6%, USD/JPY −1.7%.
A mean-reversion rule: it bets that falls bounce. In a trending market it spends long stretches out, or on the wrong side.
The rule. The bands sit two standard deviations either side of the 20-day average. The rule goes in when the price closes below the lower band and steps out when it gets back to the average. For currencies it also goes short above the upper band and exits at the average.
Stocks. US stock market 1993–2026: 5.1% a year after costs against 10.8% for holding throughout; worst fall −30.3% against −54.6%; ahead of holding in 28% of full calendar years. Europe 1991–2026: 2.2% against 8.1%; worst fall −44.3% against −62.2%. US 1927–2026: 2.3% against 10.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD −0.8%, GBP/USD −0.1%, USD/JPY −0.6%.
It is in the market only a small share of days, so its result says more about those few days than about the market.
The rule. In the market when the close is above the highest close of the previous 20 days; out when it falls below the lowest close of the previous 10 days. For currencies the mirror image also goes short. This is a close-only version of the Turtle rule.
Stocks. US stock market 1993–2026: 5.4% a year after costs against 10.8% for holding throughout; worst fall −29.0% against −54.6%; ahead of holding in 19% of full calendar years. Europe 1991–2026: 4.2% against 8.1%; worst fall −31.8% against −62.2%. US 1927–2026: 9.1% against 10.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD −0.3%, GBP/USD −2.9%, USD/JPY −2.6%.
A breakout rule switches often in sideways markets, so costs and whipsaws weigh on it.
The rule. On the last trading day of each month, look at the past 12 months' return. Positive: in the market for the next month. Negative: out (stocks) or short (currencies).
Stocks. US stock market 1993–2026: 10.5% a year after costs against 10.8% for holding throughout; worst fall −34.2% against −54.6%; ahead of holding in 13% of full calendar years. Europe 1991–2026: 5.9% against 8.1%; worst fall −45.8% against −62.2%. US 1927–2026: 10.0% against 10.1%.
Currencies (2000–2026, yearly return after costs): EUR/USD −2.8%, GBP/USD −1.5%, USD/JPY 2.3%.
It decides only once a month, so it trades rarely. It is the slowest rule on both stock markets.
Split in half. A result that only works in one half of the data is fragile. The table shows the Sharpe ratio (return per unit of risk, above cash) in each half of each window.
Sharpe ratio in the first half / second half of each window, after costs. Each window is cut at its calendar midpoint.
| Rule | US stock market 1993–2009 / to 2026 | US stock market, long history 1927–1976 / to 2026 | European stock market 1991–2009 / to 2026 | EUR/USD 2000–2013 / to 2026 | GBP/USD 2000–2013 / to 2026 | USD/JPY 2000–2013 / to 2026 |
|---|---|---|---|---|---|---|
| Hold throughout | 0.30 / 0.76 | 0.41 / 0.51 | 0.26 / 0.50 | 0.14 / −0.24 | −0.10 / −0.18 | −0.05 / 0.30 |
| 200-day average | 0.43 / 0.67 | 0.83 / 0.57 | 0.59 / 0.37 | 0.14 / 0.05 | 0.11 / −0.27 | −0.20 / −0.09 |
| 50/200 crossover | 0.62 / 0.62 | 0.46 / 0.53 | 0.57 / 0.25 | −0.01 / −0.04 | −0.14 / −0.24 | −0.09 / −0.17 |
| MACD | −0.03 / 0.51 | 0.83 / 0.36 | −0.19 / 0.24 | 0.02 / −0.47 | −0.07 / −0.18 | −0.16 / −0.27 |
| RSI 30/70 | 0.22 / 0.53 | −0.02 / 0.27 | −0.08 / 0.26 | −0.29 / −0.48 | −0.33 / −0.14 | 0.00 / −0.28 |
| Bollinger | 0.22 / 0.32 | −0.21 / 0.20 | −0.05 / 0.11 | −0.30 / 0.23 | −0.19 / 0.25 | 0.03 / −0.12 |
| Donchian | 0.07 / 0.66 | 0.95 / 0.36 | 0.27 / 0.16 | 0.32 / −0.44 | −0.21 / −0.47 | −0.32 / −0.21 |
| 12-month momentum | 0.57 / 0.63 | 0.59 / 0.54 | 0.57 / 0.09 | −0.24 / −0.31 | −0.06 / −0.19 | 0.30 / 0.25 |
A century of US data. Run back to 1927, the picture changes for some rules. With today's costs, which are too low for most of that century, here is the same test from 3 January 1927 to 30 September 2026:
US stock market, long history, 3 January 1927 to 30 September 2026. Costs: 5 basis points a side, plus 0.09% a year fund fee.
| Rule | Yearly return, after costs | Before costs | Sharpe | Worst fall | Years ahead of holding | Switches a year | Time in market |
|---|---|---|---|---|---|---|---|
| Hold throughout | 10.1% | 10.2% | 0.46 | −84.1% | n/a | 0.0 | 100% |
| 200-day average | 11.1% | 11.5% | 0.70 | −33.8% | 28% | 5.6 | 72% |
| 50/200 crossover | 9.1% | 9.2% | 0.50 | −61.7% | 20% | 1.1 | 72% |
| MACD | 9.5% | 10.6% | 0.60 | −37.2% | 40% | 19.9 | 51% |
| RSI 30/70 | 3.9% | 4.1% | 0.12 | −79.4% | 33% | 2.3 | 36% |
| Bollinger | 2.3% | 2.8% | −0.01 | −75.0% | 23% | 8.4 | 21% |
| Donchian | 9.1% | 9.9% | 0.66 | −31.7% | 36% | 12.4 | 50% |
| 12-month momentum | 10.0% | 10.1% | 0.56 | −47.1% | 16% | 0.8 | 74% |
Several timing rules look better over the full century than since 1993. Two things differ. Their worst fall was far smaller: from 1927 the 200-day average fell at most −33.8%, against −84.1% for holding, because it held stocks on few days of the 1929–1932 crash. And their edge sits in the older history, not in the crash alone: in the column for 1927 to 2026, the 200-day average, MACD and the Donchian breakout had a Sharpe ratio of 0.83, 0.83 and 0.95 in the first half, and 0.57, 0.36 and 0.36 in the second. A rule chosen because it worked in one stretch of history is the trap our lesson on backtest overfitting explains.
THUZTRA GLOBAL RULES STUDY v1 - FROZEN SPEC (written 2026-11-08, before any result was computed)
Purpose: test widely taught retail trading rules on US stocks, European stocks and three major
FX pairs, after costs, with textbook parameters only (no tuning).
DATA (frozen in raw/, hashes in raw/SHA256SUMS)
- US: raw/F-F_Research_Data_Factors_daily.csv (Kenneth R. French Data Library). Columns are percent.
Daily total market return R_t = (Mkt-RF + RF)/100. Cash return RF_t = RF/100.
This is the CRSP value-weighted US market (NYSE, AMEX, NASDAQ) including dividends. Not the S&P 500.
- Europe: raw/Europe_3_Factors_Daily.csv (same library). R_t = (Mkt-RF + RF)/100 in USD, RF_t = RF/100
(US one-month T-bill). Rows with -99.99 are dropped. Europe includes the UK.
- FX: raw/DEXUSEU.csv (EUR/USD, USD per EUR), raw/DEXUSUK.csv (GBP/USD, USD per GBP),
raw/DEXJPUS.csv (USD/JPY, JPY per USD). Federal Reserve H.10 noon buying rates via FRED.
Blank values (holidays) are dropped. Pair return r_t = P_t / P_{t-1} - 1.
PRICE SERIES FOR SIGNALS
- Stocks: P_t = cumulative product of (1 + R_t) over the whole file (total-return index).
- FX: P_t = the quoted rate.
Indicators use the whole file history (so warm-up comes before the evaluation window).
TIMING (no look-ahead)
- A signal s_t uses data up to and including close t. The position held during day t+1 is pos_{t+1} = s_t.
- The day-t return uses pos_t.
RULES (stocks: long = 1, flat = 0. FX: long = +1, short = -1, flat = 0)
1. BH Buy and hold: s = 1 always (FX: long the pair always).
2. SMA200 Price above 200-day simple moving average: s = 1 if P > SMA200 else 0 (FX: +1 / -1).
3. GC 50/200 moving-average crossover: s = 1 if SMA50 > SMA200 else 0 (FX: +1 / -1).
4. MACD 12/26/9: MACD = EMA12(P) - EMA26(P); signal = EMA9(MACD); s = 1 if MACD > signal else 0 (FX: +1/-1).
EMA_n: alpha = 2/(n+1), recursive, seeded with the first value (pandas ewm(adjust=False)).
5. RSI RSI(14) 30/70 (Wilder): d = P_t - P_{t-1}; gain = max(d,0); loss = max(-d,0);
avg = ewm(alpha=1/14, adjust=False) of gain and loss starting at the first diff; RSI = 100 - 100/(1+avgGain/avgLoss)
(avgLoss = 0 -> RSI = 100). State machine, starts at 0:
stocks: go to 1 when RSI < 30; go to 0 when RSI > 70; else keep state.
FX: go to +1 when RSI < 30; go to -1 when RSI > 70; else keep state.
6. BOLL Bollinger(20, 2) reversion: mid = SMA20, sd = population std (ddof=0) of last 20 P.
stocks: enter 1 when P < mid - 2 sd; exit to 0 when P >= mid; else keep.
FX: as stocks for longs; also enter -1 when P > mid + 2 sd; exit -1 to 0 when P <= mid.
(From flat, a long entry is tested before a short entry.)
7. DON Donchian 20/10 breakout (close-based Turtle rule): hi20 = max of P over the previous 20 days (excluding today),
lo10 = min over previous 10 days, lo20 / hi10 likewise.
stocks: enter 1 when P > hi20; exit to 0 when P < lo10; else keep.
FX: also enter -1 when P < lo20; exit -1 to 0 when P > hi10. From flat, long tested first.
8. MOM12 12-month time-series momentum: on the last trading day of each calendar month,
s = 1 if P_t / P_{t-252} - 1 > 0 else 0 (FX: +1 / -1). Held until the next month-end decision.
Before the first month-end decision with 252 days of history: 0.
Undefined indicator values (warm-up) -> s = 0.
COSTS (per side = per unit of |change in position|)
- US: 5 bps per side (index-ETF spread + slippage). ETF fee 0.09% a year, charged as 0.0009/252 per day times |pos|.
- Europe: 10 bps per side. ETF fee 0.20% a year likewise.
- FX: 2 bps per side (retail spread + slippage, major pairs). Financing mark-up 1.00% a year, charged 0.01/252 per day times |pos|.
The interest-rate differential (carry) is NOT modelled. Taxes are NOT modelled.
- A move from -1 to +1 is 2 units of change.
Daily net return:
- stocks: ret_t = pos_t * R_t + (1 - pos_t) * RF_t - cost_side * |pos_t - pos_{t-1}| - fee_daily * pos_t
- FX: ret_t = pos_t * r_t - cost_side * |pos_t - pos_{t-1}| - fin_daily * |pos_t|
(FX results are unlevered excess returns: cash collateral interest is not added.)
At the start of the evaluation window the previous position is taken as 0 (so the first entry pays its cost).
Gross = the same with all costs and fees set to 0.
EVALUATION WINDOWS (fixed in advance)
- US headline: 1993-02-01 to 2026-09-30 (first US-listed index ETF launched 1993-01-29).
- US long history (robustness, costs are optimistic before 1993): 1927-01-03 to 2026-09-30.
- Europe: 1991-07-01 to 2026-09-30.
- FX: 2000-01-03 to 2026-10-30 (all three pairs).
- Split check: the evaluation window is cut at its calendar midpoint; Sharpe is reported for each half.
METRICS (on daily net returns inside the window; equity starts at 1 on the last date before the window)
- years = (last date - date before first window date in the file).days / 365.25
- CAGR = equity_end ** (1/years) - 1
- k = N_days / years (bars per year)
- Volatility = std(ret, ddof=1) * sqrt(k)
- Sharpe: stocks use x = ret - RF; FX uses x = ret. Sharpe = mean(x) / std(x, ddof=1) * sqrt(k)
- Max drawdown = min over t of (equity_t / running max equity - 1), running max includes the starting 1.
- Stocks: % of full calendar years in the window where the rule's compounded return > buy and hold's (strictly).
- FX: % of full calendar years with a positive compounded return.
- Switches per year = count of days with pos_t != pos_{t-1} (including the first entry) / years.
- Exposure = mean of |pos_t|.
"""Thuztra global rules study v1. Implements SPEC.txt exactly.
The page /reference/trading-rules-backtested (backend/src/trading_rules_study.ts)
renders out/page_data.json, copied to backend/src/trading_rules_study.json.
The raw files are not in this repo: the French library holds their copyright, so
only derived statistics are published. To reproduce, download into raw/ and check
the hashes in SHA256SUMS (taken 2026-11-08; the French library revises its files):
https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/F-F_Research_Data_Factors_daily_CSV.zip
https://mba.tuck.dartmouth.edu/pages/faculty/ken.french/ftp/Europe_3_Factors_Daily_CSV.zip
https://fred.stlouisfed.org/graph/fredgraph.csv?id=DEXUSEU (saved as DEXUSEU.csv)
https://fred.stlouisfed.org/graph/fredgraph.csv?id=DEXUSUK (saved as DEXUSUK.csv)
https://fred.stlouisfed.org/graph/fredgraph.csv?id=DEXJPUS (saved as DEXJPUS.csv)
Unzip both zips in raw/, then run: python3 study.py (needs pandas and numpy).
check.py is the independent second calculation of two rules (GC, RSI) on US and EUR/USD,
written from SPEC.txt alone; run.txt is study.py's printed output.
"""
import csv
import datetime as dt
import json
import math
import os
import sys
import numpy as np
import pandas as pd
HERE = os.path.dirname(os.path.abspath(__file__))
RAW = os.path.join(HERE, "raw")
OUT = os.path.join(HERE, "out")
RULES = ["BH", "SMA200", "GC", "MACD", "RSI", "BOLL", "DON", "MOM12"]
def load_french(name):
rows = []
with open(os.path.join(RAW, name)) as f:
for line in f:
parts = [p.strip() for p in line.split(",")]
if len(parts) == 5 and parts[0].isdigit() and len(parts[0]) == 8:
mkt_rf, rf = float(parts[1]), float(parts[4])
if mkt_rf <= -99.98 or rf <= -99.98:
continue
rows.append((pd.Timestamp(parts[0]), (mkt_rf + rf) / 100.0, rf / 100.0))
df = pd.DataFrame(rows, columns=["date", "R", "RF"]).set_index("date").sort_index()
df["P"] = (1.0 + df["R"]).cumprod()
return df
def load_fred(name):
df = pd.read_csv(os.path.join(RAW, name))
col = df.columns[1]
df = df[pd.to_numeric(df[col], errors="coerce").notna()].copy()
df["date"] = pd.to_datetime(df["observation_date"])
df["P"] = df[col].astype(float)
df = df.set_index("date")[["P"]].sort_index()
df["R"] = df["P"].pct_change()
return df
def ema(s, n):
return s.ewm(alpha=2.0 / (n + 1), adjust=False).mean()
def rsi(p, n=14):
d = p.diff()
gain = d.clip(lower=0)
loss = (-d).clip(lower=0)
ag = gain.iloc[1:].ewm(alpha=1.0 / n, adjust=False).mean()
al = loss.iloc[1:].ewm(alpha=1.0 / n, adjust=False).mean()
out = pd.Series(np.nan, index=p.index)
with np.errstate(divide="ignore", invalid="ignore"):
r = np.where(al.values == 0, 100.0, 100.0 - 100.0 / (1.0 + ag.values / al.values))
out.iloc[1:] = r
return out
def signals(p, fx):
"""Return dict rule -> signal series s_t (decided at close t)."""
neg = -1.0 if fx else 0.0
idx = p.index
sig = {}
sig["BH"] = pd.Series(1.0, index=idx)
sma200 = p.rolling(200).mean()
sma50 = p.rolling(50).mean()
sig["SMA200"] = pd.Series(np.where(sma200.isna(), 0.0, np.where(p > sma200, 1.0, neg)), index=idx)
sig["GC"] = pd.Series(np.where(sma200.isna(), 0.0, np.where(sma50 > sma200, 1.0, neg)), index=idx)
macd = ema(p, 12) - ema(p, 26)
sigl = ema(macd, 9)
sig["MACD"] = pd.Series(np.where(macd > sigl, 1.0, neg), index=idx)
rs = rsi(p).values
st, cur = np.zeros(len(p)), 0.0
for i, v in enumerate(rs):
if not np.isnan(v):
if v < 30:
cur = 1.0
elif v > 70:
cur = -1.0 if fx else 0.0
st[i] = cur
sig["RSI"] = pd.Series(st, index=idx)
mid = p.rolling(20).mean().values
sd = p.rolling(20).std(ddof=0).values
pv = p.values
st, cur = np.zeros(len(p)), 0.0
for i in range(len(p)):
if not np.isnan(mid[i]):
lo, hi = mid[i] - 2 * sd[i], mid[i] + 2 * sd[i]
if cur == 0.0:
if pv[i] < lo:
cur = 1.0
elif fx and pv[i] > hi:
cur = -1.0
elif cur == 1.0:
if pv[i] >= mid[i]:
cur = 0.0
elif cur == -1.0:
if pv[i] <= mid[i]:
cur = 0.0
st[i] = cur
sig["BOLL"] = pd.Series(st, index=idx)
hi20 = p.shift(1).rolling(20).max().values
lo20 = p.shift(1).rolling(20).min().values
hi10 = p.shift(1).rolling(10).max().values
lo10 = p.shift(1).rolling(10).min().values
st, cur = np.zeros(len(p)), 0.0
for i in range(len(p)):
if not np.isnan(hi20[i]):
if cur == 0.0:
if pv[i] > hi20[i]:
cur = 1.0
elif fx and pv[i] < lo20[i]:
cur = -1.0
elif cur == 1.0:
if pv[i] < lo10[i]:
cur = 0.0
elif cur == -1.0:
if pv[i] > hi10[i]:
cur = 0.0
st[i] = cur
sig["DON"] = pd.Series(st, index=idx)
ret12 = p / p.shift(252) - 1.0
month = idx.to_period("M")
is_me = np.r_[month[1:] != month[:-1], True]
st, cur = np.zeros(len(p)), 0.0
r12 = ret12.values
for i in range(len(p)):
if is_me[i] and not np.isnan(r12[i]):
cur = 1.0 if r12[i] > 0 else neg
st[i] = cur
sig["MOM12"] = pd.Series(st, index=idx)
return sig
def run_rule(df, s, start, end, fx, side, fee_yr, gross=False):
pos_all = s.shift(1).fillna(0.0)
m = (df.index >= start) & (df.index <= end)
sub = df.loc[m]
pos = pos_all.loc[m].values
prev = np.r_[0.0, pos[:-1]]
c = 0.0 if gross else side
f = 0.0 if gross else fee_yr / 252.0
if fx:
ret = pos * sub["R"].values - c * np.abs(pos - prev) - f * np.abs(pos)
x = ret
else:
ret = pos * sub["R"].values + (1 - pos) * sub["RF"].values - c * np.abs(pos - prev) - f * pos
x = ret - sub["RF"].values
d0 = df.index[df.index < sub.index[0]][-1]
return pd.Series(ret, index=sub.index), x, pos, prev, d0
def metrics(ret, x, pos, prev, d0):
eq = (1 + ret).cumprod()
years = (ret.index[-1] - d0).days / 365.25
k = len(ret) / years
peak = np.maximum.accumulate(np.r_[1.0, eq.values])[1:]
return {
"cagr": float(eq.iloc[-1] ** (1 / years) - 1),
"vol": float(np.std(ret.values, ddof=1) * math.sqrt(k)),
"sharpe": float(np.mean(x) / np.std(x, ddof=1) * math.sqrt(k)),
"maxdd": float(np.min(eq.values / peak - 1)),
"switches_per_year": float(np.sum(pos != prev) / years),
"exposure": float(np.mean(np.abs(pos))),
"final_equity": float(eq.iloc[-1]),
"years": years,
"days": int(len(ret)),
}
def yearly(ret):
y = (1 + ret).groupby(ret.index.year).prod() - 1
first, last = ret.index[0], ret.index[-1]
full = [yr for yr in y.index if not (yr == first.year and first > pd.Timestamp(yr, 1, 1) + pd.Timedelta(days=7))
and not (yr == last.year and last < pd.Timestamp(yr, 12, 24))]
return y.loc[full]
def study(name, df, start, end, fx, side, fee_yr):
start, end = pd.Timestamp(start), pd.Timestamp(end)
end = min(end, df.index[-1])
sig = signals(df["P"], fx)
mid = start + (end - start) / 2
out = {}
bh_y = None
for rule in RULES:
ret, x, pos, prev, d0 = run_rule(df, sig[rule], start, end, fx, side, fee_yr)
g = run_rule(df, sig[rule], start, end, fx, side, fee_yr, gross=True)
mt = metrics(ret, x, pos, prev, d0)
mt["gross_cagr"] = metrics(*g)["cagr"]
halves = []
for a, b in ((start, mid), (mid + pd.Timedelta(days=1), end)):
r2, x2, p2, pv2, d2 = run_rule(df, sig[rule], a, b, fx, side, fee_yr)
halves.append(metrics(r2, x2, p2, pv2, d2)["sharpe"])
mt["sharpe_first_half"], mt["sharpe_second_half"] = halves
y = yearly(ret)
if rule == "BH":
bh_y = y
if fx:
mt["pct_years_positive"] = float((y > 0).mean())
else:
mt["pct_years_beat_bh"] = float((y > bh_y).mean()) if rule != "BH" else None
mt["full_years"] = int(len(y))
mt["yearly"] = {str(k): float(v) for k, v in y.items()}
out[rule] = mt
return {
"market": name,
"start": str(ret.index[0].date()),
"end": str(ret.index[-1].date()),
"split_date": str(mid.date()),
"fx": fx,
"cost_side_bps": side * 1e4,
"annual_fee_pct": fee_yr * 100,
"rules": out,
}
def main():
us = load_french("F-F_Research_Data_Factors_daily.csv")
eu = load_french("Europe_3_Factors_Daily.csv")
fx = {k: load_fred(f) for k, f in (("EURUSD", "DEXUSEU.csv"), ("GBPUSD", "DEXUSUK.csv"), ("USDJPY", "DEXJPUS.csv"))}
res = {
"generated_by": "study.py",
"markets": {
"US": study("US stock market", us, "1993-02-01", "2026-09-30", False, 0.0005, 0.0009),
"US_LONG": study("US stock market, long history", us, "1927-01-03", "2026-09-30", False, 0.0005, 0.0009),
"EUROPE": study("European stock market", eu, "1991-07-01", "2026-09-30", False, 0.0010, 0.0020),
},
}
for k, d in fx.items():
res["markets"][k] = study(k, d, "2000-01-03", "2026-10-30", True, 0.0002, 0.01)
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "results.json"), "w") as f:
json.dump(res, f, indent=1, sort_keys=True)
page = {"markets": {mk: {**{k: v for k, v in m.items() if k != "rules"},
"rules": {r: {k: v for k, v in d.items() if k != "yearly"} for r, d in m["rules"].items()}}
for mk, m in res["markets"].items()}}
with open(os.path.join(OUT, "page_data.json"), "w") as f:
json.dump(page, f, indent=1, sort_keys=True)
f.write("\n")
with open(os.path.join(OUT, "results.csv"), "w", newline="") as f:
w = csv.writer(f)
w.writerow(["market", "rule", "start", "end", "cagr_net", "cagr_gross", "sharpe", "volatility", "max_drawdown",
"pct_years_beat_buy_and_hold", "pct_years_positive", "switches_per_year", "exposure",
"sharpe_first_half", "sharpe_second_half", "cost_per_side_bps", "annual_fee_or_financing_pct"])
for mk, m in res["markets"].items():
for rule, r in m["rules"].items():
w.writerow([mk, rule, m["start"], m["end"], f"{r['cagr']:.4f}", f"{r['gross_cagr']:.4f}",
f"{r['sharpe']:.3f}", f"{r['vol']:.4f}", f"{r['maxdd']:.4f}",
"" if r.get("pct_years_beat_bh") is None else f"{r['pct_years_beat_bh']:.3f}",
"" if r.get("pct_years_positive") is None else f"{r['pct_years_positive']:.3f}",
f"{r['switches_per_year']:.2f}", f"{r['exposure']:.3f}",
f"{r['sharpe_first_half']:.3f}", f"{r['sharpe_second_half']:.3f}",
f"{m['cost_side_bps']:.0f}", f"{m['annual_fee_pct']:.2f}"])
for mk, m in res["markets"].items():
print(f"== {mk} {m['start']}..{m['end']} split {m['split_date']}")
for rule, r in m["rules"].items():
extra = r.get("pct_years_beat_bh") if not m["fx"] else r.get("pct_years_positive")
print(f" {rule:7s} cagr {r['cagr']*100:6.2f}% gross {r['gross_cagr']*100:6.2f}% sharpe {r['sharpe']:5.2f} "
f"mdd {r['maxdd']*100:6.1f}% yrs {('' if extra is None else f'{extra*100:4.0f}%'):>5s} "
f"sw/yr {r['switches_per_year']:5.1f} exp {r['exposure']:.2f} halves {r['sharpe_first_half']:.2f}/{r['sharpe_second_half']:.2f}")
if __name__ == "__main__":
sys.exit(main())
53598ec5af305d321ee93db492a32076fc882f976e67d27eba724d0266d20ad2 Europe_3_Factors_Daily_CSV.zip
96c23b88e651cbfff3740a53f8225e59ee39893371e96385a1d8125ec2cd9c89 F-F_Research_Data_Factors_daily_CSV.zip
60ba955456192c4a1cbf6a1f4dee25fb040b2120bdcea0050a4e55b04cf8e997 DEXJPUS.csv
3d95f828a707b568522fc90b3efb08a82169777536b3f9a131ad2df065f3a791 DEXUSEU.csv
75c6228bdb54f33f37dec3059b688f4e43dd6c5e88ce5905e69ed2a3b02567c5 DEXUSUK.csv
9d76d27f486206667a6bcdf18dde487b11505ba637e8159f23810ecdf3e265b7 Europe_3_Factors_Daily.csv
3f7910502dd6f188748210e5145d95642de1d8cff56c2cf0fa85985299610ff0 F-F_Research_Data_Factors_daily.csv
Every step here, from the no-look-ahead rule to the cost model and the split-half check, is a skill you can learn. Start with what a backtest is, in plain words, then why most backtests overfit.
Thuztra Learn teaches the maths of quant finance from zero, in short lessons, for readers who are unsure of it. Three questions in the app build your quant roadmap, free. Membership is a paid subscription. See what Thuztra Learn is and what it costs, or see the whole Core quant curriculum.
Get the app and answer the three questions:
Download on the Microsoft Store Download for Android