forked from animatedread/Warrior_EA
135 lines
5.1 KiB
Python
135 lines
5.1 KiB
Python
"""
| |||
Portfolio test: the recovered dip-z config across the instruments that showed
| |||
an edge over the long-only control, on ONE equity curve.
| |||
| |||
WHY A PORTFOLIO IS THE ANSWER TO THE PROP LIMIT
| |||
-----------------------------------------------
| |||
Per symbol at 1% risk the strategy clears cadence and beats its control but
| |||
breaks the 5% drawdown limit. Drawdown scales ~linearly with risk per trade
| |||
while ret/DD does not, so the fix is sizing, not signal. Spreading the same
| |||
risk budget across several instruments then buys back some return, but ONLY to
| |||
the extent the drawdowns are not simultaneous -- and four equity indices are
| |||
highly correlated, so that benefit must be measured, never assumed.
| |||
| |||
HONESTY NOTE ON THE EQUITY CURVE: each trade's R is applied at its EXIT, in
| |||
chronological order. With concurrent positions this understates the true
| |||
intra-trade drawdown, because two open losers are not marked to market
| |||
together. The reported maxDD is therefore a FLOOR, not a ceiling -- treat the
| |||
5% test as necessary, not sufficient.
| |||
"""
| |||
| |||
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
| |||
| |||
STOP_ATR = 3.0
| |||
PROP_DD = 0.05
| |||
| |||
| |||
def collect(symbols, period, lo=None, hi=None, max_bars=10):
| |||
"""All trades across symbols, chronological, with R attached."""
| |||
out = []
| |||
for s in symbols:
| |||
d = bt.load(s, period)
| |||
e, xma = zscore_entries(d, 20, -1.5, 0)
| |||
ts = d["ts"]
| |||
m = np.ones(len(ts), bool)
| |||
if lo is not None:
| |||
m &= ts >= np.datetime64(lo)
| |||
if hi is not None:
| |||
m &= ts < np.datetime64(hi)
| |||
tr = bt.simulate(d, e & m, 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(trades, risk, max_concurrent=None):
| |||
"""Equity curve; optionally refuse entries beyond `max_concurrent` open."""
| |||
if max_concurrent is not None:
| |||
kept, open_until = [], []
| |||
for t in sorted(trades, key=lambda x: x["t"]):
| |||
open_until = [u for u in open_until if u > t["t"]]
| |||
if len(open_until) >= max_concurrent:
| |||
continue
| |||
open_until.append(t["exit_t"])
| |||
kept.append(t)
| |||
trades = sorted(kept, key=lambda x: x["exit_t"])
| |||
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 trades, eq, dd.max()
| |||
| |||
| |||
def report(tag, trades, risk, max_concurrent=None):
| |||
if not trades:
| |||
print(f"{tag:<26} no trades")
| |||
return None
| |||
kept, eq, maxdd = curve(trades, risk, max_concurrent)
| |||
t0 = min(t["t"] for t in kept)
| |||
t1 = max(t["exit_t"] for t in kept)
| |||
years = (t1 - t0) / np.timedelta64(365, "D")
| |||
total = eq[-1] - 1.0
| |||
cagr = eq[-1] ** (1 / max(years, 1e-9)) - 1.0
| |||
permo = len(kept) / max(years * 12, 1e-9)
| |||
rets = np.array([t["ret"] for t in kept])
| |||
rmult = np.array([t["r"] for t in kept])
| |||
ret_dd = total / maxdd if maxdd > 1e-9 else np.nan
| |||
flags = []
| |||
if permo < 2.0:
| |||
flags.append("cadence")
| |||
if maxdd > PROP_DD:
| |||
flags.append("maxDD")
| |||
if not (ret_dd >= 2.0):
| |||
flags.append("ret/DD")
| |||
print(f"{tag:<26}{len(kept):>6}{permo:>7.1f}{rets.mean()*1e4:>9.1f}"
| |||
f"{rmult.mean():>8.3f}{total:>9.1%}{cagr:>8.1%}{maxdd:>8.1%}{ret_dd:>8.2f}"
| |||
f" {'PASS' if not flags else 'fail:' + ','.join(flags)}")
| |||
return dict(n=len(kept), permo=permo, total=total, cagr=cagr, maxdd=maxdd, ret_dd=ret_dd)
| |||
| |||
| |||
HDR = (f"{'portfolio':<26}{'n':>6}{'/mo':>7}{'exp_bp':>9}{'meanR':>8}"
| |||
f"{'total':>9}{'CAGR':>8}{'maxDD':>8}{'ret/DD':>8} screen")
| |||
| |||
IDX = ["SP500", "NAS100", "US30", "DAX40"]
| |||
ALL7 = IDX + ["EURUSD", "USDJPY", "XAUUSD"]
| |||
SPLIT = "2024-01-01"
| |||
| |||
if __name__ == "__main__":
| |||
period = "PERIOD_H4"
| |||
print("=== 4-INDEX PORTFOLIO, recovered dip-z config, H4, long only ===")
| |||
print("(equity applies each trade's R at exit; concurrent DD is understated)\n")
| |||
| |||
full = collect(IDX, period)
| |||
print(HDR)
| |||
for risk in (0.010, 0.0075, 0.005, 0.0035, 0.0025):
| |||
report(f"4 idx, risk {risk:.2%}", full, risk)
| |||
print()
| |||
print("--- risk 0.5%, capped concurrency (correlated indices draw down together) ---")
| |||
print(HDR)
| |||
for mc in (4, 3, 2, 1):
| |||
report(f"4 idx, 0.50%, max {mc} open", full, 0.005, max_concurrent=mc)
| |||
print()
| |||
print("--- IS / OOS at the sizing that passes ---")
| |||
print(HDR)
| |||
report("4 idx IS (..2024)", collect(IDX, period, None, SPLIT), 0.005)
| |||
report("4 idx OOS (2024..)", collect(IDX, period, SPLIT, None), 0.005)
| |||
print()
| |||
print("--- all 7 symbols (incl. the three with no measured edge) ---")
| |||
print(HDR)
| |||
report("7 sym, risk 0.50%", collect(ALL7, period), 0.005)
| |||
report("7 sym IS (..2024)", collect(ALL7, period, None, SPLIT), 0.005)
| |||
report("7 sym OOS (2024..)", collect(ALL7, period, SPLIT, None), 0.005)
|