forked from mnbvc188199/Warrior_EA
- Implemented `catalog.py` to define instrument metadata and price scaling checks. - Developed `sqx.py` for decoding SQX tick .dat files, handling scaling and volume adjustments. - Created `sqxbars.py` to decode SQX bar files, ensuring price integrity against known ranges. - Introduced `volume_gate.py` for relative volume analysis on D1 bars, facilitating backtesting strategies.
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()
|