Warrior_EA/research/test_exits.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

131 lines
5.4 KiB
Python

"""Does trade management create edge, or only reshape it?
The user's point: "where we exit, where we put our stop losses matters as much as where
we enter." This tests it directly, and with the control that makes the answer meaningful.
Design: hold the ENTRIES fixed, vary only the EXIT rule, and measure expectancy in R.
Then run every identical exit rule on RANDOM entries at the same bars. If a management
rule creates expectancy, it will create it on random entries too - which would prove the
gain is not edge but a reshaping of the outcome distribution. Only a rule that beats its
own random-entry control by more than noise is doing something informational.
Exit rules swept:
fixed SL/TP at entry, the baseline
be@1R move stop to entry once +1R is touched
trail_k chandelier: stop trails k*ATR below the running high (long)
time_T flat at T bars regardless
partial half off at +1R, remainder runs to TP with stop at entry
"""
import numpy as np, sys, time
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from classic import module_votes, combined_direction
from test_classic import prepare, sequential, barrier_outcomes, TF
def walk(o, h, l, a_sig, ent, dirs, sl_m, tp_m, H, sp, rule, param=0.0):
"""Bar-by-bar path simulation. Returns realised R per trade (R = the initial SL distance).
Within-bar ordering is pessimistic throughout: the adverse barrier is tested before
the favourable one, so a bar that spans both books the loss.
"""
n = len(o)
out = np.zeros(len(ent))
for q, (e, d) in enumerate(zip(ent, dirs)):
A = a_sig[e]
if not np.isfinite(A) or A <= 0:
continue
R = sl_m * A
entry = o[e] + (sp if d > 0 else -sp)
stop = entry - d * R
targ = entry + d * tp_m * A
best = entry
half_done = False
pnl = 0.0
closed = False
for b in range(e, min(e + H, n)):
hi, lo = h[b], l[b]
# --- adverse first
if (d > 0 and lo <= stop) or (d < 0 and hi >= stop):
pnl += (1.0 if half_done else 1.0) * d * (stop - entry) / R
closed = True
break
# --- favourable
if (d > 0 and hi >= targ) or (d < 0 and lo <= targ):
pnl += (0.5 if half_done else 1.0) * d * (targ - entry) / R
closed = True
break
# --- management, applied on the CLOSE of the bar (never intrabar)
best = max(best, hi) if d > 0 else min(best, lo)
if rule == 'be@1R' and d * (best - entry) >= R:
stop = max(stop, entry) if d > 0 else min(stop, entry)
elif rule == 'trail':
cand = best - d * param * A
stop = max(stop, cand) if d > 0 else min(stop, cand)
elif rule == 'partial':
if not half_done and d * (best - entry) >= R:
pnl += 0.5 * 1.0 # half booked at +1R
half_done = True
stop = max(stop, entry) if d > 0 else min(stop, entry)
elif rule == 'time' and (b - e) >= param:
pnl += (0.5 if half_done else 1.0) * d * (c_close(o, b) - entry) / R
closed = True
break
if not closed:
b = min(e + H, n) - 1
pnl += (0.5 if half_done else 1.0) * d * (c_close(o, b) - entry) / R
out[q] = pnl
return out
_CLOSE = {}
def c_close(o, b):
return _CLOSE['c'][b]
def run(sym, tf, sl_m, tp_m, H, T=30, nrand=20, seed=7):
o, h, l, c, a, sp, fireL, fireS, names, weights = prepare(sym, tf)
_CLOSE['c'] = c
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)
votes = module_votes(fireL, fireS, weights)
direction = combined_direction(votes)
fb = np.nonzero(np.abs(direction) >= T)[0]
d0 = np.sign(direction[fb]).astype(int)
ent, dirs, _ = sequential(fb, d0, winL, winS, H, n)
if len(ent) < 100:
print(f"{sym} {sl_m}:{tp_m} too few trades ({len(ent)})")
return
rng = np.random.default_rng(seed)
rules = [('fixed', 0.0), ('be@1R', 0.0), ('trail 2.0', 2.0), ('trail 3.0', 3.0),
('trail 4.0', 4.0), ('partial', 0.0),
('time %d' % (H // 4), H // 4), ('time %d' % (H // 2), H // 2)]
print(f"\n=== {sym} {TF.get(tf,tf)} SL{sl_m}:TP{tp_m} H={H} vote>={T} "
f"{len(ent)} trades (R = {sl_m} ATR) ===")
print(f"{'exit rule':<12}{'signal E[R]':>13}{'random E[R]':>13}"
f"{'diff':>9}{'diff sigma':>12}")
for name, param in rules:
base = name.split()[0]
rr = walk(o, h, l, a_sig, ent, dirs, sl_m, tp_m, H, sp, base, param)
sig_e = rr.mean()
# control: identical bars and identical exit rule, random direction
ctrl = np.empty(nrand)
for k in range(nrand):
rd = np.where(rng.random(len(ent)) < 0.5, 1, -1)
ctrl[k] = walk(o, h, l, a_sig, ent, rd, sl_m, tp_m, H, sp, base, param).mean()
diff = sig_e - ctrl.mean()
sd = np.sqrt(rr.std(ddof=1) ** 2 / len(rr) + ctrl.var(ddof=1))
print(f"{name:<12}{sig_e:>+13.4f}{ctrl.mean():>+13.4f}{diff:>+9.4f}"
f"{diff/sd:>+12.2f}")
if __name__ == '__main__':
t0 = time.time()
for sym in ('EURUSD', 'USDJPY'):
for (s, p, H) in [(2, 3, 96), (1, 2, 64)]:
run(sym, 16385, s, p, H)
print(f"\ntotal {time.time()-t0:.0f}s")