| """
|
| THE OUTSTANDING VALIDATION: does the vol-gated dip-z survive a bear market?
|
|
|
| The H4 result (STRATEGY.md) is built on 2021-2026, which contains no 2008 and no
|
| 2020. This family's losses are concentrated in bear ONSETS, so a sample without
|
| one proves very little about the drawdown that matters.
|
|
|
| Broker intraday history does not reach back far enough, so the bear test runs on
|
| D1, where the indices go back to 2008. The D1 rule is the same shape as the H4
|
| one; it is a proxy, and the point is not to re-measure the edge but to see what
|
| the drawdown does when the market actually falls.
|
|
|
| Reported per calendar year, because an average over 18 years hides exactly the
|
| thing being looked for.
|
| """
|
|
|
| from __future__ import annotations
|
|
|
| import sys
|
| import numpy as np
|
|
|
| sys.path.insert(0, __file__.rsplit("\\", 1)[0] if "\\" in __file__ else ".")
|
|
|
| import backtest as bt # noqa: E402
|
| from run_screen import zscore_entries # noqa: E402
|
| from vol_filter_test import vol_pctile # noqa: E402
|
|
|
| STOP_ATR = 3.0
|
| PROP_DD = 0.05
|
| IDX = ["SP500", "NAS100", "US30", "DAX40"]
|
|
|
|
|
| def trades_for(symbols, period, gated, max_bars=10):
|
| out = []
|
| for s in symbols:
|
| try:
|
| d = bt.load(s, period)
|
| except Exception: # noqa: BLE001
|
| continue
|
| e, xma = zscore_entries(d, 20, -1.5, 0)
|
| if gated:
|
| e = e & (np.nan_to_num(vol_pctile(d), nan=-1) >= 0.50)
|
| tr = bt.simulate(d, e, side=1, exit_ma=xma, max_bars=max_bars, stop_atr=STOP_ATR)
|
| tr = bt.add_r(tr, d, stop_atr=STOP_ATR)
|
| for t in tr:
|
| t["symbol"] = s
|
| t["exit_t"] = d["ts"][t["exit_i"]]
|
| out += tr
|
| out.sort(key=lambda t: t["exit_t"])
|
| return out
|
|
|
|
|
| def curve_stats(trades, risk):
|
| eq = [1.0]
|
| for t in trades:
|
| eq.append(eq[-1] * (1.0 + risk * t["r"]))
|
| eq = np.array(eq)
|
| peak = np.maximum.accumulate(eq)
|
| dd = (peak - eq) / peak
|
| return eq, dd
|
|
|
|
|
| def per_year(trades, risk):
|
| """Year-by-year return and the worst drawdown inside that year."""
|
| years = sorted({int(str(t["exit_t"])[:4]) for t in trades})
|
| rows = []
|
| for y in years:
|
| sub = [t for t in trades if int(str(t["exit_t"])[:4]) == y]
|
| if not sub:
|
| continue
|
| eq, dd = curve_stats(sub, risk)
|
| rows.append((y, len(sub), eq[-1] - 1.0, dd.max(),
|
| float(np.mean([t["ret"] for t in sub])) * 1e4))
|
| return rows
|
|
|
|
|
| if __name__ == "__main__":
|
| risk = 0.0025
|
| print("=== D1, 4 indices, dip-z (z<=-1.5, exit SMA20, 10 bars, stop 3ATR), "
|
| f"risk {risk:.2%} ===\n")
|
| for gated in (False, True):
|
| tag = "VOL-GATED (pct>=0.50)" if gated else "UNGATED"
|
| tr = trades_for(IDX, "PERIOD_D1", gated)
|
| if not tr:
|
| print(f"{tag}: no trades / no data")
|
| continue
|
| eq, dd = curve_stats(tr, risk)
|
| t0 = min(t["t"] for t in tr)
|
| t1 = max(t["exit_t"] for t in tr)
|
| yrs = (t1 - t0) / np.timedelta64(365, "D")
|
| total = eq[-1] - 1.0
|
| rdd = total / dd.max() if dd.max() > 1e-9 else np.nan
|
| print(f"--- {tag} --- {str(t0)[:10]} .. {str(t1)[:10]} "
|
| f"n={len(tr)} {len(tr)/max(yrs*12,1e-9):.1f}/mo "
|
| f"total {total:.1%} CAGR {eq[-1]**(1/max(yrs,1e-9))-1:.1%} "
|
| f"maxDD {dd.max():.1%} ret/DD {rdd:.2f}")
|
| print(f"{'year':>6}{'n':>5}{'return':>9}{'maxDD':>8}{'bp/trade':>10}")
|
| for y, n, r, d_, bp in per_year(tr, risk):
|
| mark = " <-- bear" if y in (2008, 2011, 2015, 2018, 2020, 2022) else ""
|
| print(f"{y:>6}{n:>5}{r:>9.2%}{d_:>8.2%}{bp:>10.1f}{mark}")
|
| print()
|