forked from animatedread/Warrior_EA
214 lines
8.7 KiB
Python
214 lines
8.7 KiB
Python
"""Decode SQX .dat BAR files - the free breadth sitting in the SQX install.
| |||
| |||
`sqx.py` decodes the TICK files, where every record has FOUR fields (time, ask, bid, volume).
| |||
The bar files use the identical container with SIX (time, open, high, low, close, volume),
| |||
and that is the only reason the tick decoder cannot read them. Everything else - the block
| |||
magic, the delta encoding, the per-record field map - is the same, so this module is that
| |||
decoder generalised over the field count rather than a second implementation.
| |||
| |||
THE FORMAT, STATED PLAINLY
| |||
--------------------------
| |||
header as sqx.read_header
| |||
block anchor MAGIC (15 bytes) + int32 block index + cfg + N ABSOLUTE int64s
| |||
ordinary record cfg + N values, each a DELTA on the running value
| |||
| |||
cfg ONE NIBBLE PER FIELD, so N fields occupy ceil(N/2) bytes:
| |||
tick 4 fields -> 2 bytes, bar 6 fields -> 3 bytes.
| |||
nibble = (logic << 2) | dtype, dtype indexes (1,2,4,8) bytes.
| |||
| |||
The records are delta-encoded, which is why reading at a fixed stride recovers the first
| |||
record perfectly and then produces noise - a failure mode worth naming, because the first
| |||
record looking right is exactly what makes it tempting to trust the rest.
| |||
| |||
THE SCALE IS THE DANGEROUS PART
| |||
-------------------------------
| |||
Prices are integers and the decimal count is NOT in the header. A wrong scale does not crash
| |||
and does not even hurt a correlation - it silently rescales every price, which is recorded
| |||
twice already in this project's notes as the trap that costs the most time. So it is never
| |||
inferred here: `catalog` pins it, states the three independent checks that agree on it, and
| |||
this module ASSERTS it per file against the instrument's known price range rather than
| |||
trusting the constant.
| |||
| |||
These are M1 bars with no ask, so any trade simulation on them must substitute the5ers' cost
| |||
from an instrument where the real spread is known. Never Dukascopy's own.
| |||
"""
| |||
import numpy as np, os, sys, struct
| |||
import sqx, catalog
| |||
from sqx import MAGIC, _SIZE, MINUS, ASIS
| |||
| |||
CACHE = catalog.CACHE
| |||
COLS = ['time', 'open', 'high', 'low', 'close', 'volume']
| |||
| |||
| |||
def _fields_n(cfg, nf):
| |||
"""Unpack `nf` (logic, dtype) pairs from ceil(nf/2) config bytes, one nibble each."""
| |||
out = []
| |||
for i in range(nf):
| |||
nib = (cfg[i >> 1] >> (4 if i % 2 == 0 else 0)) & 0xF
| |||
out.append((nib >> 2, nib & 3))
| |||
return out
| |||
| |||
| |||
def field_count(path):
| |||
"""Read the first block's cfg width to learn how many fields the file carries."""
| |||
with open(path, 'rb') as fh:
| |||
head = fh.read(1 << 16)
| |||
p, _ = sqx.read_header(head)
| |||
p += 15 + 4
| |||
n = 0
| |||
while head[p + n] == 0xBB:
| |||
n += 1
| |||
return n * 2
| |||
| |||
| |||
def decode(path, nf=None, max_records=None):
| |||
"""-> (n, nf) int64 array of absolute values: time_ms, open, high, low, close, volume."""
| |||
nf = nf or field_count(path)
| |||
ncfg = (nf + 1) // 2
| |||
fh = open(path, 'rb')
| |||
head = fh.read(1 << 16)
| |||
start, _ = sqx.read_header(head)
| |||
fh.seek(start)
| |||
cur = [0] * nf
| |||
rows = []
| |||
buf = b''
| |||
pos = 0
| |||
CH = 1 << 22
| |||
eof = False
| |||
n = 0
| |||
need_max = ncfg + 8 * nf + 32
| |||
while True:
| |||
if len(buf) - pos < need_max and not eof:
| |||
buf = buf[pos:]; pos = 0
| |||
more = fh.read(CH)
| |||
if not more:
| |||
eof = True
| |||
buf += more
| |||
if len(buf) - pos < ncfg + 1:
| |||
break
| |||
if buf[pos:pos + 15] == MAGIC:
| |||
pos += 15 + 4 # magic + int32 block index
| |||
if len(buf) - pos < ncfg + 8 * nf:
| |||
break
| |||
pos += ncfg # anchor cfg, values are absolute
| |||
cur = list(struct.unpack_from(f'>{nf}q', buf, pos))
| |||
pos += 8 * nf
| |||
else:
| |||
cfg = buf[pos:pos + ncfg]
| |||
fs = _fields_n(cfg, nf)
| |||
size = ncfg + sum(_SIZE[d] for _, d in fs)
| |||
if len(buf) - pos < size:
| |||
if eof:
| |||
break
| |||
buf = buf[pos:]; pos = 0
| |||
more = fh.read(CH)
| |||
if not more:
| |||
eof = True
| |||
buf += more
| |||
if len(buf) < size:
| |||
break
| |||
continue
| |||
q = pos + ncfg
| |||
for k, (logic, dtp) in enumerate(fs):
| |||
sz = _SIZE[dtp]
| |||
raw = int.from_bytes(buf[q:q + sz], 'big', signed=(logic == ASIS))
| |||
q += sz
| |||
cur[k] += (-raw if logic == MINUS else raw)
| |||
pos = q
| |||
rows.append(tuple(cur))
| |||
n += 1
| |||
if max_records and n >= max_records:
| |||
break
| |||
fh.close()
| |||
return np.array(rows, dtype=np.int64), nf
| |||
| |||
| |||
def check_scale(close, key):
| |||
"""Refuse a decoded file whose prices do not land in the instrument's known range.
| |||
| |||
The scale is pinned in `catalog`, not fitted, because the reference series it used to be
| |||
fitted against no longer exists. That makes this check the only thing standing between a
| |||
silently rescaled price and every ATR-normalised feature downstream, so it is an
| |||
assertion rather than a warning. The neighbouring exponents are 10x away; nothing about
| |||
this call is ever marginal.
| |||
"""
| |||
lo, hi = catalog.px_range(key)
| |||
med = float(np.median(close))
| |||
if not (lo <= med <= hi):
| |||
raise ValueError(f"{catalog.nice(key)}: median close {med:,.4f} outside the known "
| |||
f"range [{lo:,}, {hi:,}] - scale 1e{catalog.DECIMALS} is wrong "
| |||
f"for this file")
| |||
return med
| |||
| |||
| |||
def load(key, tf='M1', verbose=True):
| |||
"""Decoded, scaled, sorted bars for a catalog key. Cached as .npz after the first call."""
| |||
os.makedirs(CACHE, exist_ok=True)
| |||
out = f"{CACHE}{catalog.nice(key)}_{tf}.npz"
| |||
if os.path.exists(out):
| |||
z = np.load(out)
| |||
return z['bars'], list(z['columns'])
| |||
path = catalog.path(key, tf)
| |||
if not os.path.exists(path):
| |||
raise FileNotFoundError(path)
| |||
a, nf = decode(path)
| |||
if nf < 6:
| |||
raise ValueError(f"{key}: {nf} fields, not a bar file")
| |||
s = 10.0 ** catalog.DECIMALS
| |||
bars = np.column_stack([a[:, 0].astype(np.float64), a[:, 1] / s, a[:, 2] / s,
| |||
a[:, 3] / s, a[:, 4] / s, a[:, 5].astype(np.float64)])
| |||
bars = bars[np.argsort(bars[:, 0], kind='stable')]
| |||
med = check_scale(bars[:, 4], key)
| |||
#--- structural sanity a scale check cannot do: a high below its low means the FIELD
| |||
#--- ORDER is wrong, which rescaling would never reveal
| |||
bad = int((bars[:, 2] < bars[:, 3]).sum())
| |||
if bad > len(bars) // 1000:
| |||
raise ValueError(f"{key}: {bad:,}/{len(bars):,} bars have high < low - field order")
| |||
if verbose:
| |||
import datetime as dt
| |||
print(f" {catalog.nice(key):>9} {tf}: {len(bars):>10,} bars "
| |||
f"{dt.datetime.fromtimestamp(bars[0,0]/1000, dt.UTC):%Y-%m-%d}.."
| |||
f"{dt.datetime.fromtimestamp(bars[-1,0]/1000, dt.UTC):%Y-%m-%d}"
| |||
f" median {med:>11,.4f} high<low {bad}")
| |||
np.savez_compressed(out, bars=bars, columns=np.array(COLS))
| |||
return bars, COLS
| |||
| |||
| |||
def _cross_feed_report():
| |||
"""The replication check that replaced the lost reference series.
| |||
| |||
Six of these markets are carried TWICE, by vendors that share no data path. Agreement on
| |||
the shared minutes is therefore an independent validation of the decoder AND of the
| |||
scale, in the same way matching the tick-derived SP500 once was - and it is the only
| |||
such check that survives the tick files being gone.
| |||
"""
| |||
print()
| |||
print(f"{'pair':>19}{'shared min':>13}{'corr':>10}{'median diff':>14}{'in bp':>9}")
| |||
for a, b in catalog.PAIRS:
| |||
try:
| |||
ba, _ = load(catalog.KEY[a], 'M1', verbose=False)
| |||
bb, _ = load(catalog.KEY[b], 'M1', verbose=False)
| |||
except Exception as ex:
| |||
print(f"{a+'/'+b:>19} SKIPPED: {ex}")
| |||
continue
| |||
_, ia, ib = np.intersect1d(ba[:, 0].astype(np.int64),
| |||
bb[:, 0].astype(np.int64), return_indices=True)
| |||
if len(ia) < 1000:
| |||
print(f"{a+'/'+b:>19}{len(ia):>13,} too few shared minutes to compare")
| |||
continue
| |||
ca, cb = ba[ia, 4], bb[ib, 4]
| |||
d = np.median(ca - cb)
| |||
print(f"{a+'/'+b:>19}{len(ia):>13,}{np.corrcoef(ca, cb)[0,1]:>10.6f}"
| |||
f"{d:>14.5f}{1e4*d/np.median(cb):>9.2f}")
| |||
| |||
| |||
if __name__ == '__main__':
| |||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
| |||
print("=== SQX BAR DECODER: build M1 cache for every catalog instrument ===")
| |||
for k in catalog.SYMS:
| |||
try:
| |||
tf = catalog.base_tf(k)
| |||
load(k, tf)
| |||
except Exception as ex:
| |||
print(f" {catalog.nice(k):>9}: FAILED - {ex}")
| |||
_cross_feed_report()
|