research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
"""Decoder for StrategyQuant X tick .dat files (format 4.2).
|
|
|
|
|
|
|
|
|
|
Derived from SQX's own writer, disassembled out of internal/libs/SQDataLib.jar:
|
|
|
|
|
com.strategyquant.datalib.data.io.newDataFormat.TickDataWriter
|
|
|
|
|
com.strategyquant.datalib.data.io.newDataFormat.NewDataFormat{,Writter}
|
|
|
|
|
|
|
|
|
|
Why bother, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The
|
|
|
|
|
broker's MT5 tick history goes back a few years; these files start in 2011. Sample size has
|
|
|
|
|
been the binding constraint on this entire investigation, so 15 years of ticks is worth a
|
|
|
|
|
decoder.
|
|
|
|
|
|
|
|
|
|
FORMAT
|
|
|
|
|
------
|
|
|
|
|
Header: four Java writeUTF strings (version "4.2", a type char, a column string, and an
|
|
|
|
|
empty one), ten zero bytes, then one more writeUTF. Data follows.
|
|
|
|
|
|
|
|
|
|
Records are (time, ASK, BID, volume) - ask before bid, and the writer swaps them if bid>ask,
|
|
|
|
|
so ask is always the larger. Every BLOCK_LENGTH=1000 records a block header is emitted:
|
|
|
|
|
|
|
|
|
|
MAGIC (15 bytes: 0x00..0x0e) + int32 block index + config + four raw int64 values
|
|
|
|
|
|
|
|
|
|
and in between, delta records:
|
|
|
|
|
|
|
|
|
|
config + four variable-width deltas against the previous record
|
|
|
|
|
|
|
|
|
|
CONFIG is two bytes = four nibbles, one per field, laid out high-nibble-first:
|
|
|
|
|
|
|
|
|
|
nibble = (logicType << 2) | dataType
|
|
|
|
|
dataType : 0=BYTE(1) 1=SHORT(2) 2=INT(4) 3=LONG(8) <- chosen by |value| magnitude
|
|
|
|
|
logicType: 0=MINUS 1=PLUS 2=ASIS <- sign carried here, not in the bytes
|
|
|
|
|
|
|
|
|
|
MINUS/PLUS payloads are UNSIGNED magnitudes with the sign supplied by logicType; ASIS is a
|
|
|
|
|
plain signed read (used for the full records at block starts).
|
|
|
|
|
|
|
|
|
|
SCALING
|
|
|
|
|
-------
|
|
|
|
|
Prices are integers scaled by 10**decimals. SQX carries `decimals` in its own metadata, not
|
|
|
|
|
in this file, so it is inferred here by checking which power of ten yields a sane price -
|
|
|
|
|
and validated against the first record, which is uncompressed and therefore unambiguous.
|
|
|
|
|
Volume divides by VOLUME_CONSTANT = 100000.0 (OLD_VOLUME_CONSTANT = 100.0 for old files).
|
|
|
|
|
A volume of Long.MAX_VALUE is the writer's "no volume" sentinel and decodes to NaN.
|
|
|
|
|
"""
|
|
|
|
|
import struct, sys, datetime as dt
|
|
|
|
|
import numpy as np
|
|
|
|
|
|
|
|
|
|
MAGIC = bytes(range(15))
|
|
|
|
|
BLOCK_LENGTH = 1000
|
|
|
|
|
VOLUME_CONSTANT = 100000.0
|
|
|
|
|
_SIZE = (1, 2, 4, 8)
|
|
|
|
|
MINUS, PLUS, ASIS = 0, 1, 2
|
|
|
|
|
NO_VOLUME = (1 << 63) - 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def read_header(b):
|
|
|
|
|
"""Returns the offset at which the first block MAGIC starts, plus the header strings."""
|
|
|
|
|
p, strs = 0, []
|
|
|
|
|
for _ in range(4):
|
|
|
|
|
n = struct.unpack_from('>H', b, p)[0]
|
|
|
|
|
strs.append(b[p + 2:p + 2 + n].decode('latin1'))
|
|
|
|
|
p += 2 + n
|
|
|
|
|
p += 10 # ten zero bytes
|
|
|
|
|
n = struct.unpack_from('>H', b, p)[0]
|
|
|
|
|
strs.append(b[p + 2:p + 2 + n].decode('latin1'))
|
|
|
|
|
p += 2 + n
|
|
|
|
|
if b[p:p + 15] != MAGIC: # be forgiving: just find it
|
|
|
|
|
p = b.find(MAGIC)
|
|
|
|
|
if p < 0:
|
|
|
|
|
raise ValueError("no block magic found in first chunk")
|
|
|
|
|
return p, strs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fields(cfg):
|
|
|
|
|
"""Unpack the two config bytes into four (logicType, dataType) pairs."""
|
|
|
|
|
out = []
|
|
|
|
|
for i in range(4):
|
|
|
|
|
nib = (cfg[i >> 1] >> (4 if i % 2 == 0 else 0)) & 0xF
|
|
|
|
|
out.append((nib >> 2, nib & 3))
|
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decode(path, max_records=None, progress=None):
|
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
|
|
|
"""Whole-file decode into four int64 arrays. Convenient, but a full symbol is ~458M
|
|
|
|
|
ticks = ~15 GB of arrays - use decode_iter() for anything beyond a sample."""
|
|
|
|
|
parts = [[], [], [], []]
|
|
|
|
|
for ts, a, b, v in decode_iter(path, max_records=max_records, progress=progress):
|
|
|
|
|
parts[0].append(ts); parts[1].append(a); parts[2].append(b); parts[3].append(v)
|
|
|
|
|
if not parts[0]:
|
|
|
|
|
return tuple(np.empty(0, dtype=np.int64) for _ in range(4))
|
|
|
|
|
return tuple(np.concatenate(p) for p in parts)
|
|
|
|
|
|
|
|
|
|
|
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 find_block(f, approx, size, window=1 << 20):
|
|
|
|
|
"""Byte offset of the first VALID block header at or after `approx`, or None.
|
|
|
|
|
|
|
|
|
|
This is what makes the format parallel-decodable: a block header restates all four
|
|
|
|
|
fields as absolute int64s, so a decoder can start there cold with no history. The catch
|
|
|
|
|
is that MAGIC is 0x00..0x0e, a byte run that delta payloads produce by coincidence
|
|
|
|
|
fairly often - a small negative delta on a LONG field is a lot of zero bytes. So a
|
|
|
|
|
candidate is only accepted when the NEXT candidate downstream carries the next
|
|
|
|
|
sequential block index, which junk essentially never does.
|
|
|
|
|
"""
|
|
|
|
|
def candidate(off):
|
|
|
|
|
"""(offset, block_index, timestamp) of the first plausible header at/after off."""
|
|
|
|
|
p = off
|
|
|
|
|
while p < size:
|
|
|
|
|
f.seek(p)
|
|
|
|
|
buf = f.read(window + 64)
|
|
|
|
|
if len(buf) < 21 + 32:
|
|
|
|
|
return None
|
|
|
|
|
k = 0
|
|
|
|
|
while True:
|
|
|
|
|
k = buf.find(MAGIC, k)
|
|
|
|
|
if k < 0 or k + 15 + 4 + 2 + 32 > len(buf):
|
|
|
|
|
break
|
|
|
|
|
idx = struct.unpack_from('>i', buf, k + 15)[0]
|
|
|
|
|
t, a, b, _v = struct.unpack_from('>4q', buf, k + 15 + 4 + 2)
|
|
|
|
|
#--- a real header: sane block index, epoch-ms in [2000, 2035], ask >= bid > 0
|
|
|
|
|
if (0 <= idx < (1 << 28) and 946684800000 <= t <= 2051222400000
|
|
|
|
|
and a >= b > 0):
|
|
|
|
|
return (p + k, idx, t)
|
|
|
|
|
k += 1
|
|
|
|
|
p += window
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
first = candidate(approx)
|
|
|
|
|
while first is not None:
|
|
|
|
|
nxt = candidate(first[0] + 15 + 4 + 2 + 32)
|
|
|
|
|
if nxt is None:
|
|
|
|
|
return first[0] # last block in the file
|
|
|
|
|
if nxt[1] == first[1] + 1 and nxt[2] >= first[2]:
|
|
|
|
|
return first[0]
|
|
|
|
|
first = nxt # the first was noise; try the next
|
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def split_offsets(path, nchunks):
|
|
|
|
|
"""Byte ranges [(start, stop), ...] on block boundaries covering the whole file.
|
|
|
|
|
|
|
|
|
|
Half-open and contiguous: chunk k's stop IS chunk k+1's start, so every record is
|
|
|
|
|
decoded exactly once with no duplicates and no gaps.
|
|
|
|
|
"""
|
|
|
|
|
import os
|
|
|
|
|
size = os.path.getsize(path)
|
|
|
|
|
with open(path, 'rb') as f:
|
|
|
|
|
head = f.read(1 << 16)
|
|
|
|
|
first, _ = read_header(head)
|
|
|
|
|
if nchunks <= 1:
|
|
|
|
|
return [(first, size)]
|
|
|
|
|
cuts = [first]
|
|
|
|
|
span = (size - first) // nchunks
|
|
|
|
|
for k in range(1, nchunks):
|
|
|
|
|
off = find_block(f, first + k * span, size)
|
|
|
|
|
#--- keep it only if it is strictly after the previous cut
|
|
|
|
|
if off is not None and off > cuts[-1]:
|
|
|
|
|
cuts.append(off)
|
|
|
|
|
cuts.append(size)
|
|
|
|
|
return [(cuts[i], cuts[i + 1]) for i in range(len(cuts) - 1)]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def decode_iter(path, batch=2000000, max_records=None, progress=None, start=None, stop=None):
|
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
|
|
|
"""Streaming decode. Yields (time_ms, ask_i, bid_i, vol_i) int64 arrays in batches.
|
|
|
|
|
|
|
|
|
|
A full symbol here is hundreds of millions of ticks, so nothing that wants the whole
|
|
|
|
|
file can afford to materialise it. Everything downstream aggregates to bars in one pass.
|
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
|
|
|
|
|
|
|
|
start/stop: decode only the byte range [start, stop), which MUST begin on a block
|
|
|
|
|
boundary - see split_offsets(). Used to decode one file across many processes, since
|
|
|
|
|
this per-record loop is pure Python and saturates exactly one core.
|
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
|
|
|
"""
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
f = open(path, 'rb')
|
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 start is None:
|
|
|
|
|
head = f.read(1 << 16)
|
|
|
|
|
start, strs = read_header(head)
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
f.seek(start)
|
|
|
|
|
|
|
|
|
|
ts, asks, bids, vols = [], [], [], []
|
|
|
|
|
t = a = bd = v = 0
|
|
|
|
|
buf = b''
|
|
|
|
|
pos = 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
|
|
|
base = start # absolute file offset of buf[0]
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
CH = 1 << 22
|
|
|
|
|
eof = False
|
|
|
|
|
n = 0
|
|
|
|
|
while True:
|
|
|
|
|
if len(buf) - pos < 64 and not eof:
|
|
|
|
|
buf = buf[pos:]
|
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
|
|
|
base += pos
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
pos = 0
|
|
|
|
|
more = f.read(CH)
|
|
|
|
|
if not more:
|
|
|
|
|
eof = True
|
|
|
|
|
buf += more
|
|
|
|
|
if len(buf) - pos < 2:
|
|
|
|
|
break
|
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 stop is not None and base + pos >= stop:
|
|
|
|
|
break
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
if buf[pos:pos + 15] == MAGIC:
|
|
|
|
|
pos += 15 + 4 # magic + int32 block index
|
|
|
|
|
if len(buf) - pos < 2 + 32:
|
|
|
|
|
break
|
|
|
|
|
cfg = buf[pos:pos + 2]; pos += 2
|
|
|
|
|
t, a, bd, v = struct.unpack_from('>4q', buf, pos)
|
|
|
|
|
pos += 32
|
|
|
|
|
else:
|
|
|
|
|
cfg = buf[pos:pos + 2]
|
|
|
|
|
fs = _fields(cfg)
|
|
|
|
|
need = 2 + sum(_SIZE[d] for _, d in fs)
|
|
|
|
|
if len(buf) - pos < need:
|
|
|
|
|
if eof:
|
|
|
|
|
break
|
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
|
|
|
buf = buf[pos:]; base += pos; pos = 0
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
more = f.read(CH)
|
|
|
|
|
if not more:
|
|
|
|
|
eof = True
|
|
|
|
|
buf += more
|
|
|
|
|
if len(buf) < need:
|
|
|
|
|
break
|
|
|
|
|
continue
|
|
|
|
|
q = pos + 2
|
|
|
|
|
vals = []
|
|
|
|
|
for logic, dtp in fs:
|
|
|
|
|
sz = _SIZE[dtp]
|
|
|
|
|
raw = int.from_bytes(buf[q:q + sz], 'big', signed=(logic == ASIS))
|
|
|
|
|
q += sz
|
|
|
|
|
vals.append(-raw if logic == MINUS else raw)
|
|
|
|
|
pos = q
|
|
|
|
|
t += vals[0]; a += vals[1]; bd += vals[2]; v += vals[3]
|
|
|
|
|
ts.append(t); asks.append(a); bids.append(bd); vols.append(v)
|
|
|
|
|
n += 1
|
|
|
|
|
if progress and n % progress == 0:
|
|
|
|
|
print(f" {n:,} ticks {dt.datetime.fromtimestamp(t/1000, dt.UTC):%Y-%m-%d}", flush=True)
|
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
|
|
|
if len(ts) >= batch:
|
|
|
|
|
yield (np.array(ts, dtype=np.int64), np.array(asks, dtype=np.int64),
|
|
|
|
|
np.array(bids, dtype=np.int64), np.array(vols, dtype=np.int64))
|
|
|
|
|
ts, asks, bids, vols = [], [], [], []
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
if max_records and n >= max_records:
|
|
|
|
|
break
|
|
|
|
|
f.close()
|
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
|
|
|
if ts:
|
|
|
|
|
yield (np.array(ts, dtype=np.int64), np.array(asks, dtype=np.int64),
|
|
|
|
|
np.array(bids, dtype=np.int64), np.array(vols, dtype=np.int64))
|
research: decode StrategyQuant .dat tick files
Reads SQX tick format 4.2 in pure Python. Derived from SQX's own writer, disassembled out
of internal/libs/SQDataLib.jar (TickDataWriter, NewDataFormat{,Writter}) with the javap
bundled in the install - so this follows the format as specified rather than as guessed.
Why, when Scripts/ExportTicks.mq5 pulls the same four fields from MT5: DEPTH. The broker's
MT5 tick history covers a few years; this file starts 2011-09-19. Sample size has been the
binding constraint on every question in this project - the H1/128-bar setup yields ~300
independent trades in 18 years, enough to resolve only a +6pp edge - so 15 years of ticks
is worth a decoder.
Format: four writeUTF strings, ten zero bytes, one more writeUTF, then records of
(time, ASK, BID, volume) - ask before bid, and the writer swaps them when bid>ask so ask is
always the larger. Every BLOCK_LENGTH=1000 records: MAGIC (15 bytes 0x00..0x0e) + int32
block index + config + four raw int64s. In between, deltas against the previous record.
Config is two bytes = four nibbles laid out high-first, nibble = (logicType << 2) |
dataType, where dataType 0..3 selects a 1/2/4/8-byte payload by magnitude and logicType
supplies the sign (MINUS/PLUS carry unsigned magnitudes; ASIS is a plain signed read).
The scale is the one thing NOT in the file. SQX keeps `decimals` in external metadata, and
1216010000 is equally plausible at 10^3, 10^5 or 10^6 - nothing in the bytes distinguishes
them. Guessing would be precisely the silent, plausible-looking error this project keeps
getting caught by: a 100x price scale error crashes nothing, it just quietly rescales every
ATR-normalised feature downstream. So calibrate_decimals() matches against a known
reference series instead. Against the MT5 SP500 H1 export the answer is not marginal:
decimals=5 median rel.err 9.001630
decimals=6 median rel.err 0.004078 <-
decimals=7 median rel.err 0.899984
Verified on 4M ticks: strictly monotonic timestamps, zero negative spreads, price range
1118.03-2048.38 over 2011-09 to 2014-11 (correct for SP500), median spread 0.43 (matches
this broker's H1 record). The 0.41% residual is the expected artefact of comparing a tick
ask against the nearest H1 bar close.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:57:11 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def calibrate_decimals(ts, ask_i, ref_time_s, ref_close, verbose=True):
|
|
|
|
|
"""Determine the price scale by matching against a KNOWN reference series.
|
|
|
|
|
|
|
|
|
|
SQX keeps `decimals` in metadata outside the .dat, and it cannot be inferred from the
|
|
|
|
|
file alone: 1216010000 is a plausible price at 10^3 (1216010), 10^5 (12160.1) or 10^6
|
|
|
|
|
(1216.01), and nothing in the bytes distinguishes them. Guessing here would be exactly
|
|
|
|
|
the kind of silent, plausible-looking error this project keeps getting burned by - a
|
|
|
|
|
100x price scale error would not crash anything, it would just quietly rescale every
|
|
|
|
|
ATR-normalised feature downstream.
|
|
|
|
|
|
|
|
|
|
So instead: take a reference series for the same instrument (the MT5 rate exports under
|
|
|
|
|
Common\\Files\\Warrior_EA\\Research), find the tick nearest each reference bar time, and
|
|
|
|
|
pick the power of ten minimising median relative error. A correct scale lands at ~0;
|
|
|
|
|
every wrong one is off by a factor of ten or more, so the decision is never marginal.
|
|
|
|
|
"""
|
|
|
|
|
ref_ms = (ref_time_s * 1000).astype(np.int64)
|
|
|
|
|
lo, hi = ts[0], ts[-1]
|
|
|
|
|
m = (ref_ms >= lo) & (ref_ms <= hi)
|
|
|
|
|
if m.sum() < 50:
|
|
|
|
|
raise ValueError(f"only {int(m.sum())} overlapping reference bars - cannot calibrate")
|
|
|
|
|
rt, rc = ref_ms[m], ref_close[m]
|
|
|
|
|
idx = np.searchsorted(ts, rt).clip(1, len(ts) - 1)
|
|
|
|
|
got = ask_i[idx].astype(float)
|
|
|
|
|
best, best_err = None, np.inf
|
|
|
|
|
for d in range(0, 11):
|
|
|
|
|
err = float(np.median(np.abs(got / (10.0 ** d) - rc) / np.maximum(np.abs(rc), 1e-9)))
|
|
|
|
|
if verbose:
|
|
|
|
|
print(f" decimals={d:<3} median rel.err {err:12.6f}")
|
|
|
|
|
if err < best_err:
|
|
|
|
|
best, best_err = d, err
|
|
|
|
|
if best_err > 0.05:
|
|
|
|
|
raise ValueError(f"best scale (decimals={best}) still off by {best_err:.1%} - "
|
|
|
|
|
"reference series may be a different instrument")
|
|
|
|
|
return best, best_err
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def to_frame(path, max_records=None, decimals=None, volume_constant=VOLUME_CONSTANT,
|
|
|
|
|
progress=None, ref=None):
|
|
|
|
|
"""decimals: pass explicitly, or pass ref=(time_s, close) to calibrate against a known
|
|
|
|
|
series. There is no safe default - see calibrate_decimals()."""
|
|
|
|
|
ts, ai, bi, vi = decode(path, max_records, progress)
|
|
|
|
|
if decimals is None:
|
|
|
|
|
if ref is None:
|
|
|
|
|
raise ValueError("pass decimals=... or ref=(time_s, close) - see calibrate_decimals()")
|
|
|
|
|
decimals, _ = calibrate_decimals(ts, ai, ref[0], ref[1])
|
|
|
|
|
scale = 10.0 ** decimals
|
|
|
|
|
ask = ai / scale
|
|
|
|
|
bid = bi / scale
|
|
|
|
|
vol = np.where(vi == NO_VOLUME, np.nan, vi / volume_constant)
|
|
|
|
|
return ts, bid, ask, vol, decimals
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
|
# usage: python sqx.py <file.dat> [max_records] [--ref SYMBOL | --decimals N]
|
|
|
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
|
|
|
path = sys.argv[1]
|
|
|
|
|
lim = int(sys.argv[2]) if len(sys.argv) > 2 and not sys.argv[2].startswith('-') else None
|
|
|
|
|
dec_arg, ref_sym = None, None
|
|
|
|
|
if '--decimals' in sys.argv:
|
|
|
|
|
dec_arg = int(sys.argv[sys.argv.index('--decimals') + 1])
|
|
|
|
|
if '--ref' in sys.argv:
|
|
|
|
|
ref_sym = sys.argv[sys.argv.index('--ref') + 1]
|
|
|
|
|
ref = None
|
|
|
|
|
if ref_sym:
|
|
|
|
|
from kit import load_rates
|
|
|
|
|
rt, ro, rh, rl, rc, rv, rs = load_rates(ref_sym, 16385)
|
|
|
|
|
ref = (rt, rc)
|
|
|
|
|
print(f"calibrating scale against {ref_sym} H1 closes:")
|
|
|
|
|
ts, bid, ask, vol, dec = to_frame(path, max_records=lim, decimals=dec_arg,
|
|
|
|
|
progress=5000000, ref=ref)
|
|
|
|
|
print(f"\ndecoded {len(ts):,} ticks decimals={dec}")
|
|
|
|
|
print(f"span {dt.datetime.fromtimestamp(ts[0]/1000, dt.UTC)}"
|
|
|
|
|
f" -> {dt.datetime.fromtimestamp(ts[-1]/1000, dt.UTC)}")
|
|
|
|
|
print(f"bid min {np.nanmin(bid):.5f} max {np.nanmax(bid):.5f}")
|
|
|
|
|
spread = ask - bid
|
|
|
|
|
print(f"spread median {np.nanmedian(spread):.5f} negative: {(spread<0).sum()}")
|
|
|
|
|
print(f"time monotonic: {bool((np.diff(ts)>=0).all())} "
|
|
|
|
|
f"max gap {np.max(np.diff(ts))/1000/3600:.1f} h")
|
|
|
|
|
print(f"volume: median {np.nanmedian(vol)} nan {int(np.isnan(vol).sum()):,}")
|
|
|
|
|
print("\nfirst 5:")
|
|
|
|
|
for i in range(5):
|
|
|
|
|
print(f" {dt.datetime.fromtimestamp(ts[i]/1000, dt.UTC)} "
|
|
|
|
|
f"bid {bid[i]:.5f} ask {ask[i]:.5f} vol {vol[i]}")
|