Warrior_EA/research/test_confluence.py
AnimateDread bd076ddbad 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

109 lines
4.4 KiB
Python

"""Confluence, not lone patterns.
The user's framing, and the EA's own design: weight-10 models are CONFIRMING states that
must never be acted on alone; a trade needs an event model plus agreement. So this tests
the aggregate, three ways:
vote>=T CExpertSignalCustom::Direction() - each module casts its highest-numbered
matching pattern's weight, signed; the non-zero module votes are AVERAGED;
trade when |average| >= T. This is literally what the shipped EA does.
quorum>=K at least K of the 4 modules agree on a direction (any strength).
event+cfm an ACTIONABLE model (weight > 10) fires, AND at least K confirming models
(weight == 10) agree with it - the doctrine the weights encode.
Same discipline as test_classic.py: sequential non-overlapping trades, break-even ==
chance by the gambler's-ruin identity, family-wise 5% bar from a sign-flip null.
"""
import numpy as np, sys, time
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from classic import module_votes, combined_direction, MODULE_SLICES
from test_classic import prepare, barrier_outcomes, sequential, TF
def evaluate(label, fire_bars, dirs, winL, winS, H, n, be, rng, nperm, acc):
ti, td, tw = sequential(fire_bars, dirs, winL, winS, H, n)
nT = len(ti)
if nT < 30:
return None
wr = tw.mean()
se = np.sqrt(be * (1 - be) / nT)
z = (wr - be) / se
wl_at, ws_at = winL[ti], winS[ti]
fl = rng.random((nperm, nT)) < 0.5
pz = (np.where(fl, wl_at[None, :], ws_at[None, :]).mean(axis=1) - be) / se
acc.append(np.abs(pz))
return (label, len(fire_bars), nT, 100 * wr, 100 * (wr - be), z)
def run(sym, tf, sl_m, tp_m, H, nperm=2000, seed=1):
o, h, l, c, a, sp, fireL, fireS, names, weights = prepare(sym, tf)
n = len(c)
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)
rng = np.random.default_rng(seed)
rows, acc = [], []
votes = module_votes(fireL, fireS, weights)
direction = combined_direction(votes)
V = np.column_stack([votes[m] for m in MODULE_SLICES])
# --- 1. the EA's own averaged weighted vote
for T in (10, 20, 30, 40, 50, 60, 70):
fb = np.nonzero(np.abs(direction) >= T)[0]
if len(fb) == 0:
continue
r = evaluate(f"vote>={T}", fb, np.sign(direction[fb]).astype(int),
winL, winS, H, n, be, rng, nperm, acc)
if r:
rows.append(r)
# --- 2. quorum: K of 4 modules agree, no strength requirement
agree_long = (V > 0).sum(axis=1)
agree_short = (V < 0).sum(axis=1)
for K in (2, 3, 4):
m = ((agree_long >= K) & (agree_short == 0)) | ((agree_short >= K) & (agree_long == 0))
fb = np.nonzero(m)[0]
if len(fb) == 0:
continue
d = np.where(agree_long[fb] >= K, 1, -1)
r = evaluate(f"quorum>={K}", fb, d, winL, winS, H, n, be, rng, nperm, acc)
if r:
rows.append(r)
# --- 3. an actionable event, corroborated by K confirming states
act = np.array([w > 10 for w in weights])
cfm = ~act
evL, evS = fireL[:, act].any(axis=1), fireS[:, act].any(axis=1)
ncL, ncS = fireL[:, cfm].sum(axis=1), fireS[:, cfm].sum(axis=1)
for K in (1, 2, 3, 4, 5):
mL = evL & (ncL >= K) & ~evS
mS = evS & (ncS >= K) & ~evL
fb = np.nonzero(mL | mS)[0]
if len(fb) == 0:
continue
d = np.where(mL[fb], 1, -1)
r = evaluate(f"event+{K}cfm", fb, d, winL, winS, H, n, be, rng, nperm, acc)
if r:
rows.append(r)
if not rows:
return
crit = np.quantile(np.maximum.reduce(acc), 0.95)
print(f"\n=== {sym} {TF.get(tf,tf)} SL{sl_m}:TP{tp_m} H={H} "
f"break-even={100*be:.2f}% (family-wise 5% bar |z|>{crit:.2f}) ===")
print(f"{'rule':<14}{'fires':>9}{'trades':>8}{'win%':>8}{'edge pp':>9}{'z':>7}")
for r in sorted(rows, key=lambda x: -x[5]):
star = ' *' if abs(r[5]) > crit else ''
print(f"{r[0]:<14}{r[1]:>9}{r[2]:>8}{r[3]:>8.2f}{r[4]:>+9.2f}{r[5]:>+7.2f}{star}")
if __name__ == '__main__':
t0 = time.time()
for sym in ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500'):
for (s, p, H) in [(2, 3, 96), (2, 6, 192), (1, 2, 64)]:
try:
run(sym, 16385, s, p, H)
except Exception as ex:
print(f"{sym} {s}:{p} FAILED {ex}")
print(f"\ntotal {time.time()-t0:.0f}s")