Warrior_EA/research/wyckoff.py

172 lines
7.4 KiB
Python
Raw Permalink Normal View History

"""Range structure, the five-trace context score, and the retest as a CONTINUUM of depths.
Two findings set this up:
the CONTEXT score is real +0.045 R per agreeing trace, replicated on two triggers
the retest level matters, and price edge > value-area edge > VPOC, monotone in 8/8
it matters in the book's - the deeper the level you wait at, the more your fills
opposite direction are breakouts that have already failed (adverse selection)
That second result was measured at three discrete locations. Since the ordering was monotone
at all three, the interesting question is not "which of the three" but "what does the curve
do if you keep going" - and in particular whether it crosses zero on the SHALLOW side, which
is where a base near zero would have to live for the context modifier to be worth bolting on.
THE DESIGN
----------
After a breakout, place a BUY LIMIT (long case) at
level = broken_edge + u * ATR u > 0 shallow, never reaching the old edge
u = 0 the price edge itself
u < 0 deep, back inside the old range
and sweep u. The depth is a level YOU CHOOSE when the order is placed, not a property of
what price went on to do, so there is no selection bias in the x-axis itself. Everything -
level, stop, target - is fixed at placement time from bars already closed.
Orders that would fill instantly are DROPPED, not filled at market: a buy limit already
above the ask is not a retest, and letting `fills.py` cap it at the open would quietly mix
market entries into a test about waiting.
THE CONTROL
-----------
The same orders at the same DISTANCE from the current price, but with that distance permuted
across events - geometry preserved exactly, the identity of the level destroyed. Without it,
"buy pullbacks" and "buy pullbacks TO THIS LEVEL" are indistinguishable, and the first is
just drift.
"""
import numpy as np, sys
import fills, book
from fills import LIMIT, MARKET
SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500')
def traces(f, s, i, top, bot, dd, htf):
"""The five traces of book 2 2.3 / 7.1. +1 each if it agrees with direction dd.
Reads only bars in [s, i], all closed before the order is placed.
"""
h, l, c, vol = f.h, f.l, f.c, f.v
mid = 0.5 * (top + bot)
Lq = i - s
th = max(Lq // 3, 2)
segA, segB, segC = slice(s, s + th), slice(s + th, s + 2 * th), slice(s + 2 * th, i)
t1 = 1 if (h[segA].max() - mid) > (mid - l[segA].min()) else -1
t2 = 1 if (h[segB].max() - mid) > (mid - l[segB].min()) else -1
t3 = 0
if segC.stop > segC.start:
if t2 > 0:
t3 = 1 if l[segC].min() > bot + 0.25 * (top - bot) else -1
else:
t3 = -1 if h[segC].max() < top - 0.25 * (top - bot) else 1
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
t5 = 1 if htf == dd else -1
return sum(1 for x in (t1 * dd, t2 * dd, t3 * dd, t4, t5) if x > 0)
def breakouts(f, theta=0.60, htf=200, score=True):
"""Range breakouts with their structure, one row per event.
-> dict of arrays: i (breakout bar), s (range start), d, top, bot, atr, ag (trace count)
"""
atr = f.atr(14)
L, hi_, lo_ = book.find_ranges(f.h, f.l, atr, theta=theta)
up = (L > 0) & (f.c > hi_)
dn = (L > 0) & (f.c < lo_)
fire = np.nonzero(up | dn)[0]
fire = fire[(fire > max(300, htf + 5)) & (fire < f.n - 400)]
if not len(fire):
return None
d = np.where(up[fire], 1, -1)
s = fire - L[fire]
ok = s >= 1
fire, d, s = fire[ok], d[ok], s[ok]
htf_sig = np.sign(f.c[fire] - f.c[fire - htf]).astype(int)
ag = np.zeros(len(fire), np.int8)
if score:
for q in range(len(fire)):
ag[q] = traces(f, int(s[q]), int(fire[q]), hi_[fire[q]], lo_[fire[q]],
int(d[q]), int(htf_sig[q]))
return dict(i=fire, s=s, d=d, top=hi_[fire], bot=lo_[fire], atr=atr[fire],
L=L[fire], ag=ag)
def retest(sym, tf, phi, mrisk=1.0, kR=2.0, wait=40, H=200, theta=0.60,
bk=None, f=None, ev=None, placebo=0):
"""One depth arm, parameterised by RETRACE FRACTION rather than distance in ATR.
level = price_now - phi * (price_now - broken_edge)
phi -> 0 at market, no pullback demanded
phi = 1 the price edge itself - the classic Last Point of Support
phi > 1 through the edge, into the old range: value-area and VPOC territory
Why not "edge + u*ATR": that version only lets an order exist when the breakout has
already extended past u*ATR, so the shallow arms were quietly a strong-breakout filter
and the curve mixed depth with extension. As a fraction of the CURRENT distance to the
edge, every event qualifies at every phi and the arms are the same sample throughout.
stop = entry - d * mrisk * ATR fixed multiple, known at placement
target= entry + d * kR * risk
"""
bk = bk or fills.Book(sym)
f = f or book.frame(sym, tf, bk)
ev = ev or breakouts(f, theta=theta)
if ev is None:
return None
step = book.TF_SEC[tf] // 60
i, d, atr = ev['i'], ev['d'], ev['atr']
edge = np.where(d > 0, ev['top'], ev['bot'])
#--- the order is placed after bar i closes and is live from bar i+1
e = i + 1
start = f.i0[e]
ask0, bid0 = bk.ao[start], bk.bo[start]
here = np.where(d > 0, ask0, bid0)
#--- extension of the breakout beyond the edge, at the moment the order is placed
ext = (here - edge) * d
lvl = here - d * phi * ext
dist = phi * ext
if placebo:
#--- same distance from the same starting price, level identity destroyed
rng = np.random.default_rng(placebo)
dist = dist[rng.permutation(len(dist))]
lvl = here - d * dist
#--- a buy limit must sit strictly BELOW the ask (a sell limit above the bid), else it
#--- would fill instantly at the open and a market entry would be mixed into a test
#--- about waiting
live = (dist > 0) & np.where(d > 0, lvl < ask0, lvl > bid0)
keep = live & np.isfinite(lvl) & (atr > 0)
if keep.sum() < 100:
return None
idx = np.nonzero(keep)[0]
risk = mrisk * atr[idx]
stop = lvl[idx] - d[idx] * risk
targ = lvl[idx] + d[idx] * kR * risk
out = fills.simulate(bk, start[idx], d[idx], stop, targ, H * step,
entry=LIMIT, entry_px=lvl[idx], entry_window=wait * step)
if out is None:
return None
sel = idx[np.nonzero(out['filled'])[0][out['kept']]]
out['event'] = sel
out['ag'] = ev['ag'][sel]
out['start'] = start[sel]
#--- overlapping trades share price path; the honest n is the independent one
out['indep'] = book.nonoverlap(out['idx'], out['exit_idx'] - out['idx'])
out['placed'] = int(keep.sum())
return out
def line(tag, out, extra=''):
if out is None or out['n'] < 60:
return f" {tag:<26} - too few"
R = out['R']; ind = out['indep']
Ri = R[ind]
return (f" {tag:<26} n={out['n']:>6} ({int(ind.sum()):>5} ind)"
f" expR {R.mean():+7.4f} t {book.tstat(R):+6.2f}"
f" ind {Ri.mean():+7.4f} t {book.tstat(Ri):+6.2f}"
f" fill {100*out['n']/max(out['placed'],1):5.1f}%"
f" amb {100*out['ambiguous']:4.1f}% unres {100*out['unresolved']:4.1f}%{extra}")