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>
202 lines
9.2 KiB
Python
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}")
|