LibraryReference articles

We backtested 7 popular trading rules on US stocks, Europe and forex, after costs

A Thuztra Learn data study. Published 8 November 2026. Stock data to 30 September 2026; currency data to 30 October 2026.

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.

Education only, not advice. These are historical simulations. Past results do not predict future results, and nothing here is a recommendation to trade anything.

The results

US stocks, 1993–2026

Hold throughout 200-day average 50/200 crossover MACD RSI 30/70 Bollinger Donchian 12-month momentum Yearly return after costs 10.8% 8.6% 10.4% 4.4% 6.8% 5.1% 5.4% 10.5% Worst fall −54.6% −30.5% −34.2% −34.4% −50.8% −30.3% −29.0% −34.2% Hold throughout 200-day average 50/200 crossover MACD RSI 30/70 Bollinger Donchian 12-month momentum Yearly return after costs 10.8% 8.6% 10.4% 4.4% 6.8% 5.1% 5.4% 10.5% Hold throughout 200-day average 50/200 crossover MACD RSI 30/70 Bollinger Donchian 12-month momentum Worst fall −54.6% −30.5% −34.2% −34.4% −50.8% −30.3% −29.0% −34.2%
Figure 1. US stock market, 1 February 1993 to 30 September 2026, after costs. Yearly return is the compound annual growth rate. Worst fall is the largest peak-to-trough drop of the account. Hold throughout is in black.

US stock market, 1 February 1993 to 30 September 2026. Costs: 5 basis points a side, plus 0.09% a year fund fee.

RuleYearly return, after costsBefore costsSharpeWorst fallYears ahead of holdingSwitches a yearTime in market
Hold throughout10.8%10.9%0.52−54.6%n/a0.0100%
200-day average8.6%9.0%0.55−30.5%16%6.377%
50/200 crossover10.4%10.6%0.62−34.2%19%0.977%
MACD4.4%5.6%0.22−34.4%22%21.551%
RSI 30/706.8%6.9%0.35−50.8%34%1.834%
Bollinger5.1%5.6%0.27−30.3%28%8.619%
Donchian5.4%6.1%0.35−29.0%19%12.251%
12-month momentum10.5%10.6%0.60−34.2%13%0.681%

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 stocks, 1991–2026

Hold throughout 200-day average 50/200 crossover MACD RSI 30/70 Bollinger Donchian 12-month momentum Yearly return after costs 8.1% 7.7% 7.0% 2.2% 2.8% 2.2% 4.2% 5.9% Worst fall −62.2% −24.8% −36.9% −48.7% −60.0% −44.3% −31.8% −45.8% Hold throughout 200-day average 50/200 crossover MACD RSI 30/70 Bollinger Donchian 12-month momentum Yearly return after costs 8.1% 7.7% 7.0% 2.2% 2.8% 2.2% 4.2% 5.9% Hold throughout 200-day average 50/200 crossover MACD RSI 30/70 Bollinger Donchian 12-month momentum Worst fall −62.2% −24.8% −36.9% −48.7% −60.0% −44.3% −31.8% −45.8%
Figure 2. European stock market, 1 July 1991 to 30 September 2026, after costs. Yearly return is the compound annual growth rate. Worst fall is the largest peak-to-trough drop of the account. Hold throughout is in black.

European stock market, 1 July 1991 to 30 September 2026. Costs: 10 basis points a side, plus 0.20% a year fund fee.

RuleYearly return, after costsBefore costsSharpeWorst fallYears ahead of holdingSwitches a yearTime in market
Hold throughout8.1%8.3%0.38−62.2%n/a0.0100%
200-day average7.7%8.6%0.47−24.8%32%7.271%
50/200 crossover7.0%7.3%0.40−36.9%29%1.371%
MACD2.2%4.5%0.03−48.7%26%21.451%
RSI 30/702.8%3.0%0.09−60.0%32%1.537%
Bollinger2.2%3.2%0.03−44.3%32%9.022%
Donchian4.2%5.6%0.21−31.8%29%12.848%
12-month momentum5.9%6.2%0.31−45.8%15%1.271%

The European series covers the developed European markets, the UK included, measured in US dollars with dividends.

Forex: EUR/USD, GBP/USD and USD/JPY, 2000–2026

RuleEUR/USDGBP/USDUSD/JPY
Hold throughout−0.6%−1.7%0.6%
200-day average0.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%
Figure 3. Yearly return after costs on each currency pair, 3 January 2000 to 30 October 2026, unlevered. Red is a loss. Interest-rate carry is not included (see the method).

EUR/USD, 3 January 2000 to 30 October 2026. Costs: 2 basis points a side, plus 1.00% a year financing mark-up.

RuleYearly return, after costsBefore costsSharpeWorst fallYears with a gainSwitches a yearTime in market
Hold throughout−0.6%0.4%−0.02−48.0%46%0.0100%
200-day average0.5%1.8%0.10−30.3%58%6.7100%
50/200 crossover−0.6%0.4%−0.02−38.6%38%1.5100%
MACD−2.1%−0.3%−0.19−59.5%42%21.2100%
RSI 30/70−3.6%−2.6%−0.36−74.2%19%1.6100%
Bollinger−0.8%0.1%−0.08−39.8%50%18.655%
Donchian−0.3%0.9%0.00−44.4%38%21.171%
12-month momentum−2.8%−1.7%−0.26−65.7%35%1.8100%

GBP/USD, 3 January 2000 to 30 October 2026. Costs: 2 basis points a side, plus 1.00% a year financing mark-up.

RuleYearly return, after costsBefore costsSharpeWorst fallYears with a gainSwitches a yearTime in market
Hold throughout−1.7%−0.7%−0.14−56.3%50%0.0100%
200-day average−1.1%0.2%−0.07−52.2%46%8.0100%
50/200 crossover−2.1%−1.1%−0.19−54.3%42%1.7100%
MACD−1.6%0.2%−0.13−49.2%42%19.1100%
RSI 30/70−2.6%−1.6%−0.24−67.9%35%1.6100%
Bollinger−0.1%0.8%0.02−29.0%50%19.053%
Donchian−2.9%−1.7%−0.33−58.6%35%22.370%
12-month momentum−1.5%−0.5%−0.12−61.8%38%1.5100%

USD/JPY, 3 January 2000 to 30 October 2026. Costs: 2 basis points a side, plus 1.00% a year financing mark-up.

RuleYearly return, after costsBefore costsSharpeWorst fallYears with a gainSwitches a yearTime in market
Hold throughout0.6%1.6%0.11−49.0%42%0.0100%
200-day average−1.9%−0.5%−0.15−44.2%42%9.7100%
50/200 crossover−1.7%−0.7%−0.13−47.0%38%1.5100%
MACD−2.5%−0.7%−0.21−59.3%42%20.4100%
RSI 30/70−1.7%−0.7%−0.13−58.6%50%1.7100%
Bollinger−0.6%0.3%−0.04−37.3%38%18.154%
Donchian−2.6%−1.4%−0.27−57.3%38%21.772%
12-month momentum2.3%3.3%0.28−21.7%58%1.2100%

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.

Each rule, what it does and how it did

Hold throughout (buy and hold)

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.

200-day moving average filter

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.

50/200-day moving average crossover (golden cross)

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.

MACD 12/26/9 crossover

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.

RSI(14) 30/70

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.

Bollinger Bands (20, 2) reversion

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.

Donchian 20/10 breakout (Turtle-style)

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.

12-month time-series momentum

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.

Does it hold up? Two checks

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.

RuleUS 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 throughout0.30 / 0.760.41 / 0.510.26 / 0.500.14 / −0.24−0.10 / −0.18−0.05 / 0.30
200-day average0.43 / 0.670.83 / 0.570.59 / 0.370.14 / 0.050.11 / −0.27−0.20 / −0.09
50/200 crossover0.62 / 0.620.46 / 0.530.57 / 0.25−0.01 / −0.04−0.14 / −0.24−0.09 / −0.17
MACD−0.03 / 0.510.83 / 0.36−0.19 / 0.240.02 / −0.47−0.07 / −0.18−0.16 / −0.27
RSI 30/700.22 / 0.53−0.02 / 0.27−0.08 / 0.26−0.29 / −0.48−0.33 / −0.140.00 / −0.28
Bollinger0.22 / 0.32−0.21 / 0.20−0.05 / 0.11−0.30 / 0.23−0.19 / 0.250.03 / −0.12
Donchian0.07 / 0.660.95 / 0.360.27 / 0.160.32 / −0.44−0.21 / −0.47−0.32 / −0.21
12-month momentum0.57 / 0.630.59 / 0.540.57 / 0.09−0.24 / −0.31−0.06 / −0.190.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.

RuleYearly return, after costsBefore costsSharpeWorst fallYears ahead of holdingSwitches a yearTime in market
Hold throughout10.1%10.2%0.46−84.1%n/a0.0100%
200-day average11.1%11.5%0.70−33.8%28%5.672%
50/200 crossover9.1%9.2%0.50−61.7%20%1.172%
MACD9.5%10.6%0.60−37.2%40%19.951%
RSI 30/703.9%4.1%0.12−79.4%33%2.336%
Bollinger2.3%2.8%−0.01−75.0%23%8.421%
Donchian9.1%9.9%0.66−31.7%36%12.450%
12-month momentum10.0%10.1%0.56−47.1%16%0.874%

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.

Method

Limits: what this study does not show

Data, code and download

The code

The written specification, fixed before any result (SPEC.txt)
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|.
The study script (study.py, Python with pandas and numpy)
"""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())
SHA-256 hashes of the raw files used
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

Learn to run a test like this

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.

Fine print: the French Data Library and the Federal Reserve are named only as sources of public data; they have no link to Thuztra. Education only, not advice. These are historical simulations. Past results do not predict future results, and nothing here is a recommendation to trade anything.

Learn quant finance on Thuztra Learn

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

On Android, the app is a direct download, not a Google Play listing. Which devices, and what each one needs.

Also in the library