172 lines
7.4 KiB
Python
172 lines
7.4 KiB
Python
|
|
"""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}")
|