Warrior_EA/research/sqx.py

326 lines
14 KiB
Python
Raw Permalink Normal View History

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