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>
326 lines
14 KiB
Python
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]}")
|