Warrior_EA/research/test_spring.py
AnimateDread 9a4ae635e0 research: the two canonical Wyckoff trades tested whole - both negative
Completes the programme on both books. Entries are MARKET ORDERS at a bar's
open throughout, so the fill-timing artifact that invalidated the last round is
designed out rather than remembered. Benchmark is analytic: entry, stop and
target fixed at entry means a driftless market gives expR = 0 exactly.

1. SPRING / UPTHRUST (book 1 ch.18, the event 'all Wyckoff operators wait for').
   Pierce of a COMPRESSION-QUALIFIED range edge, close back inside, stop beyond
   the shakeout extreme, target the far side of the range.

   30 cells across 4 symbols x M15/H1/H4. Reward-to-risk averages 4-6:1, so the
   break-even win rate is only 15-20%, and it still loses nearly everywhere:
   M15 all four symbols -0.15 to -0.24 with 0/4 folds positive. Best cell is
   EURUSD H1 climactic-volume +0.302 at t +2.27, which over 30 cells is inside
   the family-wise band.

   The books' volume requirement was applied - climactic (>1.5x range average)
   and quiet (<0.8x) shakeouts scored separately. Neither rescues it.

2. LPS / LPSY, the test-after-breakout, and book 2's A/B (5.7.1, 5.8.3): it
   claims the retest should be awaited at the VOLUME PROFILE level, not the
   price edge. Same breakout, same stop, same 2R target, only the location
   differs:

     retest at            typical expR
     A price edge         -0.041 .. -0.315
     B value-area edge    -0.128 .. -0.413
     C range VPOC         -0.129 .. -0.506

   24/24 cells negative, and A > B > C in ALL EIGHT symbol/timeframe
   combinations. That monotone ordering is not noise, and it inverts the book's
   recommendation. Mechanism is adverse selection: the VPOC sits deep inside the
   old range, so a retest that reaches it is disproportionately a breakout that
   has already failed. The deeper the level you wait at, the more your fills are
   selected against you.

   Practical consequence: the volume profile is real (levels beat distance-matched
   placebos at z +3 to +7.8) but using it to LOCATE ENTRIES makes this trade
   worse, not better.

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

166 lines
7.5 KiB
Python

"""The Spring and the Upthrust - the trade both Wyckoff books are actually built around.
Everything tested so far took a piece of the method (levels, volume nodes, range projection)
in isolation. This tests the ENTRY the books teach, whole, with its own structural stop and
its own structural target:
book 1 ch.18 "the shakeout is the key event that all Wyckoff operators wait for"
book 2 5.7.1 range boundaries are low-volume nodes; the shakeout pierces one and fails
SPRING inside a confirmed trading range, price pierces the RANGE LOW by a small
amount and closes back inside. Demand absorbed the supply. Go long.
UPTHRUST the mirror at the range high. Go short.
stop just beyond the shakeout extreme - if that level fails, the premise is wrong
target the OPPOSITE side of the range (structural, per the books - not an R multiple)
WHY THIS IS NOT THE SWEEP TEST THAT ALREADY FAILED
--------------------------------------------------
[[project_stop_run_liquidity_edge]] faded pierces of a rolling N-bar extreme. Three things
differ here and each is a book requirement that test ignored:
1. the extreme must bound a CONFIRMED CONSOLIDATION (compression-qualified), not just be
the highest of the last N bars - most N-bar extremes are trend, not range
2. the target is the range's far side, so reward scales with the structure that produced it
3. VOLUME confirmation, which no previous test in this project could apply: the books
require effort/result divergence at the shakeout and, crucially, a LOW-VOLUME TEST
afterwards. Tick counts and true volume-at-price are available here.
FILL MODEL - the bug from the last round, designed out
------------------------------------------------------
Every entry is a MARKET ORDER AT THE NEXT BAR'S OPEN. No stop-entry, no limit, no level to
cross. The fill price is that open and the outcome race starts at that same bar, so there is
no window in which price is on the wrong side of the entry. The previous round's edge was
entirely an artifact of entering at a level while measuring from the bar open; it cannot
recur in this form.
NULL - analytic, no simulation
------------------------------
Entry, stop and target are all fixed at entry, so a driftless market gives
P(target first) = risk/(risk+reward) and expected R = 0 EXACTLY, at every geometry. Any
positive expR after cost is the finding. Bars spanning both barriers book the loss; spread
is charged once, round trip.
"""
import numpy as np, sys, datetime as dt
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 springs(sym, tf, theta=0.60, ov=0.75, back=3, path_tf='M5', H=200):
"""Detect springs/upthrusts and price them as complete trades."""
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
#--- average volume inside the range that is being pierced, for the effort filter
W = np.lib.stride_tricks.sliding_window_view
vavg = np.full(n, np.nan)
for Lv in np.unique(L[L > 0]):
m = L == Lv
if m.sum() == 0 or n <= Lv:
continue
av = np.full(n, np.nan)
av[Lv:] = W(vol, Lv).mean(axis=1)[:-1]
vavg[m] = av[m]
ok = (L > 0) & np.isfinite(hi) & np.isfinite(lo) & np.isfinite(vavg) & (vavg > 0)
#--- SPRING: pierced the range low by <= ov*ATR and closed back inside
sp_ = ok & (l < lo) & ((lo - l) <= ov * atr) & (c > lo)
#--- UPTHRUST: mirror
up_ = ok & (h > hi) & ((h - hi) <= ov * atr) & (c < hi)
idx = np.nonzero(sp_ | up_)[0]
idx = idx[(idx > 200) & (idx < n - 5)]
if not len(idx):
return None
d = np.where(sp_[idx], 1, -1)
e = idx + 1 # MARKET ORDER at the next bar's open
pi = np.clip(pmap[e], 0, len(ph) - 1)
keep = (pi + HH < len(ph)) & (e < n)
idx, d, e, pi = idx[keep], d[keep], e[keep], pi[keep]
if len(idx) < 60:
return None
ent = o[e]
sp = spm[e]
ext = np.where(d > 0, l[idx], h[idx]) # the shakeout extreme, already closed
buf = 0.10 * atr[idx]
stop = ext - d * buf
targ = np.where(d > 0, hi[idx], lo[idx]) # structural target: the range's far side
risk = np.abs(ent - stop)
rew = np.abs(targ - ent)
good = (risk > 2 * sp) & (rew > risk * 0.25)
idx, d, e, pi, ent, sp, stop, targ, risk, rew = (
v[good] for v in (idx, d, e, pi, ent, sp, stop, targ, risk, rew))
if len(idx) < 60:
return None
#--- non-overlapping in time, so significance is not manufactured by shared paths
keep2, busy = [], -1
for q in range(len(e)):
if e[q] <= busy:
continue
keep2.append(q); busy = e[q] + L[idx[q]]
keep2 = np.array(keep2, int)
idx, d, e, pi, ent, sp, stop, targ, risk, rew = (
v[keep2] for v in (idx, d, e, pi, ent, sp, stop, targ, risk, rew))
r = race_px(ph, pl, pi, d, stop, targ, HH)
RR = rew / risk
R = np.where(r > 0, RR, np.where(r < 0, -1.0, 0.0))
un = r == 0
if un.any():
q = np.minimum(pi[un] + HH, len(pc) - 1)
R[un] = (pc[q] - ent[un]) * d[un] / risk[un]
R = R - sp / risk
return dict(R=R, d=d, t=t1[e], RR=RR, idx=idx,
vshake=vol[idx] / vavg[idx], L=L[idx], unres=(r == 0).mean())
def report(sym, tf, out, tag):
if out is None:
print(f" {sym:>7} {tf:>4} {tag:<22} - too few events")
return None
R = out['R']
if len(R) < 60:
print(f" {sym:>7} {tf:>4} {tag:<22} n={len(R)} too few")
return None
se = R.std(ddof=1) / np.sqrt(len(R))
f = [float(x.mean()) for x in np.array_split(R, 4)]
pos = sum(1 for x in f if x > 0)
print(f" {sym:>7} {tf:>4} {tag:<22} n={len(R):>5} R:R {out['RR'].mean():>4.1f}"
f" expR {R.mean():+7.3f} t {R.mean()/max(se,1e-12):+6.2f}"
f" folds " + "".join(f"{x:+6.2f}" for x in f) + f" {pos}/4")
return R.mean(), R.mean() / max(se, 1e-12), pos, len(R)
if __name__ == '__main__':
syms = [s for s in sys.argv[1:] if s in SYMS] or list(SYMS)
print("=== SPRING / UPTHRUST: the shakeout trade, entered at the next bar's OPEN ===")
print(" stop beyond the shakeout extreme, target the far side of the range.")
print(" Driftless benchmark is expR = 0 exactly, at every geometry.\n")
for tf in ('M15', 'H1', 'H4'):
for s in syms:
out = springs(s, tf)
report(s, tf, out, 'all shakeouts')
if out is None:
continue
#--- the books' volume requirement: effort at the shakeout
for lo_, hi_, lbl in ((1.5, 99., 'climactic vol >1.5x'),
(0.0, 0.8, 'quiet vol <0.8x')):
m = (out['vshake'] >= lo_) & (out['vshake'] < hi_)
if m.sum() >= 60:
sub = {k: (v[m] if isinstance(v, np.ndarray) and len(v) == len(m) else v)
for k, v in out.items()}
report(s, tf, sub, lbl)