Warrior_EA/research/test_wyckoff.py
AnimateDread c2dd9eb6aa research: retail setups ARE anti-predictive - and the edge dies with the cost
Tests the user's thesis directly: if price is unpredictable, trade against the
people predicting it badly. Implements the three mechanical setups from 'How To
Day Trade Forex For Profit' ch.5 with their DOCUMENTED stop rules, so retail
stops are located exactly rather than by proxy.

THE MIRROR TEST. Retail's trade and its exact mirror, priced under identical
rules. Both sides pay the same spread and suffer the same same-bar tie
convention, so those cancel in the difference and double in the sum:

    edge = (mirror - retail)/2      cost = -(mirror + retail)/2

  pin     EDGE +0.108 R   COST +0.143 R
  inside  EDGE +0.068 R   COST +0.140 R
  engulf  EDGE -0.001 R   COST +0.095 R

So pin-bar and inside-bar setups really are anti-predictive - the first
confirmed directional edge in this project. Engulfing is a pure coin flip whose
loss is entirely the spread, i.e. money already gone to the broker.

Stable across three conventions: H1 bars pessimistic ties, M5 path pessimistic,
M5 path optimistic. Re-walking the barriers on M5 CUT the cost (0.195 -> 0.143)
and RAISED the edge (0.078 -> 0.108), so the coarse-bar convention was masking
the effect, not manufacturing it.

THEN THE TEST THAT KILLS IT. Cost in R is spread/stop-distance, so widening the
stop divides it. If the edge is directional drift it survives. Fade expR by stop
multiple (pin, k=1, 122k trades):

    m=1.0  cost 0.146  expR -0.045     implied edge +0.101
    m=1.5  cost 0.097  expR -0.067                  +0.030
    m=2.0  cost 0.073  expR -0.065                  +0.008
    m=3.0  cost 0.049  expR -0.051                  -0.002
    m=5.0  cost 0.029  expR -0.040                  -0.011

The edge decays exactly as fast as the cost, then inverts. It was never drift:
it is reversion against a stop order filled AT a local extreme, and it lives
within one bar-range of the entry - the same short-horizon reversal the tick-flow
work already measured, meeting the same fate.

Also in this commit, the volume-profile claims from Wyckoff 2.0:
  MAGNET   all 10 tests positive vs a distance-matched placebo, z +3.0 to +7.8,
           family-wise bar 2.79 - but the effect is +0.15 to +0.59pp on a ~74%
           base rate, i.e. ~0.01 R.
  REACTION naked VPOC and VPOC reject +0.71 to +0.86pp (z to +5.65); value-area
           edges null or negative; HVN/LVN marginal.
  80% RULE dead. 27.8% traversal against a 29.0% martingale benchmark. Acceptance
           nearly DOUBLES the raw rate (14.1% -> 27.8%) and the benchmark doubles
           with it - every bit of the apparent improvement is geometry.

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

432 lines
18 KiB
Python

"""Does volume-at-price structure do anything measurable? (Villahermosa, "Wyckoff 2.0")
This is a different family from everything tested so far in this project. Every previous
test asked a DIRECTIONAL question - can something predict the sign of the next move - and
the answer was consistently "a little, and less than the spread". The volume-profile claims
are not directional. They are claims about the PATH:
HVN / VPOC agreement -> a magnet. Price is drawn to it and lingers. ("good targets")
LVN rejection -> price refuses to trade there and turns. ("good stops")
Value area the 80% rule: re-entry and acceptance implies traversal.
That distinction is why this is worth testing after six negative families. A path claim can
be true while every directional claim is false, and if HVN/LVN really are non-uniform then
STOP AND TARGET PLACEMENT carries edge with no forecast at all - which is exactly what was
asked for: where to enter, where to exit, where to put the stop.
THE NULL - and why the obvious one is degenerate
------------------------------------------------
The tempting test is "how often does price reach the naked VPOC". That number is
meaningless on its own, and worse, it cannot be fixed by comparing against a control level
at the SAME distance, because at the same distance the control IS the same price. The
probability of touching a level is a function of its distance and nothing else.
So the null has to destroy the level's IDENTITY while preserving its GEOMETRY:
null offset = ATR_i x (d / ATR)_permuted
Distances in volatility units are shuffled across events. The marginal distribution of
distance is preserved exactly, the local volatility scaling is preserved exactly (a naked
VPOC in a quiet market stays near, in a wild one stays far), the market conditions are the
real ones - and the only thing removed is that the level sits where the market previously
agreed on value. Anything the real levels do beyond the permuted ones is attributable to
volume structure.
The 80% rule gets a better null still: an EXACT martingale benchmark. Entering a band at
price p between a near edge and a far edge, a driftless process reaches the far edge first
with probability (p - near)/(far - near). No simulation needed, and it correctly punishes
the fact that acceptance usually happens close to the edge you came in through.
"""
import numpy as np, sys, os, datetime as dt
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from vplevels import (load, day_index, session_levels, composite_nodes, naked_vpocs,
broker_day)
BARS = 'c:/Users/admin/Documents/Workspaces/Market Data/bars/'
SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500')
def load_bars(sym, tf='M5'):
z = np.load(f"{BARS}{sym}_{tf}_ticks.npz", allow_pickle=True)
a = z['bars']
return a, {str(c): k for k, c in enumerate(z['columns'])}
def atr_of(h, l, c, n=14):
pc = np.roll(c, 1); pc[0] = c[0]
tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc)))
out = np.convolve(tr, np.ones(n) / n, mode='full')[:len(tr)]
out[:n] = tr[:n].mean()
return out
def forward_cummax(h, l, idx, H):
"""Running max-high and min-low over bars idx+1 .. idx+k, for every k <= H.
This is the whole permutation test in one array. 'Does price reach a level at distance
d' is exactly 'd <= the forward excursion', so once the excursion is known every
permuted level is a single comparison instead of another 288-pass scan - the difference
between 5 hours and a few seconds. Monotone in k, so the first-touch BAR is a
searchsorted rather than a scan too.
"""
W = np.lib.stride_tricks.sliding_window_view
keep = idx + H < len(h)
idx = idx[keep]
cu = np.maximum.accumulate(W(h, H)[idx + 1], axis=1).astype(np.float32)
cl = np.minimum.accumulate(W(l, H)[idx + 1], axis=1).astype(np.float32)
return idx, keep, cu, cl
def touched(cu, cl, up, price):
"""Was `price` reached within the window? Pure comparison against the final excursion."""
return np.where(up, price <= cu[:, -1], price >= cl[:, -1])
def touch_bar(cu, cl, up, price, idx):
"""Bar index of first touch, or -1.
argmax over the boolean 'reached by bar k' matrix, not a per-row searchsorted: rows
differ in their target so searchsorted cannot be batched, and a Python loop over
100k rows inside a permutation loop is the whole runtime.
"""
reach = np.where(up[:, None], cu >= price[:, None], cl <= price[:, None])
k = reach.argmax(axis=1)
return np.where(reach[np.arange(len(price)), k], idx + 1 + k, -1)
def first_touch(h, l, idx, price, up, H):
"""First bar within (idx, idx+H] whose range covers `price`. -1 if never.
Vectorised over events by stepping the OFFSET, not the event: every event shares the
same k = 1..H, so this is H vector passes rather than one Python loop per event. On
100k events x H=288 that is ~30M element ops, about a second.
"""
n = len(h)
out = np.full(len(idx), -1, np.int64)
live = np.ones(len(idx), bool)
for k in range(1, H + 1):
j = idx + k
ok = live & (j < n)
if not ok.any():
break
jj = j[ok]
hit = np.where(up[ok], h[jj] >= price[ok], l[jj] <= price[ok])
w = np.nonzero(ok)[0][hit]
out[w] = j[w]
live[w] = False
return out
def race(h, l, idx, lo_px, hi_px, H):
"""Which of two barriers is touched first: +1 upper, -1 lower, 0 neither in H bars.
A bar spanning both counts as the LOWER first - the pessimistic convention used
everywhere else in this project, so results stay comparable."""
n = len(h)
out = np.zeros(len(idx), np.int8)
live = np.ones(len(idx), bool)
for k in range(1, H + 1):
j = idx + k
ok = live & (j < n)
if not ok.any():
break
jj = j[ok]
dn = l[jj] <= lo_px[ok]
up = h[jj] >= hi_px[ok]
res = np.where(dn, -1, np.where(up, 1, 0)).astype(np.int8)
w = np.nonzero(ok)[0]
got = res != 0
out[w[got]] = res[got]
live[w[got]] = False
return out
def build_levels(sym, lookback=20):
day, b, tk, dw, binsize = load(sym)
days, cells = day_index(day, b, tk)
sess = session_levels(days, cells)
nodes = composite_nodes(days, cells, lookback=lookback)
naked = naked_vpocs(sess)
return sess, nodes, naked, binsize
def nearest(levels, price_bin, above):
"""Nearest level strictly above / below a price bin, or None."""
if levels is None or len(levels) == 0:
return None
d = levels - price_bin
m = d > 0 if above else d < 0
if not m.any():
return None
return int(levels[m][np.argmin(np.abs(d[m]))])
#----------------------------------------------------------------------------------------
def collect(sym, kind, H=288, every=12, lookback=20, seed=7):
"""Reference bars -> (offset in ATR, real level price, market state) for one level type.
`every` subsamples the M5 grid (12 = hourly) so that consecutive reference bars are not
near-duplicates of each other; overlapping events inflate significance without adding
information.
"""
a, I = load_bars(sym)
g = lambda c: a[:, I[c]]
t, o, h, l, c = g('time'), g('open'), g('high'), g('low'), g('close')
atr = atr_of(h, l, c, 14)
atr = np.concatenate([[atr[0]], atr[:-1]]) # causal
bd = broker_day(t.astype(np.int64))
sess, nodes, naked, binsize = build_levels(sym, lookback)
sday = {int(d): i for i, d in enumerate(sess[:, 0])}
idx, offs, ups = [], [], []
n = len(c)
step = np.arange(14 + 1, n - H - 1, every)
for i in step:
d = int(bd[i])
si = sday.get(d)
if si is None or si < 1:
continue
pb = c[i] / binsize
if kind == 'naked':
lv = naked.get(d)
elif kind in ('hvn', 'lvn'):
nd = nodes.get(d)
if nd is None:
continue
lv = nd[0] if kind == 'hvn' else nd[1]
elif kind == 'vpoc':
lv = np.array([sess[si - 1, 1]])
elif kind == 'vaedge':
lv = np.array([sess[si - 1, 2], sess[si - 1, 3]])
else:
raise ValueError(kind)
if lv is None or len(lv) == 0:
continue
for up in (True, False):
v = nearest(lv, pb, up)
if v is None:
continue
off = v * binsize - c[i]
if atr[i] <= 0:
continue
r = abs(off) / atr[i]
if not (0.2 <= r <= 8.0): # a level 30 ATR away is not an operational level
continue
idx.append(i); offs.append(off); ups.append(up)
return (np.array(idx, np.int64), np.array(offs), np.array(ups, bool),
atr, h, l, c, len(c))
def permute_within(r, up, rng):
"""Shuffle distances WITHIN each direction.
Pooling the two would let an 'up' event inherit a 'down' event's distance. Up and down
distances have different distributions (drift, and asymmetric ranges), and up and down
touch rates differ, so a pooled shuffle mixes a direction effect into what is supposed
to be a pure level-identity test.
"""
out = np.empty_like(r)
for m in (up, ~up):
if m.any():
out[m] = rng.permutation(r[m])
return out
def magnet_test(sym, kind, H=288, every=12, nperm=2000, seed=7, quiet=False, pre=None):
"""Does price reach a real level more often than a distance-matched placebo?"""
idx, off, up, atr, h, l, c, n = pre if pre else collect(sym, kind, H, every)
if len(idx) < 500:
print(f" {sym} {kind}: only {len(idx)} events, skipping")
return None
idx2, keep, cu, cl = forward_cummax(h, l, idx, H)
off, up = off[keep], up[keep]
a = atr[idx2]; base = c[idx2]
rate = touched(cu, cl, up, base + off).mean()
rng = np.random.default_rng(seed)
r = np.abs(off) / a
sgn = np.sign(off)
pr = np.empty(nperm)
for k in range(nperm):
pr[k] = touched(cu, cl, up, base + sgn * permute_within(r, up, rng) * a).mean()
mu, sd = pr.mean(), max(pr.std(ddof=1), 1e-12)
z = (rate - mu) / sd
if not quiet:
print(f" {sym:>7} {kind:<7} n={len(idx2):>7,} touch {100*rate:6.2f}% "
f"null {100*mu:6.2f}% diff {100*(rate-mu):+6.2f}pp z {z:+7.2f}")
return rate, mu, sd, z, len(idx2), (pr - mu) / sd
def reaction_test(sym, kind, H=288, K=48, w=0.30, every=12, nperm=200, seed=11,
quiet=False, pre=None):
"""CONDITIONAL ON REACHING the level, does anything happen there?
At the touch bar, put symmetric barriers w*ATR either side of the level and ask which
is hit first. Rejection means price turns back the way it came. Under no effect this is
~0.5 by symmetry, so the test needs no model of drift - and the same permuted-offset
placebo controls for the fact that arriving anywhere after a directional run is not a
neutral state.
"""
idx, off, up, atr, h, l, c, n = pre if pre else collect(sym, kind, H, every)
if len(idx) < 500:
return None
idx2, keep, cu, cl = forward_cummax(h, l, idx, H)
off, up = off[keep], up[keep]
a = atr[idx2]; base = c[idx2]
def run(offsets):
px = base + offsets
j = touch_bar(cu, cl, up, px, idx2)
m = (j >= 0) & (j + K < n)
if m.sum() < 100:
return np.nan, 0
res = race(h, l, j[m], px[m] - w * a[m], px[m] + w * a[m], K)
dec = res != 0
if dec.sum() < 100:
return np.nan, 0
#--- "reject" = the barrier AWAY from the direction of approach is hit first
away = np.where(up[m], -1, 1)[dec]
return float((res[dec] == away).mean()), int(dec.sum())
rate, nn = run(off)
if not np.isfinite(rate):
return None
rng = np.random.default_rng(seed)
r = np.abs(off) / a; sgn = np.sign(off)
pr = []
for k in range(nperm):
v, _ = run(sgn * permute_within(r, up, rng) * a)
if np.isfinite(v):
pr.append(v)
pr = np.array(pr)
mu, sd = pr.mean(), max(pr.std(ddof=1), 1e-12)
z = (rate - mu) / sd
if not quiet:
print(f" {sym:>7} {kind:<7} touches={nn:>7,} reject {100*rate:6.2f}% "
f"null {100*mu:6.2f}% diff {100*(rate-mu):+6.2f}pp z {z:+7.2f}")
return rate, mu, sd, z, nn, (pr - mu) / sd
def eighty_rule(sym, accept_bars=12, H=288):
"""Market Profile's 80% rule, with an exact martingale benchmark.
Setup: the session OPENS outside the previous session's value area, later trades back
inside, and is ACCEPTED (`accept_bars` consecutive M5 closes inside - 12 = two 30-minute
periods, which is the classic formulation). Claim: ~80% chance of traversing the whole
value area to the far edge.
Benchmark: from the acceptance price p, a driftless process reaches the far edge before
the near one with probability (distance to near edge)/(width). Quoted per event and
averaged, so the comparison is against what geometry alone already delivers.
"""
a, I = load_bars(sym)
g = lambda c: a[:, I[c]]
t, o, h, l, c = g('time'), g('open'), g('high'), g('low'), g('close')
bd = broker_day(t.astype(np.int64))
sess, nodes, naked, binsize = build_levels(sym)
sday = {int(d): i for i, d in enumerate(sess[:, 0])}
starts = np.concatenate(([0], np.flatnonzero(np.diff(bd)) + 1))
ends = np.concatenate((starts[1:], [len(bd)]))
#--- three readings of the same rule, because they answer different questions:
#--- acc = tradeable, after acceptance (far edge BEFORE the near edge)
#--- re = tradeable, on bare re-entry (does 'acceptance' add anything?)
#--- lit = literal, after acceptance (far edge touched AT ALL in the session)
res = {k: ([], []) for k in ('acc', 're')}
lit = []
for s, e in zip(starts, ends):
d = int(bd[s]); si = sday.get(d)
if si is None or si < 1 or e - s < 60:
continue
val = sess[si - 1, 2] * binsize
vah = sess[si - 1, 3] * binsize
if vah - val <= 0:
continue
op = o[s]
if val <= op <= vah:
continue # opened inside value - not this setup
from_above = op > vah
inside = (c[s:e] >= val) & (c[s:e] <= vah)
near, far = (vah, val) if from_above else (val, vah)
run = 0; acc = -1; re = -1
for k in range(len(inside)):
if inside[k] and re < 0:
re = s + k
run = run + 1 if inside[k] else 0
if run >= accept_bars:
acc = s + k
break
for tag, at in (('acc', acc), ('re', re)):
if at < 0 or at + 1 >= e:
continue
r = race(h, l, np.array([at]), np.array([min(near, far)]),
np.array([max(near, far)]), H)[0]
if r == 0:
continue
res[tag][0].append((r == -1) if from_above else (r == 1))
res[tag][1].append(abs(c[at] - near) / (vah - val))
if acc >= 0 and acc + 1 < e:
seg = slice(acc, e)
lit.append(bool((l[seg] <= far).any() if from_above else (h[seg] >= far).any()))
for tag, label in (('acc', 'after acceptance'), ('re', 'on bare re-entry')):
hits = np.array(res[tag][0], bool); bench = np.array(res[tag][1])
if len(hits) < 30:
print(f" {sym}: only {len(hits)} qualifying sessions ({label})")
continue
var = (bench * (1 - bench)).sum()
z = (hits.sum() - bench.sum()) / max(np.sqrt(var), 1e-9)
print(f" {sym:>7} {label:<18} n={len(hits):>5} traverse {100*hits.mean():6.2f}% "
f"martingale {100*bench.mean():6.2f}% "
f"diff {100*(hits.mean()-bench.mean()):+6.2f}pp z {z:+6.2f}")
if lit:
print(f" {sym:>7} {'literal (no stop)':<18} n={len(lit):>5} far edge touched at "
f"some point in the session: {100*np.mean(lit):5.2f}% "
f"-- no stop, so no benchmark and no money in it")
return res
if __name__ == '__main__':
which = sys.argv[1] if len(sys.argv) > 1 else 'all'
syms = [s for s in sys.argv[2:] if s in SYMS] or list(SYMS)
KINDS = ('naked', 'vpoc', 'vaedge', 'hvn', 'lvn')
#--- collect() re-decodes the bars and rebuilds every level, ~45 s a time. Do it once
#--- per (symbol, kind) and hand the same event set to both tests.
PRE = {}
for sym in syms:
for kind in KINDS:
try:
PRE[(sym, kind)] = collect(sym, kind)
except FileNotFoundError:
pass
def family(title, fn):
print(title)
acc = []
for sym in syms:
for kind in KINDS:
p = PRE.get((sym, kind))
if p is None:
continue
r = fn(sym, kind, pre=p)
if r:
acc.append(np.abs(r[5]))
if acc:
m = min(len(x) for x in acc)
crit = float(np.quantile(np.maximum.reduce([x[:m] for x in acc]), 0.95))
print(f" family-wise |z| bar over {len(acc)} tests: {crit:.2f}")
if which in ('all', 'magnet'):
family("\n=== 1. MAGNET: is a real level reached more often than a "
"distance-matched placebo? (M5, H=288 bars = 1 session) ===", magnet_test)
if which in ('all', 'reaction'):
family("\n=== 2. REACTION: conditional on touching it, does price reject the "
"level? (barriers +/-0.30 ATR, K=48 bars) ===", reaction_test)
if which in ('all', 'eighty'):
print("\n=== 3. THE 80% RULE: open outside value, accept back inside, "
"traverse to the far edge? ===")
for sym in syms:
eighty_rule(sym)