161 行
6.8 KiB
Python
161 行
6.8 KiB
Python
|
|
"""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")
|