The statistics had cleared a family-wise bar (z to +6.56) and a split-half. Both necessary, neither sufficient: thorough enough mining passes both. What mining cannot do is obey a mechanism it was never fitted to - so the decisive test is whether the effect appears WHERE THE THEORY SAYS IT MUST. Osler's stop-clustering predicts the edge concentrates where the stop reservoir is deepest and fresh forced orders arrive: London open and the London/NY overlap. Measured, by session in real UTC (broker is UTC+2): EURUSD Sydney/Tokyo +0.197* London open -0.063 London -0.156 USDJPY Rollover +0.233 London open -0.223 XAUUSD Sydney/Tokyo +0.070 London open -0.345 LDN/NY -0.185 SP500 Rollover +0.206 NY afternoon -0.098 Exactly inverted. The liquid sessions where stops actually cluster are the worst on every instrument; what remains lives in Sydney/Tokyo and rollover - the THINNEST hours, where fewest stops sit. That is not the mechanism, and thin hours are also where spreads are widest and fills worst, so even the surviving fragment points away from tradeability rather than toward it. Walk-forward by quarter agrees, and shows what the two-way split was hiding: EURUSD 2/4 positive (Q1 -0.029, Q2 +0.062, Q3 -0.010, Q4 +0.089) USDJPY 1/4 XAUUSD 0/4 SP500 2/4 No instrument reaches 3/4. The split-half HOLDS was Q2+Q4 carrying Q1+Q3. Verdict: not an edge. Recording it as disconfirmed rather than leaving an encouraging half-result in the log, because the next person to read this - me - would otherwise build on it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
5.2 KiB
Python
121 lines
5.2 KiB
Python
"""Does the stop-run edge behave the way its MECHANISM says it should?
|
|
|
|
The statistics already cleared a family-wise bar and a split-half. That is necessary and
|
|
not sufficient - a mined artifact can do both if the mining was thorough enough. What a
|
|
mined artifact CANNOT do is obey a mechanism it was never fitted to.
|
|
|
|
If the edge is really stop-cluster liquidity, then it must be concentrated where the stop
|
|
reservoir is deepest and where fresh forced orders arrive:
|
|
- around SESSION OPENS, and the London/NY handover
|
|
- NOT uniform around the clock
|
|
A uniform effect would be evidence AGAINST the mechanism even with the same p-value, and
|
|
would make the whole thing much likelier to be curve-fit. This is the test that can
|
|
disconfirm.
|
|
|
|
Also here: walk-forward by quarters rather than halves (a 2-way split can hide a single
|
|
lucky regime), and the other two instruments.
|
|
|
|
Broker time is UTC+2 (measured, see dukas.py notes) - every hour below is converted to UTC
|
|
so the session labels mean what they say.
|
|
"""
|
|
import numpy as np, sys, datetime as dt
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
from test_liquidity import load, atr_of, rolling_extreme, outcomes
|
|
|
|
BROKER_OFFSET_H = 2 # SQX conforms to the5ers: broker = UTC + 2
|
|
|
|
|
|
def build(sym, tf='H1', N=50, ov=0.5, R=3.0, H=48):
|
|
a, I = load(sym, tf)
|
|
g = lambda c: a[:, I[c]]
|
|
o, h, l, c = g('open'), g('high'), g('low'), g('close')
|
|
spb = g('spread_mean'); spb = np.concatenate([[spb[0]], spb[:-1]])
|
|
atr = atr_of(h, l, c, 14); atr = np.concatenate([[atr[0]], atr[:-1]])
|
|
n = len(c)
|
|
ph = rolling_extreme(h, N, 'max'); pl = rolling_extreme(l, N, 'min')
|
|
sh = (h > ph) & ((h - ph) <= ov * atr) & (c < ph) & np.isfinite(ph)
|
|
sl = (l < pl) & ((pl - l) <= ov * atr) & (c > pl) & np.isfinite(pl)
|
|
fire = np.nonzero(sh | sl)[0]
|
|
fire = fire[(fire > N + 2) & (fire < n - H - 2)]
|
|
d = np.where(sh[fire], -1, 1)
|
|
e = fire + 1
|
|
buf = 0.05 * atr[fire]
|
|
stop = np.where(d < 0, h[fire] + buf, l[fire] - buf)
|
|
ent = o[e]
|
|
risk = np.abs(ent - stop) + spb[e]
|
|
keep, busy = [], -1
|
|
for k in range(len(e)):
|
|
if risk[k] <= 0 or e[k] <= busy:
|
|
continue
|
|
keep.append(k); busy = e[k] + H
|
|
keep = np.array(keep, int)
|
|
targ = np.where(d < 0, ent - R * risk, ent + R * risk)
|
|
w, to = outcomes(o, h, l, e[keep], d[keep],
|
|
np.where(d[keep] < 0, stop[keep] + spb[e[keep]],
|
|
stop[keep] - spb[e[keep]]),
|
|
targ[keep], H)
|
|
t = a[:, I['time']][e[keep]]
|
|
utc_h = np.array([(dt.datetime.fromtimestamp(x / 1000, dt.UTC).hour - BROKER_OFFSET_H) % 24
|
|
for x in t])
|
|
dow = np.array([dt.datetime.fromtimestamp(x / 1000, dt.UTC).weekday() for x in t])
|
|
return w, d[keep], utc_h, dow, t, R
|
|
|
|
|
|
def expR(w, R):
|
|
return w.mean() * R - (1 - w.mean()) if len(w) else 0.0
|
|
|
|
|
|
def boot_ci(w, R, n=4000, seed=3):
|
|
"""Bootstrap CI on expected R. Trades are non-overlapping so a plain resample is fine."""
|
|
if len(w) < 20:
|
|
return (np.nan, np.nan)
|
|
rng = np.random.default_rng(seed)
|
|
idx = rng.integers(0, len(w), (n, len(w)))
|
|
m = w[idx].mean(axis=1)
|
|
e = m * R - (1 - m)
|
|
return float(np.quantile(e, 0.05)), float(np.quantile(e, 0.95))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
SESSIONS = [('Sydney/Tokyo', lambda h: (h >= 0) & (h < 7)),
|
|
('London open', lambda h: (h >= 7) & (h < 10)),
|
|
('London', lambda h: (h >= 10) & (h < 12)),
|
|
('LDN/NY overlap', lambda h: (h >= 12) & (h < 16)),
|
|
('NY afternoon', lambda h: (h >= 16) & (h < 21)),
|
|
('Rollover/thin', lambda h: (h >= 21) | (h < 0))]
|
|
|
|
for sym in ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500'):
|
|
try:
|
|
w, d, uh, dow, t, R = build(sym)
|
|
except Exception as ex:
|
|
print(f"{sym}: {ex}")
|
|
continue
|
|
print(f"\n{'='*76}\n{sym} H1 N=50 ov=0.5 R=3 {len(w)} trades "
|
|
f"overall expR {expR(w,R):+.3f}\n{'='*76}")
|
|
|
|
print(" --- by SESSION (UTC) - the mechanism test -------------------------")
|
|
print(f" {'session':<16}{'n':>6}{'win%':>8}{'expR':>9}{'90% CI':>20}")
|
|
for name, f in SESSIONS:
|
|
m = f(uh)
|
|
if m.sum() < 30:
|
|
continue
|
|
lo, hi = boot_ci(w[m], R)
|
|
print(f" {name:<16}{int(m.sum()):>6}{100*w[m].mean():>8.2f}{expR(w[m],R):>+9.3f}"
|
|
f" [{lo:+.3f}, {hi:+.3f}]{' *' if lo > 0 else ''}")
|
|
|
|
print(" --- WALK-FORWARD by quarter of the sample -------------------------")
|
|
q = np.array_split(np.arange(len(w)), 4)
|
|
line = " "
|
|
for i, ix in enumerate(q):
|
|
line += f"Q{i+1} {expR(w[ix],R):+.3f} (n={len(ix)}) "
|
|
print(line)
|
|
pos = sum(1 for ix in q if expR(w[ix], R) > 0)
|
|
print(f" quarters positive: {pos}/4")
|
|
|
|
print(" --- by DIRECTION --------------------------------------------------")
|
|
for lbl, m in (('fade high sweep (short)', d < 0), ('fade low sweep (long)', d > 0)):
|
|
if m.sum() < 30:
|
|
continue
|
|
lo, hi = boot_ci(w[m], R)
|
|
print(f" {lbl:<24}{int(m.sum()):>6}{100*w[m].mean():>8.2f}"
|
|
f"{expR(w[m],R):>+9.3f} [{lo:+.3f}, {hi:+.3f}]")
|