Warrior_EA/research/test_context.py
AnimateDread c998d655ee research: Wyckoff CONTEXT has real predictive content - 8/8 positive slopes
Earlier tests fired on the shakeout alone, which is not the method. Book 2 2.3
treats it as the third of four cumulative traces and reads the structure's own
history first. This scores all of them, oriented to the shakeout's direction:

  1  Phase A test location (upper vs lower half of the structure)
  2  Phase B test location
  2b STRUCTURAL FAILURE - after the Phase B test, did price fail to reach the
     opposite extreme
  4  effort/result on the shakeout bar (close position + volume vs range average)
  7.1 higher-timeframe context - is the larger move in the shakeout's favour

Conditioning on agreement shrinks the sample and multiplies the ways to slice
it, so the test is NOT 'find the combination that works'. It is the one
pre-specified prediction the books make and mining does not: expR must rise
MONOTONICALLY with the number of agreeing traces. One slope, no threshold to
tune, no best cell to pick.

POOLED (16,234 non-overlapping trades):

    0 traces  -0.332      3 traces  -0.114
    1 trace   -0.214      4 traces  -0.117
    2 traces  -0.184

  slope +0.0464 R per agreeing trace, t +2.16
  per-symbol slopes POSITIVE IN ALL 8 CELLS (p ~ 0.004 on sign alone)

So the context logic is real and measurable - it is not folklore. But the base
trade is in too deep a hole for it to matter: full confluence still returns
-0.117, and reaching break-even would need ~7 agreeing traces when only 5 exist.

The useful reading is that context is a MODIFIER worth about +0.05 R per trace,
which is only interesting when bolted to a trigger whose base expectancy is
already near zero. The shakeout's is not, because its structural target sits
4-6R away and is rarely reached.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:56:05 -04:00

198 lines
8.8 KiB
Python

"""Wyckoff as the books actually teach it: the shakeout PLUS the context that qualifies it.
The previous test fired on any pierce of a range edge. That is not the method. Book 2 §2.3
is explicit that the shakeout is the third of four cumulative traces, and that you read the
structure's own history before deciding what a pierce means:
TRACE 1 Phase A test location. "divide the vertical distance of the structure in two:
if the Secondary Test develops in the lower part it indicates weakness; if it
ends at the top, less resistance."
TRACE 2 Phase B test + REACTION. "a test at the upper part denotes strength, at the
lower part weakness"; and "an inability to visit the opposite extreme alerts us
to a STRUCTURAL FAILURE, which adds strength in the opposite direction."
TRACE 3 Phase C shakeout. "the dominant event... the shakeout alone should be valid
enough to bias us in favour of its direction."
TRACE 4 Phase D effort/result. "wide ranges and high volume in favour of the movement
that follows the shakeout (SOS/SOW bar)."
§7.1 context: in a range, trade the extremes; in a trend, trade only with it.
THE DESIGN, AND WHY IT IS A DOSE-RESPONSE CURVE
-----------------------------------------------
Conditioning on several agreeing traces shrinks the sample and multiplies the ways to slice
it, which is precisely how a filter gets mined into looking profitable. So the test is NOT
"find the combination that works". It is a single pre-specified prediction the books make
and a mined artifact does not:
if context is real, expR must RISE MONOTONICALLY with the number of agreeing traces.
One number decides it - the slope across buckets - with no threshold to tune, no best cell
to pick, and no way to improve it by looking. A jagged profile whose top bucket happens to
be positive is exactly what mining produces and it fails this test.
Entries remain MARKET ORDERS at the next bar's open, so the fill artifact that invalidated
an earlier round cannot recur. Benchmark is expR = 0 exactly.
"""
import numpy as np, sys
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from test_retail import load_bars, race_px
from test_cause_effect import atr_of, find_ranges
SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500')
def events(sym, tf, theta=0.60, ov=0.75, H=200, path_tf='M5', htf=200):
a1, I1 = load_bars(sym, tf)
g = lambda k: a1[:, I1[k]]
o, h, l, c = g('open'), g('high'), g('low'), g('close')
vol = g('ticks'); spm = g('spread_mean')
t1 = a1[:, I1['time']].astype(np.int64)
n = len(c)
atr = atr_of(h, l, c, 14); atr = np.concatenate([[atr[0]], atr[:-1]])
L, hi_, lo_ = find_ranges(h, l, c, atr, theta=theta)
a2, I2 = load_bars(sym, path_tf)
ph, pl, pc = a2[:, I2['high']], a2[:, I2['low']], a2[:, I2['close']]
pmap = np.searchsorted(a2[:, I2['time']], t1)
step = 12 if tf == 'H1' else (3 if tf == 'M15' else 1)
HH = H * step
ok = (L > 0) & np.isfinite(hi_) & np.isfinite(lo_)
sp_ = ok & (l < lo_) & ((lo_ - l) <= ov * atr) & (c > lo_) # spring -> long
up_ = ok & (h > hi_) & ((h - hi_) <= ov * atr) & (c < hi_) # upthrust -> short
idx = np.nonzero(sp_ | up_)[0]
idx = idx[(idx > max(300, htf + 5)) & (idx < n - 5)]
if not len(idx):
return None
d = np.where(sp_[idx], 1, -1)
rows = []
for q in range(len(idx)):
i = int(idx[q]); dd = int(d[q]); Lq = int(L[i])
s = i - Lq # the range window is [s, i-1]
if s < 1:
continue
top, bot = hi_[i], lo_[i]
mid = 0.5 * (top + bot)
th = max(Lq // 3, 2)
segA = slice(s, s + th) # Phase A third
segB = slice(s + th, s + 2 * th) # Phase B third
segC = slice(s + 2 * th, i) # the run-up to the shakeout
#--- TRACE 1: did the early test reach the upper or the lower half?
upA = h[segA].max() - mid
dnA = mid - l[segA].min()
t1_ = 1 if upA > dnA else -1
#--- TRACE 2: same for the middle third
upB = h[segB].max() - mid
dnB = mid - l[segB].min()
t2_ = 1 if upB > dnB else -1
#--- TRACE 2b: STRUCTURAL FAILURE - after the Phase B test, did price fail to
#--- reach the opposite extreme? Failure adds strength AGAINST the tested side.
t3_ = 0
if len(c[segC]):
if t2_ > 0: # tested the top; did it reach the low?
t3_ = 1 if l[segC].min() > bot + 0.25 * (top - bot) else -1
else: # tested the low; did it reach the top?
t3_ = -1 if h[segC].max() < top - 0.25 * (top - bot) else 1
#--- TRACE 4: effort/result on the shakeout bar itself - closes back decisively in
#--- the shakeout's direction, on elevated volume. Known at the signal bar.
rr = max(h[i] - l[i], 1e-12)
clspos = (c[i] - l[i]) / rr if dd > 0 else (h[i] - c[i]) / rr
vavg = vol[s:i].mean() if i > s else vol[i]
t4_ = 1 if (clspos > 0.6 and vol[i] > 1.2 * max(vavg, 1e-12)) else -1
#--- §7.1 CONTEXT: is the larger move in the shakeout's favour (re-accumulation)?
t5_ = 1 if np.sign(c[i] - c[i - htf]) == dd else -1
#--- traces are oriented so +1 = agrees with the shakeout's implied direction
agree = sum(1 for x in (t1_ * dd, t2_ * dd, t3_ * dd, t4_, t5_) if x > 0)
e = i + 1
if e >= n - 1:
continue
pi = int(pmap[e])
if pi + HH >= len(ph):
continue
ent = o[e]
ext = l[i] if dd > 0 else h[i]
stop = ext - dd * 0.10 * atr[i]
targ = top if dd > 0 else bot
risk = abs(ent - stop); rew = abs(targ - ent)
if risk <= 2 * spm[e] or rew < 0.25 * risk:
continue
rows.append((e, pi, dd, ent, stop, targ, risk, rew, spm[e], agree, Lq, t1[e]))
if len(rows) < 100:
return None
#--- non-overlapping, so buckets are not padded with shared price paths
rows.sort(key=lambda r: r[0])
keep, busy = [], -1
for r in rows:
if r[0] <= busy:
continue
keep.append(r); busy = r[0] + r[10]
if len(keep) < 100:
return None
A = lambda k: np.array([r[k] for r in keep])
E, P, D, EN, ST, TG, RK, RW, SP, AG = (A(k) for k in range(10))
r = race_px(ph, pl, P, D, ST, TG, HH)
R = np.where(r > 0, RW / RK, np.where(r < 0, -1.0, 0.0))
un = r == 0
if un.any():
qq = np.minimum(P[un] + HH, len(pc) - 1)
R[un] = (pc[qq] - EN[un]) * D[un] / RK[un]
R = R - SP / RK
return R, AG, A(11)
def trend_t(R, AG):
"""Slope of expR against the confluence count, and its t. This is the whole test."""
x = AG.astype(float)
if x.std() < 1e-9:
return 0.0, 0.0
X = np.column_stack([np.ones(len(x)), x])
beta, *_ = np.linalg.lstsq(X, R, rcond=None)
resid = R - X @ beta
s2 = resid @ resid / max(len(x) - 2, 1)
se = np.sqrt(s2 * np.linalg.inv(X.T @ X)[1, 1])
return float(beta[1]), float(beta[1] / max(se, 1e-12))
if __name__ == '__main__':
syms = [s for s in sys.argv[1:] if s in SYMS] or list(SYMS)
print("=== WYCKOFF WITH CONTEXT: does expR rise with the number of agreeing traces? ===")
print(" traces: PhaseA test / PhaseB test / structural failure / effort-result / HTF")
print(" the books predict a MONOTONE rise. A jagged profile with a good top bucket")
print(" is what mining produces.\n")
print(f" {'symbol':>7}{'tf':>5}{'n':>6} " +
"".join(f"{k:>9}" for k in range(6)) + f"{'slope':>9}{'t':>7}")
pool_R, pool_A = [], []
for tf in ('M15', 'H1'):
for s in syms:
out = events(s, tf)
if out is None:
print(f" {s:>7}{tf:>5} - too few")
continue
R, AG, _ = out
cells = []
for k in range(6):
m = AG == k
cells.append(f"{R[m].mean():+6.2f}({int(m.sum()):>3})" if m.sum() >= 25
else f"{'-':>9}")
sl, tt = trend_t(R, AG)
print(f" {s:>7}{tf:>5}{len(R):>6} " + "".join(f"{x:>9}" for x in cells)
+ f"{sl:>+9.3f}{tt:>+7.2f}")
pool_R.append(R); pool_A.append(AG)
if pool_R:
R = np.concatenate(pool_R); AG = np.concatenate(pool_A)
sl, tt = trend_t(R, AG)
print(f"\n POOLED n={len(R):,}")
for k in range(6):
m = AG == k
if m.sum() >= 25:
se = R[m].std(ddof=1) / np.sqrt(m.sum())
print(f" {k} traces agree: n={int(m.sum()):>5} expR {R[m].mean():+7.3f}"
f" +/- {se:.3f}")
print(f" slope per extra agreeing trace: {sl:+.4f} R t {tt:+.2f}")