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}")
|