research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
"""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/'
|
|
|
|
|
|
|
|
|
|
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
#--- 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)
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
def build_signals(a, I, zwin=288):
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
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,
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
'ofi_z': zscore(ofi_raw, zwin),
|
|
|
|
|
'tick_imb_z': zscore(tick_imb, zwin),
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
}
|
|
|
|
|
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,
|
perf(research): parallel tick decode, 12x, plus two correctness fixes
The SQX decoder is a per-record Python loop and cannot be vectorised - record
LENGTH depends on the config nibbles, so record k+1's offset is unknowable
without parsing record k. It therefore saturated exactly one core: 10% CPU on a
12-core box, ~3h for the four files.
But the format is randomly seekable. Every BLOCK_LENGTH records SQX restates all
four fields as absolute int64s, so byte ranges beginning at block headers decode
with no shared history. split_offsets() cuts a file on those boundaries and
decode_iter() gained start/stop. EURUSD: 55 min -> 9.4 min, 94% CPU.
find_block() will not trust a bare MAGIC match: 0x00..0x0e is a byte run that
delta payloads produce by coincidence, so a candidate is accepted only when the
next header downstream carries the next sequential block index.
Verified equal, not assumed equal: the same 315MB span decoded serially and in 6
chunks gives identical tick counts (31,056,000), identical bar counts (206,316)
and identical OHLC. The only divergence is the documented seam artifact - the
first tick of a chunk has no predecessor so its delta counts as zero, bounded at
workers-1 ticks in 513M (~2e-8).
Two fixes this shook out:
- The feed is not perfectly time-ordered. EURUSD carries 2 backward steps in
513,494,303 ticks, both under an hour, both in 2003-2006. Bucketing is by
absolute timestamp so every tick still lands in its true bar; the symptom is a
bucket emitted twice out of order. finalise() now stable-sorts before the
duplicate merge. The ordering assert is kept but keyed to MAGNITUDE, since a
real chunking bug displaces a large fraction of rows and feed noise displaces a
handful - only one of those is safe to continue past.
- Chunk workers return undivided sums; means are divided once globally. Dividing
per chunk would weight a straddling bar's mean-of-means wrong.
test_flow.py: charge the PER-BAR spread instead of a single median across
2003-2026 - FX spreads narrowed by roughly an order of magnitude over that span,
so one median charges modern cost to the 2000s and vice versa. Timeouts are now
reported separately rather than silently booked as stop-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:14:38 -04:00
|
|
|
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."""
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
n = len(o)
|
|
|
|
|
INF = np.iinfo(np.int32).max
|
|
|
|
|
winL = np.zeros(n, bool); winS = np.zeros(n, bool)
|
perf(research): parallel tick decode, 12x, plus two correctness fixes
The SQX decoder is a per-record Python loop and cannot be vectorised - record
LENGTH depends on the config nibbles, so record k+1's offset is unknowable
without parsing record k. It therefore saturated exactly one core: 10% CPU on a
12-core box, ~3h for the four files.
But the format is randomly seekable. Every BLOCK_LENGTH records SQX restates all
four fields as absolute int64s, so byte ranges beginning at block headers decode
with no shared history. split_offsets() cuts a file on those boundaries and
decode_iter() gained start/stop. EURUSD: 55 min -> 9.4 min, 94% CPU.
find_block() will not trust a bare MAGIC match: 0x00..0x0e is a byte run that
delta payloads produce by coincidence, so a candidate is accepted only when the
next header downstream carries the next sequential block index.
Verified equal, not assumed equal: the same 315MB span decoded serially and in 6
chunks gives identical tick counts (31,056,000), identical bar counts (206,316)
and identical OHLC. The only divergence is the documented seam artifact - the
first tick of a chunk has no predecessor so its delta counts as zero, bounded at
workers-1 ticks in 513M (~2e-8).
Two fixes this shook out:
- The feed is not perfectly time-ordered. EURUSD carries 2 backward steps in
513,494,303 ticks, both under an hour, both in 2003-2006. Bucketing is by
absolute timestamp so every tick still lands in its true bar; the symptom is a
bucket emitted twice out of order. finalise() now stable-sorts before the
duplicate merge. The ordering assert is kept but keyed to MAGNITUDE, since a
real chunking bug displaces a large fraction of rows and feed noise displaces a
handful - only one of those is safe to continue past.
- Chunk workers return undivided sums; means are divided once globally. Dividing
per chunk would weight a straddling bar's mean-of-means wrong.
test_flow.py: charge the PER-BAR spread instead of a single median across
2003-2026 - FX spreads narrowed by roughly an order of magnitude over that span,
so one median charges modern cost to the 2000s and vice versa. Timeouts are now
reported separately rather than silently booked as stop-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:14:38 -04:00
|
|
|
toL = np.zeros(n, bool); toS = np.zeros(n, bool)
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
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
|
perf(research): parallel tick decode, 12x, plus two correctness fixes
The SQX decoder is a per-record Python loop and cannot be vectorised - record
LENGTH depends on the config nibbles, so record k+1's offset is unknowable
without parsing record k. It therefore saturated exactly one core: 10% CPU on a
12-core box, ~3h for the four files.
But the format is randomly seekable. Every BLOCK_LENGTH records SQX restates all
four fields as absolute int64s, so byte ranges beginning at block headers decode
with no shared history. split_offsets() cuts a file on those boundaries and
decode_iter() gained start/stop. EURUSD: 55 min -> 9.4 min, 94% CPU.
find_block() will not trust a bare MAGIC match: 0x00..0x0e is a byte run that
delta payloads produce by coincidence, so a candidate is accepted only when the
next header downstream carries the next sequential block index.
Verified equal, not assumed equal: the same 315MB span decoded serially and in 6
chunks gives identical tick counts (31,056,000), identical bar counts (206,316)
and identical OHLC. The only divergence is the documented seam artifact - the
first tick of a chunk has no predecessor so its delta counts as zero, bounded at
workers-1 ticks in 513M (~2e-8).
Two fixes this shook out:
- The feed is not perfectly time-ordered. EURUSD carries 2 backward steps in
513,494,303 ticks, both under an hour, both in 2003-2006. Bucketing is by
absolute timestamp so every tick still lands in its true bar; the symptom is a
bucket emitted twice out of order. finalise() now stable-sorts before the
duplicate merge. The ordering assert is kept but keyed to MAGNITUDE, since a
real chunking bug displaces a large fraction of rows and feed noise displaces a
handful - only one of those is safe to continue past.
- Chunk workers return undivided sums; means are divided once globally. Dividing
per chunk would weight a straddling bar's mean-of-means wrong.
test_flow.py: charge the PER-BAR spread instead of a single median across
2003-2026 - FX spreads narrowed by roughly an order of magnitude over that span,
so one median charges modern cost to the 2000s and vice versa. Timeouts are now
reported separately rather than silently booked as stop-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:14:38 -04:00
|
|
|
toL[s:e2] = (ltp == INF) & (lsl == INF)
|
|
|
|
|
toS[s:e2] = (stp == INF) & (ssl == INF)
|
|
|
|
|
return winL, winS, toL, toS
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def sequential(fire, dirs, winL, winS, H, n):
|
perf(research): parallel tick decode, 12x, plus two correctness fixes
The SQX decoder is a per-record Python loop and cannot be vectorised - record
LENGTH depends on the config nibbles, so record k+1's offset is unknowable
without parsing record k. It therefore saturated exactly one core: 10% CPU on a
12-core box, ~3h for the four files.
But the format is randomly seekable. Every BLOCK_LENGTH records SQX restates all
four fields as absolute int64s, so byte ranges beginning at block headers decode
with no shared history. split_offsets() cuts a file on those boundaries and
decode_iter() gained start/stop. EURUSD: 55 min -> 9.4 min, 94% CPU.
find_block() will not trust a bare MAGIC match: 0x00..0x0e is a byte run that
delta payloads produce by coincidence, so a candidate is accepted only when the
next header downstream carries the next sequential block index.
Verified equal, not assumed equal: the same 315MB span decoded serially and in 6
chunks gives identical tick counts (31,056,000), identical bar counts (206,316)
and identical OHLC. The only divergence is the documented seam artifact - the
first tick of a chunk has no predecessor so its delta counts as zero, bounded at
workers-1 ticks in 513M (~2e-8).
Two fixes this shook out:
- The feed is not perfectly time-ordered. EURUSD carries 2 backward steps in
513,494,303 ticks, both under an hour, both in 2003-2006. Bucketing is by
absolute timestamp so every tick still lands in its true bar; the symptom is a
bucket emitted twice out of order. finalise() now stable-sorts before the
duplicate merge. The ordering assert is kept but keyed to MAGNITUDE, since a
real chunking bug displaces a large fraction of rows and feed noise displaces a
handful - only one of those is safe to continue past.
- Chunk workers return undivided sums; means are divided once globally. Dividing
per chunk would weight a straddling bar's mean-of-means wrong.
test_flow.py: charge the PER-BAR spread instead of a single median across
2003-2026 - FX spreads narrowed by roughly an order of magnitude over that span,
so one median charges modern cost to the 2000s and vice versa. Timeouts are now
reported separately rather than silently booked as stop-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:14:38 -04:00
|
|
|
out_i, out_w, out_d = [], [], []
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
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]))
|
perf(research): parallel tick decode, 12x, plus two correctness fixes
The SQX decoder is a per-record Python loop and cannot be vectorised - record
LENGTH depends on the config nibbles, so record k+1's offset is unknowable
without parsing record k. It therefore saturated exactly one core: 10% CPU on a
12-core box, ~3h for the four files.
But the format is randomly seekable. Every BLOCK_LENGTH records SQX restates all
four fields as absolute int64s, so byte ranges beginning at block headers decode
with no shared history. split_offsets() cuts a file on those boundaries and
decode_iter() gained start/stop. EURUSD: 55 min -> 9.4 min, 94% CPU.
find_block() will not trust a bare MAGIC match: 0x00..0x0e is a byte run that
delta payloads produce by coincidence, so a candidate is accepted only when the
next header downstream carries the next sequential block index.
Verified equal, not assumed equal: the same 315MB span decoded serially and in 6
chunks gives identical tick counts (31,056,000), identical bar counts (206,316)
and identical OHLC. The only divergence is the documented seam artifact - the
first tick of a chunk has no predecessor so its delta counts as zero, bounded at
workers-1 ticks in 513M (~2e-8).
Two fixes this shook out:
- The feed is not perfectly time-ordered. EURUSD carries 2 backward steps in
513,494,303 ticks, both under an hour, both in 2003-2006. Bucketing is by
absolute timestamp so every tick still lands in its true bar; the symptom is a
bucket emitted twice out of order. finalise() now stable-sorts before the
duplicate merge. The ordering assert is kept but keyed to MAGNITUDE, since a
real chunking bug displaces a large fraction of rows and feed noise displaces a
handful - only one of those is safe to continue past.
- Chunk workers return undivided sums; means are divided once globally. Dividing
per chunk would weight a straddling bar's mean-of-means wrong.
test_flow.py: charge the PER-BAR spread instead of a single median across
2003-2026 - FX spreads narrowed by roughly an order of magnitude over that span,
so one median charges modern cost to the 2000s and vice versa. Timeouts are now
reported separately rather than silently booked as stop-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:14:38 -04:00
|
|
|
out_d.append(int(d))
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
busy = e + H
|
perf(research): parallel tick decode, 12x, plus two correctness fixes
The SQX decoder is a per-record Python loop and cannot be vectorised - record
LENGTH depends on the config nibbles, so record k+1's offset is unknowable
without parsing record k. It therefore saturated exactly one core: 10% CPU on a
12-core box, ~3h for the four files.
But the format is randomly seekable. Every BLOCK_LENGTH records SQX restates all
four fields as absolute int64s, so byte ranges beginning at block headers decode
with no shared history. split_offsets() cuts a file on those boundaries and
decode_iter() gained start/stop. EURUSD: 55 min -> 9.4 min, 94% CPU.
find_block() will not trust a bare MAGIC match: 0x00..0x0e is a byte run that
delta payloads produce by coincidence, so a candidate is accepted only when the
next header downstream carries the next sequential block index.
Verified equal, not assumed equal: the same 315MB span decoded serially and in 6
chunks gives identical tick counts (31,056,000), identical bar counts (206,316)
and identical OHLC. The only divergence is the documented seam artifact - the
first tick of a chunk has no predecessor so its delta counts as zero, bounded at
workers-1 ticks in 513M (~2e-8).
Two fixes this shook out:
- The feed is not perfectly time-ordered. EURUSD carries 2 backward steps in
513,494,303 ticks, both under an hour, both in 2003-2006. Bucketing is by
absolute timestamp so every tick still lands in its true bar; the symptom is a
bucket emitted twice out of order. finalise() now stable-sorts before the
duplicate merge. The ordering assert is kept but keyed to MAGNITUDE, since a
real chunking bug displaces a large fraction of rows and feed noise displaces a
handful - only one of those is safe to continue past.
- Chunk workers return undivided sums; means are divided once globally. Dividing
per chunk would weight a straddling bar's mean-of-means wrong.
test_flow.py: charge the PER-BAR spread instead of a single median across
2003-2026 - FX spreads narrowed by roughly an order of magnitude over that span,
so one median charges modern cost to the 2000s and vice versa. Timeouts are now
reported separately rather than silently booked as stop-outs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:14:38 -04:00
|
|
|
return np.array(out_i, int), np.array(out_w, bool), np.array(out_d, int)
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
|
|
|
|
|
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
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)
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
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]])
|
fix(research): standardise flow test against the EMPIRICAL null, not a costless coin
The first run reported -23pp edges at -75 sigma, which is not a market effect -
it is the tell this project has been burned by before (a lookahead, or here a
wrong reference, inflates whatever sign it lands on). The give-away was in the
output itself: a family-wise 5% bar of |z| > 71.67 where a centred null over 16
tests should sit near 2.5. Random entry was losing almost as badly as the signal.
Cause: z and "edge pp" were measured against be = sl/(sl+tp), the break-even of a
COSTLESS coin. These barriers charge the spread and book a loss when a single bar
spans both levels, so random entry at 1 ATR on M5 wins ~36.8%, not 50%. The table
was reporting the fixed cost of trading as if it were signal.
Now every row shows the empirical null win rate, the gap against it, and z
standardised by the null's own spread. Family-wise bar drops to 2.95 and the
result becomes legible: order flow is genuinely ANTI-predictive at M5, about 1pp
below random at z -5 to -8, clearing the bar in 12 of 16 tests and reproducing
across three geometries and two independent signal families. It agrees with the
-0.0151 next-bar correlation.
It is also untradeable, which the table now says out loud: the spread is 0.099 ATR
and costs 13pp of win rate against a 1pp effect. Reversing does not rescue it -
expR_rev is reported per row and stays negative everywhere. Added a footer stating
that beating the null is necessary but NOT sufficient; only exp R > 0 makes money.
Also:
- permutation null was allocating a single (nperm x nT) array, ~2 GB at these
trade counts. Now batched.
- null permutes the OBSERVED directions instead of flipping a fair coin, so a
directionally skewed rule on a trending instrument cannot pass on drift alone.
- timeouts reported separately rather than silently booked as stop-outs.
- calibration falls back to a midpoint sample when the tick history predates the
MT5 reference series (XAUUSD ticks start 2003-05-05, its H1 export 2004-06-11,
so the head sample overlapped by nothing). 2M ticks, because the sample must
span >=50 reference HOURS - 200k ticks of modern gold is nine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:51:07 -04:00
|
|
|
#--- 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)
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
be = sl_m / (sl_m + tp_m)
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
sigs = build_signals(a, I, PER_DAY.get(tf, 288))
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
rng = np.random.default_rng(seed)
|
|
|
|
|
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
#--- 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:,} "
|
fix(research): standardise flow test against the EMPIRICAL null, not a costless coin
The first run reported -23pp edges at -75 sigma, which is not a market effect -
it is the tell this project has been burned by before (a lookahead, or here a
wrong reference, inflates whatever sign it lands on). The give-away was in the
output itself: a family-wise 5% bar of |z| > 71.67 where a centred null over 16
tests should sit near 2.5. Random entry was losing almost as badly as the signal.
Cause: z and "edge pp" were measured against be = sl/(sl+tp), the break-even of a
COSTLESS coin. These barriers charge the spread and book a loss when a single bar
spans both levels, so random entry at 1 ATR on M5 wins ~36.8%, not 50%. The table
was reporting the fixed cost of trading as if it were signal.
Now every row shows the empirical null win rate, the gap against it, and z
standardised by the null's own spread. Family-wise bar drops to 2.95 and the
result becomes legible: order flow is genuinely ANTI-predictive at M5, about 1pp
below random at z -5 to -8, clearing the bar in 12 of 16 tests and reproducing
across three geometries and two independent signal families. It agrees with the
-0.0151 next-bar correlation.
It is also untradeable, which the table now says out loud: the spread is 0.099 ATR
and costs 13pp of win rate against a 1pp effect. Reversing does not rescue it -
expR_rev is reported per row and stays negative everywhere. Added a footer stating
that beating the null is necessary but NOT sufficient; only exp R > 0 makes money.
Also:
- permutation null was allocating a single (nperm x nT) array, ~2 GB at these
trade counts. Now batched.
- null permutes the OBSERVED directions instead of flipping a fair coin, so a
directionally skewed rule on a trending instrument cannot pass on drift alone.
- timeouts reported separately rather than silently booked as stop-outs.
- calibration falls back to a midpoint sample when the tick history predates the
MT5 reference series (XAUUSD ticks start 2003-05-05, its H1 export 2004-06-11,
so the head sample overlapped by nothing). 2M ticks, because the sample must
span >=50 reference HOURS - 200k ticks of modern gold is nine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:51:07 -04:00
|
|
|
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}")
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
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
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
m[:PER_DAY.get(tf, 288) + 12] = False # z-score burn-in
|
|
|
|
|
if cheap is not None:
|
|
|
|
|
m &= cheap
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
fire = np.nonzero(m)[0]
|
|
|
|
|
if len(fire) < 50:
|
|
|
|
|
continue
|
|
|
|
|
d = np.sign(s[fire]).astype(int)
|
fix(research): standardise flow test against the EMPIRICAL null, not a costless coin
The first run reported -23pp edges at -75 sigma, which is not a market effect -
it is the tell this project has been burned by before (a lookahead, or here a
wrong reference, inflates whatever sign it lands on). The give-away was in the
output itself: a family-wise 5% bar of |z| > 71.67 where a centred null over 16
tests should sit near 2.5. Random entry was losing almost as badly as the signal.
Cause: z and "edge pp" were measured against be = sl/(sl+tp), the break-even of a
COSTLESS coin. These barriers charge the spread and book a loss when a single bar
spans both levels, so random entry at 1 ATR on M5 wins ~36.8%, not 50%. The table
was reporting the fixed cost of trading as if it were signal.
Now every row shows the empirical null win rate, the gap against it, and z
standardised by the null's own spread. Family-wise bar drops to 2.95 and the
result becomes legible: order flow is genuinely ANTI-predictive at M5, about 1pp
below random at z -5 to -8, clearing the bar in 12 of 16 tests and reproducing
across three geometries and two independent signal families. It agrees with the
-0.0151 next-bar correlation.
It is also untradeable, which the table now says out loud: the spread is 0.099 ATR
and costs 13pp of win rate against a 1pp effect. Reversing does not rescue it -
expR_rev is reported per row and stays negative everywhere. Added a footer stating
that beating the null is necessary but NOT sufficient; only exp R > 0 makes money.
Also:
- permutation null was allocating a single (nperm x nT) array, ~2 GB at these
trade counts. Now batched.
- null permutes the OBSERVED directions instead of flipping a fair coin, so a
directionally skewed rule on a trending instrument cannot pass on drift alone.
- timeouts reported separately rather than silently booked as stop-outs.
- calibration falls back to a midpoint sample when the tick history predates the
MT5 reference series (XAUUSD ticks start 2003-05-05, its H1 export 2004-06-11,
so the head sample overlapped by nothing). 2M ticks, because the sample must
span >=50 reference HOURS - 200k ticks of modern gold is nine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:51:07 -04:00
|
|
|
ti, tw, td = sequential(fire, d, winL, winS, H, n)
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
if len(ti) < 100:
|
|
|
|
|
continue
|
|
|
|
|
wr = tw.mean(); nT = len(ti)
|
fix(research): standardise flow test against the EMPIRICAL null, not a costless coin
The first run reported -23pp edges at -75 sigma, which is not a market effect -
it is the tell this project has been burned by before (a lookahead, or here a
wrong reference, inflates whatever sign it lands on). The give-away was in the
output itself: a family-wise 5% bar of |z| > 71.67 where a centred null over 16
tests should sit near 2.5. Random entry was losing almost as badly as the signal.
Cause: z and "edge pp" were measured against be = sl/(sl+tp), the break-even of a
COSTLESS coin. These barriers charge the spread and book a loss when a single bar
spans both levels, so random entry at 1 ATR on M5 wins ~36.8%, not 50%. The table
was reporting the fixed cost of trading as if it were signal.
Now every row shows the empirical null win rate, the gap against it, and z
standardised by the null's own spread. Family-wise bar drops to 2.95 and the
result becomes legible: order flow is genuinely ANTI-predictive at M5, about 1pp
below random at z -5 to -8, clearing the bar in 12 of 16 tests and reproducing
across three geometries and two independent signal families. It agrees with the
-0.0151 next-bar correlation.
It is also untradeable, which the table now says out loud: the spread is 0.099 ATR
and costs 13pp of win rate against a 1pp effect. Reversing does not rescue it -
expR_rev is reported per row and stays negative everywhere. Added a footer stating
that beating the null is necessary but NOT sufficient; only exp R > 0 makes money.
Also:
- permutation null was allocating a single (nperm x nT) array, ~2 GB at these
trade counts. Now batched.
- null permutes the OBSERVED directions instead of flipping a fair coin, so a
directionally skewed rule on a trending instrument cannot pass on drift alone.
- timeouts reported separately rather than silently booked as stop-outs.
- calibration falls back to a midpoint sample when the tick history predates the
MT5 reference series (XAUUSD ticks start 2003-05-05, its H1 export 2004-06-11,
so the head sample overlapped by nothing). 2M ticks, because the sample must
span >=50 reference HOURS - 200k ticks of modern gold is nine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:51:07 -04:00
|
|
|
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()
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
expR = wr * tp_m - (1 - wr) * sl_m
|
fix(research): standardise flow test against the EMPIRICAL null, not a costless coin
The first run reported -23pp edges at -75 sigma, which is not a market effect -
it is the tell this project has been burned by before (a lookahead, or here a
wrong reference, inflates whatever sign it lands on). The give-away was in the
output itself: a family-wise 5% bar of |z| > 71.67 where a centred null over 16
tests should sit near 2.5. Random entry was losing almost as badly as the signal.
Cause: z and "edge pp" were measured against be = sl/(sl+tp), the break-even of a
COSTLESS coin. These barriers charge the spread and book a loss when a single bar
spans both levels, so random entry at 1 ATR on M5 wins ~36.8%, not 50%. The table
was reporting the fixed cost of trading as if it were signal.
Now every row shows the empirical null win rate, the gap against it, and z
standardised by the null's own spread. Family-wise bar drops to 2.95 and the
result becomes legible: order flow is genuinely ANTI-predictive at M5, about 1pp
below random at z -5 to -8, clearing the bar in 12 of 16 tests and reproducing
across three geometries and two independent signal families. It agrees with the
-0.0151 next-bar correlation.
It is also untradeable, which the table now says out loud: the spread is 0.099 ATR
and costs 13pp of win rate against a 1pp effect. Reversing does not rescue it -
expR_rev is reported per row and stays negative everywhere. Added a footer stating
that beating the null is necessary but NOT sufficient; only exp R > 0 makes money.
Also:
- permutation null was allocating a single (nperm x nT) array, ~2 GB at these
trade counts. Now batched.
- null permutes the OBSERVED directions instead of flipping a fair coin, so a
directionally skewed rule on a trending instrument cannot pass on drift alone.
- timeouts reported separately rather than silently booked as stop-outs.
- calibration falls back to a midpoint sample when the tick history predates the
MT5 reference series (XAUUSD ticks start 2003-05-05, its H1 export 2004-06-11,
so the head sample overlapped by nothing). 2M ticks, because the sample must
span >=50 reference HOURS - 200k ticks of modern gold is nine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:51:07 -04:00
|
|
|
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))
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
if not rows:
|
|
|
|
|
print(" no signal fired often enough")
|
|
|
|
|
return
|
|
|
|
|
crit = float(np.quantile(np.maximum.reduce(acc), 0.95))
|
fix(research): standardise flow test against the EMPIRICAL null, not a costless coin
The first run reported -23pp edges at -75 sigma, which is not a market effect -
it is the tell this project has been burned by before (a lookahead, or here a
wrong reference, inflates whatever sign it lands on). The give-away was in the
output itself: a family-wise 5% bar of |z| > 71.67 where a centred null over 16
tests should sit near 2.5. Random entry was losing almost as badly as the signal.
Cause: z and "edge pp" were measured against be = sl/(sl+tp), the break-even of a
COSTLESS coin. These barriers charge the spread and book a loss when a single bar
spans both levels, so random entry at 1 ATR on M5 wins ~36.8%, not 50%. The table
was reporting the fixed cost of trading as if it were signal.
Now every row shows the empirical null win rate, the gap against it, and z
standardised by the null's own spread. Family-wise bar drops to 2.95 and the
result becomes legible: order flow is genuinely ANTI-predictive at M5, about 1pp
below random at z -5 to -8, clearing the bar in 12 of 16 tests and reproducing
across three geometries and two independent signal families. It agrees with the
-0.0151 next-bar correlation.
It is also untradeable, which the table now says out loud: the spread is 0.099 ATR
and costs 13pp of win rate against a 1pp effect. Reversing does not rescue it -
expR_rev is reported per row and stays negative everywhere. Added a footer stating
that beating the null is necessary but NOT sufficient; only exp R > 0 makes money.
Also:
- permutation null was allocating a single (nperm x nT) array, ~2 GB at these
trade counts. Now batched.
- null permutes the OBSERVED directions instead of flipping a fair coin, so a
directionally skewed rule on a trending instrument cannot pass on drift alone.
- timeouts reported separately rather than silently booked as stop-outs.
- calibration falls back to a midpoint sample when the tick history predates the
MT5 reference series (XAUUSD ticks start 2003-05-05, its H1 export 2004-06-11,
so the head sample overlapped by nothing). 2M ticks, because the sample must
span >=50 reference HOURS - 200k ticks of modern gold is nine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:51:07 -04:00
|
|
|
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.")
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
|
|
|
|
|
|
2026-08-01 20:00:22 -04:00
|
|
|
#--- 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)]
|
|
|
|
|
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
if __name__ == '__main__':
|
2026-08-01 20:00:22 -04:00
|
|
|
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
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
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])
|
2026-08-01 20:00:22 -04:00
|
|
|
syms = args or ['EURUSD']
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
for sym in syms:
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
if not os.path.exists(f"{BARS}{sym}_{tf}_ticks.npz"):
|
|
|
|
|
print(f"{sym}: {tf} bars not built yet")
|
research: harness for the M5 order-flow edge test
Ready ahead of the bar build so the analysis runs the moment EURUSD lands.
Tests the two SIGNED features the tick pipeline can produce - tick-rule imbalance and
event-count OFI - as entry triggers at 1:1/12, 1:2/24 and 2:3/48 bar geometries. Everything
else the pipeline computes is unsigned and cannot point a trade however well it measures.
Same discipline as every other test here: signal on bar i, entry at the OPEN of i+1,
barriers scanned forward only, sequential NON-OVERLAPPING trades so each is independent
(skipping that is what produced a fake +2.66pp at 2.9 sigma earlier in this project),
break-even == chance by the gambler's-ruin identity, spread charged inside the barrier, and
a sign-flip null taking the max over the whole family for the family-wise bar.
The prior is written into the docstring before any result exists: OFI is established as a
CONTEMPORANEOUS explainer whose predictive power decays within seconds, and it reproduced
that here at +0.56 against the same-bar return. So the expectation is that it explains the
bar it is measured in and says nothing about the next. What is actually 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 counts without sizes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:25:44 -04:00
|
|
|
continue
|
2026-08-01 20:00:22 -04:00
|
|
|
for (s, p, H) in geos:
|
research: flow effect and spread cost decay together and never cross
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>
2026-08-01 20:12:00 -04:00
|
|
|
run(sym, s, p, H, tf=tf, cost_q=cost_q)
|