Warrior_EA/research/ticks_to_bars.py

325 lines
16 KiB
Python
Raw Permalink Normal View History

research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
"""Stream SQX tick files into bars carrying tick-derived microstructure features.
One pass, bounded memory. A full symbol is ~458M ticks (~15 GB as arrays), so nothing can
hold the raw stream; this reduces 23 years of EURUSD ticks to ~2.4M M5 bars that every
downstream test can load instantly.
WHAT IS AND IS NOT COMPUTABLE HERE
----------------------------------
The feed carries (time, bid, ask, volume). It does NOT carry trade direction: SQX's own
record has one volume field, and MT5's TICK_FLAG_BUY/SELL are empty on FX anyway. So true
signed order flow does not exist in this data and is not synthesised here under a
flattering name.
What IS available is quote dynamics, which is the standard substitute when trade labels
are absent:
tick rule - sign of each mid-price change; up-ticks vs down-ticks over the bar.
The classic Lee-Ready fallback classifier.
research: signed order-flow imbalance instead of bare quote-move counts Replaces bidmoves/askmoves with bid_up, bid_dn, ask_up, ask_dn. A bid ticking UP and an ask ticking DOWN both mean buy-side pressure, and a counter that only records "the bid changed" cannot tell them apart - it throws away the direction, which is the only part that could ever point a trade. This is order-flow imbalance in the Cont/Kukanov/Stoikov sense, in its event-count form; the feed carries no sizes so it cannot be size-weighted. Caught before the 3-hour build rather than after, which was the point of smoke-testing on a bounded sample first. Verified against the previous column set on the same 3M ticks: 21,971 bars, ticks/bar 76, up 38, dn 38, spread 0.000126, rvol 4.800e-07, gaps 3.95/31.4 - all identical - and bid_up+bid_dn reproduces the old bidmoves count of 74 exactly, as it must. The orientation check that matters: OFI correlates +0.56 with the SAME-bar return. That is the contemporaneous signature the literature reports, and it is also the cheapest guard against the failure mode that would otherwise pass silently - a sign flip would read -0.56 and every downstream test would then be fitting the negative of the intended feature. Expectations set in the docstring rather than discovered later: OFI is well established as a contemporaneous EXPLAINER of price change and its predictive power decays within seconds. At M5 with multi-hour horizons the prior should be that it explains the bar it is measured in, not the next one. Measuring it anyway is the point - but a +0.56 contemporaneous correlation is not evidence of an edge and must not be reported as one. Merge/mean bookkeeping is now index-driven off COLUMNS instead of positional, so adding a feature cannot silently mis-merge a bar that straddles a batch boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:23:50 -04:00
OFI counts - SIGNED top-of-book revisions: bid_up, bid_dn, ask_up, ask_dn. A bid
ticking up and an ask ticking down both mean buy-side pressure, and a
bare "the bid changed" counter cannot tell them apart. This is
order-flow imbalance in the Cont/Kukanov/Stoikov sense, in its
EVENT-COUNT form - the feed has no sizes, so it cannot be size-weighted.
Temper expectations accordingly: OFI is well established as a
CONTEMPORANEOUS explainer of price change, and its predictive power
decays within seconds. At M5 with multi-hour horizons the prior should
be that it explains the bar it is measured in and not the next one.
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
arrival rate - ticks per bar, and the dispersion of inter-tick gaps. Urgency, not size.
realised vol - sum of squared mid returns within the bar; a far better volatility
estimate than the bar range, and only obtainable from ticks.
spread stats - mean and max within the bar, in price units.
All of these are UNSIGNED except the tick rule and quote asymmetry, which are the only two
columns here that can point a direction.
Bars are stamped by the OPEN of their interval and computed only from ticks inside it, so
nothing in a bar's features depends on a tick after it closes.
"""
import numpy as np, sys, os, time, datetime as dt
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from sqx import decode_iter, calibrate_decimals, NO_VOLUME, VOLUME_CONSTANT
COLUMNS = ['time', 'open', 'high', 'low', 'close', 'ticks', 'upticks', 'downticks',
research: signed order-flow imbalance instead of bare quote-move counts Replaces bidmoves/askmoves with bid_up, bid_dn, ask_up, ask_dn. A bid ticking UP and an ask ticking DOWN both mean buy-side pressure, and a counter that only records "the bid changed" cannot tell them apart - it throws away the direction, which is the only part that could ever point a trade. This is order-flow imbalance in the Cont/Kukanov/Stoikov sense, in its event-count form; the feed carries no sizes so it cannot be size-weighted. Caught before the 3-hour build rather than after, which was the point of smoke-testing on a bounded sample first. Verified against the previous column set on the same 3M ticks: 21,971 bars, ticks/bar 76, up 38, dn 38, spread 0.000126, rvol 4.800e-07, gaps 3.95/31.4 - all identical - and bid_up+bid_dn reproduces the old bidmoves count of 74 exactly, as it must. The orientation check that matters: OFI correlates +0.56 with the SAME-bar return. That is the contemporaneous signature the literature reports, and it is also the cheapest guard against the failure mode that would otherwise pass silently - a sign flip would read -0.56 and every downstream test would then be fitting the negative of the intended feature. Expectations set in the docstring rather than discovered later: OFI is well established as a contemporaneous EXPLAINER of price change and its predictive power decays within seconds. At M5 with multi-hour horizons the prior should be that it explains the bar it is measured in, not the next one. Measuring it anyway is the point - but a +0.56 contemporaneous correlation is not evidence of an edge and must not be reported as one. Merge/mean bookkeeping is now index-driven off COLUMNS instead of positional, so adding a feature cannot silently mis-merge a bar that straddles a batch boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:23:50 -04:00
'bid_up', 'bid_dn', 'ask_up', 'ask_dn', 'spread_mean', 'spread_max',
'rvol', 'volume', 'gap_mean', 'gap_max']
#--- column groups used when merging a bar that straddles a batch boundary
_I = {c: k for k, c in enumerate(COLUMNS)}
SUM_IDX = tuple(_I[c] for c in ('ticks', 'upticks', 'downticks', 'bid_up', 'bid_dn',
'ask_up', 'ask_dn', 'spread_mean', 'rvol', 'volume',
'gap_mean'))
MAX_IDX = tuple(_I[c] for c in ('spread_max', 'gap_max'))
MEAN_IDX = tuple(_I[c] for c in ('spread_mean', 'gap_mean')) # accumulated as sums, divided at the end
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
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
def merge(a, b):
"""Combine two partials describing the SAME bucket. a precedes b.
Index-driven off COLUMNS rather than positional, so adding a feature cannot
silently mis-merge one."""
out = list(a)
out[_I['high']] = max(a[_I['high']], b[_I['high']])
out[_I['low']] = min(a[_I['low']], b[_I['low']])
out[_I['close']] = b[_I['close']]
for i in SUM_IDX:
out[i] = a[i] + b[i]
for i in MAX_IDX:
out[i] = max(a[i], b[i])
return tuple(out)
def finalise(arr):
"""Sort, merge duplicate-timestamp rows, then turn accumulated sums into means.
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
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
Called ONCE on the fully assembled array. Chunk workers must not divide their own
means: a bar straddling a chunk boundary arrives as two partials, and mean-of-means
weighted wrong is exactly the kind of small silent error that survives every sanity
check.
The sort is needed because the raw feed is not perfectly time-ordered - EURUSD carries
2 backward steps in 513M ticks, both under an hour, both in the 2003-2006 era. Bucketing
is by ABSOLUTE timestamp, so every tick still lands in its correct bar no matter what
order it arrives in; the only symptom is that a bucket can be emitted twice, out of
order. A stable sort followed by the duplicate merge below puts those back together
exactly. The one residual imprecision is that `open`/`close` for such a bar come from
decode order rather than true time order - at most a couple of bars in 1.7M."""
if not len(arr):
return arr
t = arr[:, _I['time']]
if (np.diff(t) < 0).any():
arr = arr[np.argsort(t, kind='stable')]
t = arr[:, _I['time']]
if (np.diff(t) == 0).any():
starts = np.concatenate(([0], np.flatnonzero(np.diff(t)) + 1))
ends = np.concatenate((starts[1:] - 1, [len(t) - 1]))
out = arr[starts].copy()
out[:, _I['high']] = np.maximum.reduceat(arr[:, _I['high']], starts)
out[:, _I['low']] = np.minimum.reduceat(arr[:, _I['low']], starts)
out[:, _I['close']] = arr[ends, _I['close']]
for i in SUM_IDX:
out[:, i] = np.add.reduceat(arr[:, i], starts)
for i in MAX_IDX:
out[:, i] = np.maximum.reduceat(arr[:, i], starts)
arr = out
n = np.maximum(arr[:, _I['ticks']], 1.0)
for i in MEAN_IDX:
arr[:, i] /= n
return arr
def _chunk(args):
"""One byte range of one file -> partial bars with means still UNDIVIDED."""
path, scale, step, start, stop, volume_constant, max_records = args
return _scan(path, scale, step, volume_constant, max_records, start, stop)[0]
def _scan(path, scale, step, volume_constant, max_records=None, start=None, stop=None,
progress_every=0, t0=None):
"""Decode a byte range and aggregate it to bars. Returns (array, tick_count)."""
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
rows = []
pend = None # carried partial bar as a plain tuple
prev_mid = prev_bid = prev_ask = None
prev_t = None
total = 0
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
t0 = t0 or time.time()
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
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
for ts, ai, bi, vi in decode_iter(path, max_records=max_records, start=start, stop=stop):
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
ask = ai / scale
bid = bi / scale
mid = (ask + bid) * 0.5
vol = np.where(vi == NO_VOLUME, 0.0, vi / volume_constant)
spread = ask - bid
# per-tick deltas, seeded from the previous batch's last tick so the first tick of
# a batch is not silently treated as having no predecessor
d = np.empty(len(mid))
d[0] = 0.0 if prev_mid is None else mid[0] - prev_mid
d[1:] = np.diff(mid)
research: signed order-flow imbalance instead of bare quote-move counts Replaces bidmoves/askmoves with bid_up, bid_dn, ask_up, ask_dn. A bid ticking UP and an ask ticking DOWN both mean buy-side pressure, and a counter that only records "the bid changed" cannot tell them apart - it throws away the direction, which is the only part that could ever point a trade. This is order-flow imbalance in the Cont/Kukanov/Stoikov sense, in its event-count form; the feed carries no sizes so it cannot be size-weighted. Caught before the 3-hour build rather than after, which was the point of smoke-testing on a bounded sample first. Verified against the previous column set on the same 3M ticks: 21,971 bars, ticks/bar 76, up 38, dn 38, spread 0.000126, rvol 4.800e-07, gaps 3.95/31.4 - all identical - and bid_up+bid_dn reproduces the old bidmoves count of 74 exactly, as it must. The orientation check that matters: OFI correlates +0.56 with the SAME-bar return. That is the contemporaneous signature the literature reports, and it is also the cheapest guard against the failure mode that would otherwise pass silently - a sign flip would read -0.56 and every downstream test would then be fitting the negative of the intended feature. Expectations set in the docstring rather than discovered later: OFI is well established as a contemporaneous EXPLAINER of price change and its predictive power decays within seconds. At M5 with multi-hour horizons the prior should be that it explains the bar it is measured in, not the next one. Measuring it anyway is the point - but a +0.56 contemporaneous correlation is not evidence of an edge and must not be reported as one. Merge/mean bookkeeping is now index-driven off COLUMNS instead of positional, so adding a feature cannot silently mis-merge a bar that straddles a batch boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:23:50 -04:00
# SIGNED quote changes, not just "did it move". Order-flow imbalance in the
# Cont/Kukanov/Stoikov sense is built from the DIRECTION of top-of-book revisions:
# a bid ticking UP and an ask ticking DOWN both mean buy-side pressure, and the two
# are indistinguishable from a bare "the bid changed" counter. Sizes are absent
# from this feed, so this is the event-count form of OFI rather than the
# size-weighted one - see the module docstring for what that costs.
db = np.empty(len(bid))
da = np.empty(len(ask))
db[0] = 0.0 if prev_bid is None else bid[0] - prev_bid
da[0] = 0.0 if prev_ask is None else ask[0] - prev_ask
db[1:] = np.diff(bid)
da[1:] = np.diff(ask)
bid_up = (db > 0).astype(np.float64)
bid_dn = (db < 0).astype(np.float64)
ask_up = (da > 0).astype(np.float64)
ask_dn = (da < 0).astype(np.float64)
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
gap = np.empty(len(ts))
gap[0] = 0.0 if prev_t is None else (ts[0] - prev_t) / 1000.0
gap[1:] = np.diff(ts) / 1000.0
np.maximum(gap, 0.0, out=gap)
bucket = (ts // step) * step
starts = np.concatenate(([0], np.flatnonzero(np.diff(bucket)) + 1))
ends = np.concatenate((starts[1:] - 1, [len(ts) - 1]))
cnt = (ends - starts + 1).astype(np.float64)
part = np.column_stack([
bucket[starts].astype(np.float64), # time
mid[starts], # open
np.maximum.reduceat(mid, starts), # high
np.minimum.reduceat(mid, starts), # low
mid[ends], # close
cnt, # ticks
np.add.reduceat((d > 0).astype(np.float64), starts), # upticks
np.add.reduceat((d < 0).astype(np.float64), starts), # downticks
research: signed order-flow imbalance instead of bare quote-move counts Replaces bidmoves/askmoves with bid_up, bid_dn, ask_up, ask_dn. A bid ticking UP and an ask ticking DOWN both mean buy-side pressure, and a counter that only records "the bid changed" cannot tell them apart - it throws away the direction, which is the only part that could ever point a trade. This is order-flow imbalance in the Cont/Kukanov/Stoikov sense, in its event-count form; the feed carries no sizes so it cannot be size-weighted. Caught before the 3-hour build rather than after, which was the point of smoke-testing on a bounded sample first. Verified against the previous column set on the same 3M ticks: 21,971 bars, ticks/bar 76, up 38, dn 38, spread 0.000126, rvol 4.800e-07, gaps 3.95/31.4 - all identical - and bid_up+bid_dn reproduces the old bidmoves count of 74 exactly, as it must. The orientation check that matters: OFI correlates +0.56 with the SAME-bar return. That is the contemporaneous signature the literature reports, and it is also the cheapest guard against the failure mode that would otherwise pass silently - a sign flip would read -0.56 and every downstream test would then be fitting the negative of the intended feature. Expectations set in the docstring rather than discovered later: OFI is well established as a contemporaneous EXPLAINER of price change and its predictive power decays within seconds. At M5 with multi-hour horizons the prior should be that it explains the bar it is measured in, not the next one. Measuring it anyway is the point - but a +0.56 contemporaneous correlation is not evidence of an edge and must not be reported as one. Merge/mean bookkeeping is now index-driven off COLUMNS instead of positional, so adding a feature cannot silently mis-merge a bar that straddles a batch boundary. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:23:50 -04:00
np.add.reduceat(bid_up, starts), # bid ticked up
np.add.reduceat(bid_dn, starts), # bid ticked down
np.add.reduceat(ask_up, starts), # ask ticked up
np.add.reduceat(ask_dn, starts), # ask ticked down
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
np.add.reduceat(spread, starts), # spread SUM (divided at the end)
np.maximum.reduceat(spread, starts), # spread max
np.add.reduceat(d * d, starts), # realised variance
np.add.reduceat(vol, starts), # volume
np.add.reduceat(gap, starts), # gap SUM
np.maximum.reduceat(gap, starts), # gap max
])
lst = [tuple(r) for r in part]
if pend is not None:
if lst and lst[0][0] == pend[0]:
lst[0] = merge(pend, lst[0])
else:
rows.append(pend)
pend = lst.pop() if lst else pend
rows.extend(lst)
prev_mid = mid[-1]; prev_bid = bid[-1]; prev_ask = ask[-1]; prev_t = ts[-1]
total += len(ts)
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
if progress_every and total % progress_every < len(ts):
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
el = time.time() - t0
print(f" {total/1e6:.0f}M ticks {len(rows):,} bars "
f"{dt.datetime.fromtimestamp(ts[-1]/1000, dt.UTC):%Y-%m-%d} "
f"{total/el/1e6:.2f}M/s", flush=True)
if pend is not None:
rows.append(pend)
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(rows, dtype=np.float64), total
def build(path, ref, tf_seconds=300, decimals=None, max_records=None,
volume_constant=VOLUME_CONSTANT, progress_every=20000000, workers=None):
"""ref = (time_s, close) reference series used only to calibrate the price scale.
The per-record decode loop is pure Python and cannot be vectorised - record LENGTH
depends on the config nibbles, so you cannot know where record k+1 starts without
parsing record k. It therefore saturates exactly one core, which is why this ran at
~10% CPU on a 12-core box. But the format is randomly seekable: every BLOCK_LENGTH
records SQX restates all four fields as absolute int64s, so byte ranges starting at
block headers decode independently with no shared history. That is the whole trick.
The only cost is that the first tick of each chunk has no predecessor, so its per-tick
deltas (tick direction, quote revisions, inter-tick gap) count as zero. That is
workers-1 ticks out of ~458M, i.e. ~2e-8 of the sample - stated here rather than left
for someone to find.
"""
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
from sqx import decode, decode_iter, split_offsets, find_block
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
# --- calibrate on a sample first; the scale must be right before a long pass
if decimals is None:
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
sts, sai, _, _ = decode(path, max_records=200000)
try:
decimals, err = calibrate_decimals(sts, sai, ref[0], ref[1], verbose=False)
except ValueError:
# The head of the tick file can predate the MT5 reference series entirely -
# XAUUSD ticks start 2003-05-05 but its H1 export starts 2004-06-11, so the
# first 200k ticks overlap it by nothing. Recent data always overlaps, so fall
# back to a sample near the END of the file. Failing here rather than guessing
# is deliberate: a wrong scale crashes nothing and silently rescales every
# ATR-normalised feature downstream.
size = os.path.getsize(path)
print(" head sample predates the reference series - resampling")
#--- 2M ticks, because the sample must span >=50 reference HOURS, not >=50
#--- ticks: 200k ticks of modern gold is about nine hours and calibration
#--- rightly refused it. Midpoint first (always inside the reference range),
#--- then the tail.
decimals = None
for frac in (0.5, 0.75, 0.9):
with open(path, 'rb') as fh:
off = find_block(fh, int(size * frac), size)
if off is None:
continue
it = decode_iter(path, batch=2000000, start=off)
sts, sai, _, _ = next(it)
it.close()
try:
decimals, err = calibrate_decimals(sts, sai, ref[0], ref[1],
verbose=False)
break
except ValueError:
continue
if decimals is None:
raise
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
print(f" scale: decimals={decimals} (median rel.err {err:.4%})")
scale = 10.0 ** decimals
step = tf_seconds * 1000
t0 = time.time()
if workers is None:
workers = max(1, (os.cpu_count() or 4) - 1)
if max_records or workers <= 1:
arr, total = _scan(path, scale, step, volume_constant, max_records,
progress_every=progress_every, t0=t0)
else:
ranges = split_offsets(path, workers)
print(f" {len(ranges)} chunks x ~{os.path.getsize(path)/len(ranges)/1e9:.2f} GB "
f"across {workers} workers", flush=True)
import multiprocessing as mp
args = [(path, scale, step, s, e, volume_constant, None) for s, e in ranges]
with mp.Pool(len(ranges)) as pool:
parts = []
for k, part in enumerate(pool.imap(_chunk, args)):
parts.append(part)
el = time.time() - t0
print(f" chunk {k+1}/{len(ranges)} done {len(part):,} bars "
f"{el/60:.1f} min elapsed", flush=True)
parts = [p for p in parts if len(p)]
arr = np.concatenate(parts) if parts else np.empty((0, len(COLUMNS)))
total = int(arr[:, _I['ticks']].sum()) if len(arr) else 0
#--- imap preserves submission order and ranges are in file order, so the
#--- concatenation should be time-sorted. It is not quite, because the FEED is not
#--- perfectly ordered. Distinguish the two causes by magnitude rather than by
#--- presence: a genuine split bug misplaces whole chunks and puts a large fraction
#--- of rows out of order, while feed noise puts a handful. finalise() sorts either
#--- way, but only one of them is acceptable to continue past.
if len(arr):
ooo = int((np.diff(arr[:, _I['time']]) < 0).sum())
if ooo > max(16, len(arr) // 1000):
raise RuntimeError(f"{ooo:,}/{len(arr):,} bars out of time order - "
"that is a chunking bug, not feed noise")
if ooo:
print(f" {ooo} out-of-order bar(s) from feed noise - sorting")
before = len(arr)
arr = finalise(arr)
if before != len(arr):
print(f" merged {before - len(arr)} bar(s) straddling chunk boundaries")
research: stream tick files into bars with microstructure features sqx.decode_iter() turns the decoder into a generator, and ticks_to_bars.py reduces a symbol to bars in one bounded-memory pass. Necessary rather than tidy: EURUSD is ~458M ticks, which is ~15 GB held as arrays, so nothing downstream can take the raw stream. 23 years collapses to ~2.4M M5 bars that every test can load instantly. Aggregation is vectorised with reduceat rather than looping per tick. The only real complexity is that a bar can straddle a batch boundary, so the last partial bar of each batch is carried and merged into the first of the next; per-tick deltas are likewise seeded from the previous batch's final tick, so the first tick of a batch is not silently treated as having no predecessor. Verified against the per-tick implementation it replaces: 21,971 bars either way, and every reported median identical to the digit (ticks/bar 76, up 38, dn 38, bidmoves 74, askmoves 74, spread 0.000126/0.000250, rvol 4.800e-07, gaps 3.95/31.4). Throughput 132k ticks/s, at which point the decoder itself is the bottleneck and the aggregation costs ~12%. Features are chosen by what the feed can honestly support. It carries (time, bid, ask, volume) and no trade direction - SQX's record has one volume field and MT5's TICK_FLAG_BUY/SELL are empty on FX - so true signed order flow does not exist here and is not synthesised under a flattering name. What is available: tick rule up/down mid-price changes; the standard Lee-Ready fallback quote asymmetry bid updates vs ask updates - which side is being repriced harder arrival rate inter-tick gaps, mean and max; urgency rather than size realised variance sum of squared mid returns, a far better volatility estimate than the bar range and only obtainable from ticks spread mean and max within the bar Of these only the tick rule and quote asymmetry can point a direction; the rest are unsigned, like every feature that has measured above noise in this project so far. Bars are stamped by the OPEN of their interval and built only from ticks inside it, so no bar's features depend on a tick after it closes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:20:34 -04:00
print(f" DONE {total:,} ticks -> {len(arr):,} bars in {(time.time()-t0)/60:.1f} min")
return arr
if __name__ == '__main__':
from kit import load_rates
D = 'c:/Users/admin/Documents/Workspaces/Market Data/'
OUT = 'c:/Users/admin/Documents/Workspaces/Market Data/bars/'
os.makedirs(OUT, exist_ok=True)
JOBS = [('EURUSD_tick_the5ers_TICK.dat', 'EURUSD'),
('USDJPY_tick_the5ers_TICK.dat', 'USDJPY'),
('XAUUSD_tick_the5ers_TICK.dat', 'XAUUSD'),
('USA500IDXUSD_tick_the5ers_TICK.dat', 'SP500')]
tf = int(sys.argv[1]) if len(sys.argv) > 1 else 300
only = sys.argv[2] if len(sys.argv) > 2 else None
for fn, sym in JOBS:
if only and sym != only:
continue
out = f"{OUT}{sym}_M{tf//60}_ticks.npz"
if os.path.exists(out):
print(f"{sym}: {out} exists, skipping")
continue
print(f"\n=== {sym} ({os.path.getsize(D+fn)/1e9:.1f} GB) -> M{tf//60} ===", flush=True)
rt, ro, rh, rl, rc, rv, rs = load_rates(sym, 16385)
arr = build(D + fn, (rt, rc), tf_seconds=tf)
np.savez_compressed(out, bars=arr, columns=np.array(COLUMNS))
print(f" saved {out}")