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>
131 lines
5.4 KiB
Python
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")
|