Warrior_EA/research/test_liquidity.py
AnimateDread 9ca0ffcc5c research: stop-run/liquidity sweep - the first setup to survive everything
Osler's currency-order-flow work is the published mechanism: stop-loss orders
cluster just beyond recent swing extremes and cascade price; take-profits cluster
and reverse it. So the claim is not "a pattern repeats" but "there is a reservoir
of forced orders at a location computable in advance" - which is falsifiable in a
way chart patterns are not.

Tested as a COMPLETE trade rather than a signal with barriers bolted on: entry,
stop and target all come from one structure. Price takes out the N-bar extreme
MARGINALLY (<= over*ATR), closes back inside, enter the opposite way next open,
stop just beyond the sweep extreme (where the liquidity actually was), target a
multiple of that risk.

19 of 24 configs clear a family-wise max-statistic bar on EURUSD/USDJPY H1, all
in the predicted direction, z to +6.56. R=1 configs are negative and R=2/R=3 turn
positive, which is coherent: the edge is directional and a tight stop pays the
spread as a large fraction of risk, so it needs a big R to clear.

SPLIT-HALF then kills most of it, as it should:
  EURUSD N=50 ov=0.5 R=3  +0.013 / +0.042  HOLDS
  EURUSD N=20 ov=0.5 R=3  +0.014 / +0.034  HOLDS
  every R=2 config        one half negative
  USDJPY                  one half negative
Surviving configs are STRONGER in the second half, the opposite of a mined
artifact decaying out of sample. But N=20 and N=50 overlap heavily and are not
independent, so this is one instrument and one R - a lead, not a system.

dukas.py: direct Dukascopy datafeed client. SQX mirrors through its own CDN
(CdnCache/CdnDownloadJob) so there is nothing reusable there. Dukascopy publishes
the raw feed - bi5, raw LZMA, 20-byte big-endian records, ZERO-BASED MONTH in the
URL (fails silently into the wrong month otherwise). Cached, resumable, bounded
concurrency.

Two corrections it forced, per the user: SQX conforms Dukascopy data to the5ers'
broker profile AND timestamps. Measured empirically, broker time = UTC+2 (EET),
clean minimum. So (1) previously reported "hours" are BROKER time - gold's hour 1
is 23:00 UTC, the daily rollover and COMEX Globex reopen, a real mechanism; and
(2) Dukascopy's raw 0.2-pip ECN spread must NOT be used for cost - the5ers' ~0.47
is what is actually paid, so the existing cost analysis was right and Dukascopy
would have made every result look falsely tradeable. Its value is the bid/ask
VOLUMES, which SQX lacks entirely - true signed flow instead of the event-count
proxy.

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

202 lines
9.2 KiB
Python

"""The stop-run / liquidity-sweep hypothesis, tested as a COMPLETE trade.
Every earlier test in this project asked "does X predict direction" with barriers bolted on
afterwards at a fixed ATR. That is not how a trade is specified. Here entry, stop and target
come from ONE structure, which is also what makes the hypothesis falsifiable:
Retail stops sit in a KNOWN place - just beyond the recent swing high/low. Carol Osler's
work on currency order flow is the published version of this: stop-loss orders cluster,
and their execution cascades price; take-profit orders cluster too and reverse it. So the
claim is not "a pattern repeats" but "there is a reservoir of forced orders at a location
we can compute in advance".
The setup, therefore:
- price takes out the N-bar extreme (the stops fire, price spikes)
- the break is MARGINAL, not a real breakout (overshoot <= max_over ATR)
- price closes back INSIDE the range (the spike was absorbed, not continuation)
- enter the OPPOSITE way on the next open
- stop goes just beyond the sweep extreme - i.e. where the liquidity actually was, not
at an arbitrary ATR multiple. If the level does not hold, the premise is wrong and the
trade should die immediately, which is what makes the stop tight and the R large.
- target is a multiple of that stop distance
WHAT WOULD MAKE THIS FAKE, and is therefore controlled for:
- Selection: sweeps happen more in volatile regimes. The null permutes DIRECTION across
the same firing bars, so regime is held fixed and only the directional claim is tested.
- Cost: per-bar spread charged on entry and on both barriers.
- Multiple testing: many (N, overshoot, R) combinations, so a family-wise max-statistic
bar over the whole grid, and split-half on anything that clears it.
- The obvious trap: requiring "closes back inside" uses bar i's CLOSE, so entry must be at
bar i+1's open. Using bar i's close as the entry price would be lookahead.
"""
import numpy as np, sys, os, datetime as dt
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
BARS = 'c:/Users/admin/Documents/Workspaces/Market Data/bars/'
def load(sym, tf):
z = np.load(f"{BARS}{sym}_{tf}_ticks.npz", allow_pickle=True)
a = z['bars']
cols = [str(c) for c in z['columns']]
return a, {c: k for k, c in enumerate(cols)}
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 rolling_extreme(x, N, kind='max'):
"""Extreme of the N bars ENDING AT i-1 (never includes bar i itself)."""
n = len(x)
out = np.full(n, np.nan)
f = np.maximum if kind == 'max' else np.minimum
#--- simple O(n*log) via strides is overkill; N is small and n <= 1.7M
from numpy.lib.stride_tricks import sliding_window_view
if n > N:
w = sliding_window_view(x, N)
agg = w.max(axis=1) if kind == 'max' else w.min(axis=1)
out[N:] = agg[:-1]
return out
def outcomes(o, h, l, entry_i, direction, stop_px, targ_px, H):
"""Walk each trade forward bar by bar. Trades have INDIVIDUAL stop/target prices here,
so the vectorised block scan used elsewhere does not apply. Stop is checked before
target within a bar (a bar spanning both books the loss)."""
win = np.zeros(len(entry_i), bool)
tout = np.zeros(len(entry_i), bool)
for k in range(len(entry_i)):
e = entry_i[k]
hi = min(e + H, len(o) - 1)
d = direction[k]
w = False; done = False
for j in range(e, hi + 1):
if d > 0:
if l[j] <= stop_px[k]:
done = True; break
if h[j] >= targ_px[k]:
w = True; done = True; break
else:
if h[j] >= stop_px[k]:
done = True; break
if l[j] <= targ_px[k]:
w = True; done = True; break
win[k] = w
tout[k] = not done
return win, tout
def run(sym, tf='H1', Ns=(20, 50), overs=(0.25, 0.5), Rs=(1.0, 2.0, 3.0),
H=48, nperm=2000, seed=5, verbose=True):
a, I = load(sym, tf)
g = lambda c: a[:, I[c]]
o, h, l, c = g('open'), g('high'), g('low'), g('close')
spb = g('spread_mean'); spb = np.concatenate([[spb[0]], spb[:-1]])
atr = atr_of(h, l, c, 14)
atr = np.concatenate([[atr[0]], atr[:-1]])
n = len(c)
t = g('time')
hrs = np.array([dt.datetime.fromtimestamp(x / 1000, dt.UTC).hour for x in t])
rng = np.random.default_rng(seed)
rows, acc = [], []
for N in Ns:
ph = rolling_extreme(h, N, 'max')
pl = rolling_extreme(l, N, 'min')
for ov in overs:
#--- SHORT setup: swept the prior high marginally, closed back below it
sw_hi = (h > ph) & ((h - ph) <= ov * atr) & (c < ph) & np.isfinite(ph)
#--- LONG setup: swept the prior low marginally, closed back above it
sw_lo = (l < pl) & ((pl - l) <= ov * atr) & (c > pl) & np.isfinite(pl)
fire = np.nonzero(sw_hi | sw_lo)[0]
fire = fire[(fire > N + 2) & (fire < n - H - 2)]
if len(fire) < 150:
continue
d = np.where(sw_hi[fire], -1, 1)
e = fire + 1
#--- stop just beyond the sweep extreme: that IS the level being tested
buf = 0.05 * atr[fire]
stop = np.where(d < 0, h[fire] + buf, l[fire] - buf)
entry = o[e]
risk = np.abs(entry - stop) + spb[e]
ok = risk > 0
for R in Rs:
targ = np.where(d < 0, entry - R * risk, entry + R * risk)
#--- sequential: no overlapping trades
keep, busy = [], -1
for k in range(len(e)):
if not ok[k] or e[k] <= busy:
continue
keep.append(k); busy = e[k] + H
keep = np.array(keep, int)
if len(keep) < 100:
continue
w, to = outcomes(o, h, l, e[keep], d[keep],
np.where(d[keep] < 0, stop[keep] + spb[e[keep]],
stop[keep] - spb[e[keep]]),
targ[keep], H)
nT = len(keep); wr = w.mean()
expR = wr * R - (1 - wr)
be = 1.0 / (1.0 + R)
#--- null: same bars, permuted directions (regime held fixed)
wl, ws = None, None
dd = d[keep]
#--- recompute both-direction outcomes once for the null
stop_L = np.where(True, l[fire[keep]] - buf[keep], 0) - spb[e[keep]]
stop_S = h[fire[keep]] + buf[keep] + spb[e[keep]]
entL = o[e[keep]]; riskL = np.abs(entL - stop_L) + spb[e[keep]]
riskS = np.abs(entL - stop_S) + spb[e[keep]]
wL, _ = outcomes(o, h, l, e[keep], np.ones(len(keep), int),
stop_L, entL + R * riskL, H)
wS, _ = outcomes(o, h, l, e[keep], -np.ones(len(keep), int),
stop_S, entL - R * riskS, H)
long_ = dd > 0
pw = np.empty(nperm)
B = max(1, 2000000 // max(nT, 1))
for b0 in range(0, nperm, B):
b1 = min(b0 + B, nperm)
pl_ = rng.permuted(np.broadcast_to(long_, (b1 - b0, nT)), axis=1)
pw[b0:b1] = np.where(pl_, wL, wS).mean(axis=1)
nmu, nsd = pw.mean(), max(pw.std(ddof=1), 1e-12)
z = (wr - nmu) / nsd
rows.append((N, ov, R, nT, 100 * wr, 100 * nmu, z, expR, 100 * to.mean(),
keep, w, dd))
acc.append(np.abs((pw - nmu) / nsd))
if not rows:
print(f"{sym} {tf}: no setup fired often enough")
return []
crit = float(np.quantile(np.maximum.reduce(acc), 0.95))
if verbose:
print(f"\n=== {sym} {tf} liquidity sweep H={H} {len(rows)} configs "
f"family-wise |z| > {crit:.2f} ===")
print(f"{'N':>4}{'over':>6}{'R':>5}{'trades':>8}{'win%':>7}{'null%':>7}{'z':>7}"
f"{'expR':>8}{'t/o%':>7}")
for r in sorted(rows, key=lambda x: -x[6])[:10]:
star = ' *' if abs(r[6]) > crit else ''
print(f"{r[0]:>4}{r[1]:>6.2f}{r[2]:>5.1f}{r[3]:>8}{r[4]:>7.2f}{r[5]:>7.2f}"
f"{r[6]:>+7.2f}{r[7]:>+8.3f}{r[8]:>7.1f}{star}")
return [(r, crit) for r in rows if abs(r[6]) > crit]
if __name__ == '__main__':
syms = [a for a in sys.argv[1:] if not a.startswith('-')] or \
['EURUSD', 'USDJPY', 'XAUUSD', 'SP500']
tf = 'H1'
for a_ in sys.argv[1:]:
if a_.startswith('--tf='):
tf = a_.split('=', 1)[1]
survivors = []
for s in syms:
if os.path.exists(f"{BARS}{s}_{tf}_ticks.npz"):
survivors += [(s,) + x for x in run(s, tf)]
print(f"\n{'='*70}\nconfigs clearing their family-wise bar: {len(survivors)}")
for s in survivors:
r = s[1]
print(f" {s[0]:>7} N={r[0]} over={r[1]} R={r[2]} {r[3]} trades "
f"win {r[4]:.2f}% vs null {r[5]:.2f}% z {r[6]:+.2f} expR {r[7]:+.3f}")