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>
166 lines
7.5 KiB
Python
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)
|