Warrior_EA/research/sqx.py
AnimateDread 91d67db737 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

326 lines
14 KiB
Python

"""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):
"""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)
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):
"""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.
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.
"""
f = open(path, 'rb')
if start is None:
head = f.read(1 << 16)
start, strs = read_header(head)
f.seek(start)
ts, asks, bids, vols = [], [], [], []
t = a = bd = v = 0
buf = b''
pos = 0
base = start # absolute file offset of buf[0]
CH = 1 << 22
eof = False
n = 0
while True:
if len(buf) - pos < 64 and not eof:
buf = buf[pos:]
base += pos
pos = 0
more = f.read(CH)
if not more:
eof = True
buf += more
if len(buf) - pos < 2:
break
if stop is not None and base + pos >= stop:
break
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
buf = buf[pos:]; base += pos; pos = 0
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)
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 = [], [], [], []
if max_records and n >= max_records:
break
f.close()
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))
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]}")