resample.py composes M5 bars into M15/H1/H4 exactly - every column this pipeline produces is composable (sums sum, maxes max, OHLC nests, means re-weight by tick count), so this costs seconds instead of another 37-minute decode per timeframe. Asserts tick conservation and extreme preservation on every output. Motivation: ATR grows ~sqrt(time) while the spread does not, so spread/ATR should fall with timeframe and make a small edge affordable. It does, monotonically, and the measurement is clean (EURUSD, 1:1 barriers): M5 spread 0.099 ATR random wins 36.8% cost 13.2pp M15 0.057 39.7% 10.3pp H1 0.029 43.6% 6.4pp H4 0.015 47.6% 2.4pp But the signal decays at the same rate. Rows clearing the family-wise bar: M5 many, z to -10.1 M15 many, z to -5.8 H1 2 of 9, one POSITIVE and one negative - the shape of noise, not signal H4 none So the effect lives where the cost is fatal and is gone where the cost is affordable. They never cross. Also added --cheap=Q, which trades only the lowest-Q quantile of spread/ATR. This is the one honest use of an unsigned feature: it cannot point a direction but it can decline to trade, and both terms are known before entry. It does cut cost (EURUSD M15 10.3 -> 7.6pp, USDJPY 12.7 -> 6.4pp) and the effect does not survive there either - nothing clears the bar. test_flow.py gained --tf= and keeps the z-score window at ~1 day on every timeframe rather than a fixed 288 bars. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
266 lines
12 KiB
Python
266 lines
12 KiB
Python
"""Does tick-derived order flow predict the NEXT bars, or only explain its own?
|
|
|
|
This is the test the whole tick pipeline exists for, and it is the first configuration in
|
|
this project with enough independent trades to settle the question rather than fail to
|
|
reject it. H1 with a 128-bar horizon gives ~300 independent trades in 18 years - enough to
|
|
resolve a +6pp edge when real edges are 1-3pp. M5 with a 12-48 bar horizon over 23 years
|
|
gives tens of thousands, which resolves ~1pp.
|
|
|
|
THE PRIOR, STATED BEFORE LOOKING
|
|
--------------------------------
|
|
Order-flow imbalance is well established as a CONTEMPORANEOUS explainer of price change
|
|
(Cont/Kukanov/Stoikov), and its predictive power is reported to decay within seconds. On
|
|
the build sample OFI correlated +0.56 with the SAME-bar return, reproducing that. So the
|
|
honest expectation is that it explains the bar it is measured in and says nothing about the
|
|
next one, and the contemporaneous correlation must never be quoted as evidence of edge.
|
|
|
|
What is being tested is the gap between that literature (equities, sub-second, size-weighted
|
|
book data) and this setting (retail FX CFD feed, 5-minute bars, event-count OFI without
|
|
sizes). That gap is worth one honest measurement.
|
|
|
|
DISCIPLINE
|
|
----------
|
|
Signals are computed on bar i and entered at the OPEN of bar i+1. Barriers are scanned from
|
|
the entry bar forward only. Trades are sequential and NON-OVERLAPPING, so each is
|
|
independent and the confidence interval means something - the pseudo-replication that
|
|
produced a fake +2.66pp at 2.9 sigma earlier in this project came from exactly this being
|
|
skipped. Break-even == chance by the gambler's-ruin identity, so "beats a coin" and "makes
|
|
money" are one question, and the spread is charged inside every barrier.
|
|
"""
|
|
import numpy as np, sys, os, time
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
|
|
BARS = 'c:/Users/admin/Documents/Workspaces/Market Data/bars/'
|
|
|
|
|
|
#--- bars per day, used to keep the trailing z-score window at ~1 day on every timeframe
|
|
PER_DAY = {'M5': 288, 'M15': 96, 'H1': 24, 'H4': 6}
|
|
|
|
|
|
def load(sym, tf='M5'):
|
|
z = np.load(f"{BARS}{sym}_{tf}_ticks.npz", allow_pickle=True)
|
|
arr = z['bars']
|
|
cols = [str(c) for c in z['columns']]
|
|
return arr, {c: k for k, c in enumerate(cols)}
|
|
|
|
|
|
def atr_bars(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 zscore(x, n=288):
|
|
"""Trailing z-score over n bars (288 M5 bars = one day), causal and shifted by one so a
|
|
bar never contributes to its own baseline."""
|
|
x = np.asarray(x, dtype=float)
|
|
c1 = np.concatenate([[0.0], np.cumsum(x)])
|
|
c2 = np.concatenate([[0.0], np.cumsum(x * x)])
|
|
out = np.zeros(len(x))
|
|
for i in range(n + 1, len(x)):
|
|
s = c1[i] - c1[i - n]
|
|
s2 = c2[i] - c2[i - n]
|
|
mu = s / n
|
|
var = max(s2 / n - mu * mu, 1e-18)
|
|
out[i] = (x[i] - mu) / np.sqrt(var)
|
|
return np.clip(out, -8, 8)
|
|
|
|
|
|
def build_signals(a, I, zwin=288):
|
|
g = lambda c: a[:, I[c]]
|
|
up, dn = g('upticks'), g('downticks')
|
|
bu, bd, au, ad = g('bid_up'), g('bid_dn'), g('ask_up'), g('ask_dn')
|
|
tot = np.maximum(up + dn, 1.0)
|
|
# --- the two SIGNED candidates. Everything else this pipeline produces is unsigned and
|
|
# cannot point a trade, however well it measures.
|
|
tick_imb = (up - dn) / tot
|
|
ofi_raw = (bu + au) - (bd + ad)
|
|
ofi = ofi_raw / np.maximum(bu + bd + au + ad, 1.0)
|
|
sig = {
|
|
'tick_imbalance': tick_imb,
|
|
'ofi': ofi,
|
|
'ofi_z': zscore(ofi_raw, zwin),
|
|
'tick_imb_z': zscore(tick_imb, zwin),
|
|
}
|
|
return sig
|
|
|
|
|
|
def barrier_outcomes(o, h, l, a_sig, sl_m, tp_m, H, sp):
|
|
"""Entry at the OPEN of bar i, both directions. Stop tested before target within a bar,
|
|
so a bar spanning both books the loss.
|
|
|
|
`sp` is PER BAR, not a global median: this data spans 2003-2026 and FX spreads narrowed
|
|
by roughly an order of magnitude over it. A single median charges the modern cost to the
|
|
2000s and the 2000s cost to today, which flatters exactly the era with the most bars.
|
|
|
|
Timeouts (neither barrier touched within H) are returned separately rather than folded
|
|
silently into the loss column - a timeout is not a stop-out, and if they are common the
|
|
quoted break-even no longer describes the experiment."""
|
|
n = len(o)
|
|
INF = np.iinfo(np.int32).max
|
|
winL = np.zeros(n, bool); winS = np.zeros(n, bool)
|
|
toL = np.zeros(n, bool); toS = np.zeros(n, bool)
|
|
risk, rew = sl_m * a_sig, tp_m * a_sig
|
|
lTp, lSl = o + rew + sp, o - risk + sp
|
|
sTp, sSl = o - rew - sp, o + risk - sp
|
|
CH = max(4000000 // max(H, 1), 1)
|
|
for s in range(0, n, CH):
|
|
e2 = min(s + CH, n - H)
|
|
if e2 <= s:
|
|
break
|
|
wi = np.arange(0, H)[None, :] + np.arange(s, e2)[:, None]
|
|
wh, wl = h[wi], l[wi]
|
|
|
|
def first(mask):
|
|
any_ = mask.any(axis=1)
|
|
return np.where(any_, mask.argmax(axis=1), INF)
|
|
lsl = first(wl <= lSl[s:e2, None]); ltp = first(wh >= lTp[s:e2, None])
|
|
ssl = first(wh >= sSl[s:e2, None]); stp = first(wl <= sTp[s:e2, None])
|
|
winL[s:e2] = ltp < lsl
|
|
winS[s:e2] = stp < ssl
|
|
toL[s:e2] = (ltp == INF) & (lsl == INF)
|
|
toS[s:e2] = (stp == INF) & (ssl == INF)
|
|
return winL, winS, toL, toS
|
|
|
|
|
|
def sequential(fire, dirs, winL, winS, H, n):
|
|
out_i, out_w, out_d = [], [], []
|
|
busy = -1
|
|
for j, d in zip(fire, dirs):
|
|
if j <= busy or j + 1 + H >= n:
|
|
continue
|
|
e = j + 1
|
|
out_i.append(e)
|
|
out_w.append(bool(winL[e] if d > 0 else winS[e]))
|
|
out_d.append(int(d))
|
|
busy = e + H
|
|
return np.array(out_i, int), np.array(out_w, bool), np.array(out_d, int)
|
|
|
|
|
|
def run(sym, sl_m, tp_m, H, thresholds=(0.5, 1.0, 1.5, 2.0), nperm=2000, seed=3, tf='M5',
|
|
cost_q=None):
|
|
a, I = load(sym, tf)
|
|
g = lambda c: a[:, I[c]]
|
|
o, h, l, c = g('open'), g('high'), g('low'), g('close')
|
|
n = len(c)
|
|
atr = atr_bars(h, l, c, 14)
|
|
a_sig = np.concatenate([[atr[0]], atr[:-1]])
|
|
#--- per-bar spread, shifted one bar so entry cost is known before entering
|
|
spb = g('spread_mean')
|
|
spb = np.concatenate([[spb[0]], spb[:-1]])
|
|
winL, winS, toL, toS = barrier_outcomes(o, h, l, a_sig, sl_m, tp_m, H, spb)
|
|
be = sl_m / (sl_m + tp_m)
|
|
sigs = build_signals(a, I, PER_DAY.get(tf, 288))
|
|
rng = np.random.default_rng(seed)
|
|
|
|
#--- COST CONDITIONING. spread/ATR varies by an order of magnitude within a timeframe
|
|
#--- (thin Asian hours and news spikes vs the London/NY overlap), so the average cost
|
|
#--- is not the cost you must pay - you can choose to trade only the cheap bars. This
|
|
#--- is the one honest use of an UNSIGNED feature: it cannot point a direction, but it
|
|
#--- can decline to trade. Both values are known at the entry decision (shifted one
|
|
#--- bar), so this is a filter, not hindsight.
|
|
cheap = None
|
|
if cost_q is not None:
|
|
ratio = spb / np.maximum(a_sig, 1e-12)
|
|
thr_c = np.nanquantile(ratio, cost_q)
|
|
cheap = ratio <= thr_c
|
|
print(f" [cost filter] spread/ATR <= {thr_c:.4f} (lowest {100*cost_q:.0f}%), "
|
|
f"{cheap.mean():.1%} of bars eligible")
|
|
|
|
print(f"\n=== {sym} {tf} SL{sl_m}:TP{tp_m} H={H} bars n={n:,} "
|
|
f"spread {np.nanmedian(spb):.6f} ({np.nanmedian(spb)/np.nanmedian(atr):.3f} ATR)"
|
|
f" break-even {100*be:.2f}% ===")
|
|
print(f"{'signal':<16}{'thr':>6}{'trades':>9}{'win%':>8}{'null%':>8}{'vs null':>9}"
|
|
f"{'z':>7}{'exp R':>8}{'expR rev':>10}{'t/o%':>7}")
|
|
acc = []
|
|
rows = []
|
|
for name, s in sigs.items():
|
|
scale = 1.0 if name.endswith('_z') else 1.0
|
|
for thr in thresholds:
|
|
t = thr if name.endswith('_z') else thr * 0.25
|
|
m = np.abs(s) >= t
|
|
m[:PER_DAY.get(tf, 288) + 12] = False # z-score burn-in
|
|
if cheap is not None:
|
|
m &= cheap
|
|
fire = np.nonzero(m)[0]
|
|
if len(fire) < 50:
|
|
continue
|
|
d = np.sign(s[fire]).astype(int)
|
|
ti, tw, td = sequential(fire, d, winL, winS, H, n)
|
|
if len(ti) < 100:
|
|
continue
|
|
wr = tw.mean(); nT = len(ti)
|
|
to = np.where(td > 0, toL[ti], toS[ti]).mean()
|
|
#--- the REVERSED rule on the same bars: the money question if the signal turns
|
|
#--- out to be anti-predictive. Not a second hypothesis - it is the same test
|
|
#--- read backwards, so it does not enlarge the family.
|
|
wrev = np.where(td > 0, winS[ti], winL[ti]).mean()
|
|
expR = wr * tp_m - (1 - wr) * sl_m
|
|
expRrev = wrev * tp_m - (1 - wrev) * sl_m
|
|
# NULL: keep the firing bars and the long/short MIX, shuffle which trade gets
|
|
# which direction. A 50/50 coin flip would be the wrong null for a directionally
|
|
# skewed signal on a trending instrument - it would let drift alone look like
|
|
# timing skill. Permuting the observed directions holds the mix fixed and tests
|
|
# only the pairing of direction to bar, which is the actual claim.
|
|
wl_, ws_ = winL[ti], winS[ti]
|
|
long_ = td > 0
|
|
pw = np.empty(nperm)
|
|
# batched: a (nperm x nT) array is ~2 GB at these trade counts
|
|
B = max(1, 4000000 // 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)
|
|
#--- Standardise against the EMPIRICAL null, not the textbook break-even.
|
|
#--- sl/(sl+tp) is the break-even of a costless coin. These barriers charge the
|
|
#--- spread and book a loss when one bar spans both levels, so random entry
|
|
#--- sits WELL below it - about 39.5% where the textbook says 50%. Measuring
|
|
#--- against 50% reports that fixed cost as if it were signal, which produced a
|
|
#--- -75 sigma "result" that was almost entirely the cost of trading.
|
|
nmu, nsd = pw.mean(), max(pw.std(ddof=1), 1e-12)
|
|
z = (wr - nmu) / nsd
|
|
rows.append((f"{name}", t, nT, 100 * wr, 100 * nmu, 100 * (wr - nmu), z,
|
|
expR, expRrev, 100 * to))
|
|
acc.append(np.abs((pw - nmu) / nsd))
|
|
if not rows:
|
|
print(" no signal fired often enough")
|
|
return
|
|
crit = float(np.quantile(np.maximum.reduce(acc), 0.95))
|
|
for r in sorted(rows, key=lambda x: -abs(x[6])):
|
|
star = ' *' if abs(r[6]) > crit else ''
|
|
print(f"{r[0]:<16}{r[1]:>6.2f}{r[2]:>9d}{r[3]:>8.2f}{r[4]:>8.2f}{r[5]:>+9.2f}"
|
|
f"{r[6]:>+7.2f}{r[7]:>+8.3f}{r[8]:>+10.3f}{r[9]:>7.1f}{star}")
|
|
print(f" family-wise 5% bar over {len(rows)} tests: |z| > {crit:.2f} (* = clears it)")
|
|
print(f" NOTE break-even {100*be:.1f}% is the COSTLESS coin; random entry at these "
|
|
f"barriers wins ~{np.mean([r[4] for r in rows]):.1f}% after spread.")
|
|
print(f" A rule only makes money if win% > {100*be:.1f}%, i.e. exp R > 0 - "
|
|
f"beating the null is necessary but NOT sufficient.")
|
|
|
|
|
|
#--- Narrow barriers are where the signal lives and where the spread is fatal: at 1 ATR the
|
|
#--- spread is ~10% of the target and costs 13pp of win rate against a ~1pp effect. Cost
|
|
#--- falls roughly as 1/width, so 8 ATR should cost under 2pp. The honest expectation is
|
|
#--- that the effect decays with horizon faster than the cost does - order flow is a
|
|
#--- seconds-to-minutes phenomenon and these wide barriers run 8 to 24 hours - but that is
|
|
#--- exactly the trade-off worth measuring rather than assuming.
|
|
NARROW = [(1, 1, 12), (1, 2, 24), (2, 3, 48)]
|
|
WIDE = [(4, 4, 96), (4, 8, 192), (8, 8, 288)]
|
|
|
|
if __name__ == '__main__':
|
|
args = [a for a in sys.argv[1:] if not a.startswith('-')]
|
|
geos = WIDE if '--wide' in sys.argv else NARROW if '--narrow' in sys.argv else NARROW + WIDE
|
|
tf, cost_q = 'M5', None
|
|
for a_ in sys.argv[1:]:
|
|
if a_.startswith('--tf='):
|
|
tf = a_.split('=', 1)[1]
|
|
if a_.startswith('--cheap='):
|
|
cost_q = float(a_.split('=', 1)[1])
|
|
syms = args or ['EURUSD']
|
|
for sym in syms:
|
|
if not os.path.exists(f"{BARS}{sym}_{tf}_ticks.npz"):
|
|
print(f"{sym}: {tf} bars not built yet")
|
|
continue
|
|
for (s, p, H) in geos:
|
|
run(sym, s, p, H, tf=tf, cost_q=cost_q)
|