Warrior_EA/research/test_classic.py

161 行
6.8 KiB
Python

research: test the shipped classic patterns for entry edge - and the lookahead that faked one Transcribes all 26 classic vote models (MA 4, RSI 4, MACD 6, Ichimoku 12) from Signals/*.mqh into vectorised Python, with their shipped constructor weights, then tests them as entry triggers on 178k-bar FX histories. Pre-registered by construction: the rules were written long before this test and nothing about them is fitted here, so there is no in-sample/out-of-sample split to draw and the whole history is usable. Break-even == chance by the gambler's-ruin identity, so "beats a coin" and "makes money" are one question. Sequential non-overlapping trades only; a sign-flip null over the whole pattern family gives the family-wise bar. The result that matters is a negative one, and it took two lookahead fixes to see: - _price_extremum reproduced the standard library's CENTRED MinValue(pos-2,5) window, which reads up to 2 bars newer than the extremum it describes. - turning_points marked a turn AT bar i, which is only knowable once bar i+1 closes. Together those two bars of leakage WERE the entire apparent edge. MACD_p4 on EURUSD 1:2 read +5.05pp at +4.05 sigma before, -0.02pp at -0.02 sigma after; USDJPY 1:2 went +5.24pp -> +0.02pp. RSI_p2's large NEGATIVE went the same way (-10.33pp -> -1.34pp), which is the tell: a leak inflates whatever sign it lands on. With both closed, across 4 instruments x 3 geometries: no pattern, no vote threshold, no quorum and no event+confirmation rule separates from chance. One cell in ~180 tests stars (SP500 2:6 vote>=30) and it is non-monotone in the threshold either side of the hit. test_exits.py answers the trade-management half with the control that makes it mean something: hold entries fixed, vary only the exit, and run every rule again on RANDOM entries at the same bars. Breakeven-at-1R, chandelier trails, partials and time stops all move E[R] - and move it by the same amount on random entries. No rule beats its own control (max +0.99 sigma over 32 comparisons). Management reshapes the win-rate/payoff split; it does not manufacture expectancy from a directionless entry. Residual E[R] across every cell is -0.01 to -0.08 R, which is approximately the spread. Incidental, both worth fixing in the EA: - CSignalMA pattern 1 is unsatisfiable at the shipped EMA default. For an EMA, MA[i]-MA[i-1] and c[i]-MA[i] are both positive multiples of (c[i]-MA[i-1]), so "close below the MA while the MA rises" cannot occur. Dead code (weight 10). - Ichimoku pattern 11 (Sanyaku, weight 100, the method's top signal) fires on 27% of bars because it is a conjunction of three standing STATES with no event term, so it dominates the averaged vote while carrying no trigger information. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 16:58:57 -04:00
"""Do the shipped classic patterns carry a directional edge?
Pre-registration note: the 26 conditions are NOT invented here. They are the models the
EA already ships in Signals/, written long before this test existed, with their shipped
constructor weights. Nothing about them is fitted to this data, so there is no in-sample
/ out-of-sample distinction to draw - every trade is an honest out-of-sample trade and
the whole history can be used. That is the one real advantage of testing a rule instead
of a model.
The null: by the gambler's-ruin identity, the probability of touching +k*ATR before
-m*ATR on a driftless random walk is m/(m+k) - which is exactly the break-even win rate
for a k:m payoff. So chance == break-even at every geometry, and "is this pattern better
than a coin" and "does this pattern make money" are the same question. Spread is charged
inside the barrier, which pushes the honest bar slightly above break-even.
Multiple comparisons: 15 actionable patterns x geometries. Controlled with a sign-flip
null (keep each pattern's firing TIMES, randomise its DIRECTION) and the distribution of
the MAX |z| over the whole family - the family-wise bar, not the per-test one.
"""
import numpy as np, sys, time
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from classic import build_patterns, module_votes, combined_direction, MODULE_SLICES
from kit import load_rates, atr
TF = {5: 'M5', 15: 'M15', 16385: 'H1', 16388: 'H4', 16408: 'D1'}
def barrier_outcomes(o, h, l, a, sl, tp, H, spread):
"""For an entry at the OPEN of bar i, in both directions: did TP land before SL?
Returns winL, resL, winS, resS (res = resolved, i.e. not a timeout).
A bar that spans both barriers scores as the LOSS (strict tp < sl), as in kit.py.
"""
n = len(o)
INF = np.iinfo(np.int32).max
winL = np.zeros(n, bool); resL = np.zeros(n, bool)
winS = np.zeros(n, bool); resS = np.zeros(n, bool)
risk, rew = sl * a, tp * a
lTp, lSl = o + rew + spread, o - risk + spread
sTp, sSl = o - rew - spread, o + risk - spread
CH = max(200000 // max(H, 1), 1)
for s in range(0, n, CH):
e2 = min(s + CH, n - H)
if e2 <= s:
break
wi = np.arange(0, H)[None, :] + np.arange(s, e2)[:, None]
wh, wl = h[wi], l[wi]
def first(mask):
any_ = mask.any(axis=1)
return np.where(any_, mask.argmax(axis=1), INF)
lsl = first(wl <= lSl[s:e2, None]); ltp = first(wh >= lTp[s:e2, None])
ssl = first(wh >= sSl[s:e2, None]); stp = first(wl <= sTp[s:e2, None])
winL[s:e2] = ltp < lsl; resL[s:e2] = np.minimum(ltp, lsl) < INF
winS[s:e2] = stp < ssl; resS[s:e2] = np.minimum(stp, ssl) < INF
return winL, resL, winS, resS
def sequential(fire_bars, dirs, winL, winS, H, n):
"""Sequential NON-OVERLAPPING trades: while a position is open, later signals are
ignored. This is the only simulation whose confidence interval means anything,
because it is the only one where the trades are independent."""
out_i, out_d, out_w = [], [], []
busy_until = -1
for j, d in zip(fire_bars, dirs):
if j <= busy_until or j + 1 + H >= n:
continue
e = j + 1 # enter at the OPEN of the next bar
w = winL[e] if d > 0 else winS[e]
out_i.append(e); out_d.append(d); out_w.append(bool(w))
busy_until = e + H
return np.array(out_i, int), np.array(out_d, int), np.array(out_w, bool)
_CACHE = {}
def prepare(sym, tf):
"""Load + build patterns once per symbol; the divergence bit-map walk is the slow part."""
key = (sym, tf)
if key not in _CACHE:
t, o, h, l, c, v, spr = load_rates(sym, tf)
a = atr(h, l, c, 14)
tick = np.nanmin(np.abs(np.diff(np.unique(np.round(c, 8)))))
sp = np.nanmedian(spr) * tick
if not np.isfinite(sp):
sp = 0.0
fireL, fireS, names, weights = build_patterns(o, h, l, c)
_CACHE[key] = (o, h, l, c, a, sp, fireL, fireS, names, weights)
return _CACHE[key]
def run(sym, tf, sl_m, tp_m, H, nperm=2000, seed=0, quiet=False):
o, h, l, c, a, sp, fireL, fireS, names, weights = prepare(sym, tf)
n = len(c)
# ATR known at the signal bar; entry one bar later, so shift the ATR forward by one
a_sig = np.concatenate([[a[0]], a[:-1]])
winL, resL, winS, resS = barrier_outcomes(o, h, l, a_sig, sl_m, tp_m, H, sp)
be = sl_m / (sl_m + tp_m) # break-even == chance
rows = []
rng = np.random.default_rng(seed)
perm_max = np.zeros(nperm)
actionable = [k for k in range(len(names)) if weights[k] > 10]
per_pattern_perm = {}
for k in actionable:
fb = np.nonzero(fireL[:, k] | fireS[:, k])[0]
# a bar where both sides fire is a genuine flat vote in the EA - skip it
both = fireL[fb, k] & fireS[fb, k]
fb = fb[~both]
if len(fb) == 0:
continue
d = np.where(fireL[fb, k], 1, -1)
ti, td, tw = sequential(fb, d, winL, winS, H, n)
nT = len(ti)
if nT < 30:
continue
wr = tw.mean()
se = np.sqrt(be * (1 - be) / nT)
z = (wr - be) / se
exp_R = wr * tp_m - (1 - wr) * sl_m
rows.append((names[k], int(weights[k]), len(fb), nT, 100 * wr, 100 * (wr - be), z, exp_R))
# sign-flip null for this pattern: same firing bars, randomised direction
wl_at, ws_at = winL[ti], winS[ti]
fl = rng.random((nperm, nT)) < 0.5
pw = np.where(fl, wl_at[None, :], ws_at[None, :]).mean(axis=1)
pz = (pw - be) / se
per_pattern_perm[names[k]] = pz
perm_max = np.maximum(perm_max, np.abs(pz))
if not rows:
return None
rows.sort(key=lambda r: -r[6])
crit = np.quantile(perm_max, 0.95)
if not quiet:
print(f"\n=== {sym} {TF.get(tf,tf)} SL{sl_m}:TP{tp_m} H={H} "
f"bars={n} spread={sp:.5f} ({sp/np.nanmedian(a):.3f} ATR) "
f"break-even={100*be:.2f}% ===")
print(f"{'pattern':<14}{'w':>4}{'fires':>8}{'trades':>8}{'win%':>8}"
f"{'edge pp':>9}{'z':>7}{'exp R':>8}")
for r in rows:
star = ' *' if abs(r[6]) > crit else ''
print(f"{r[0]:<14}{r[1]:>4}{r[2]:>8}{r[3]:>8}{r[4]:>8.2f}"
f"{r[5]:>+9.2f}{r[6]:>+7.2f}{r[7]:>+8.3f}{star}")
print(f" family-wise 5% bar (max|z| over {len(rows)} patterns, {nperm} sign-flips): "
f"|z| > {crit:.2f}")
return rows, crit
if __name__ == '__main__':
t0 = time.time()
GEOM = [(2, 3, 96), (2, 6, 192), (1, 2, 64)]
for sym, tf in [('EURUSD', 16385), ('USDJPY', 16385), ('XAUUSD', 16385), ('SP500', 16385)]:
for (s, p, H) in GEOM:
try:
run(sym, tf, s, p, H)
except Exception as ex:
print(f"{sym} {tf} {s}:{p} FAILED {ex}")
print(f"\ntotal {time.time()-t0:.0f}s")