Warrior_EA/research/portfolio.py

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)