Three results today were invalidated by the same family of error: a price level used as an entry while the outcome was measured from somewhere else. The defence is not vigilance - it is an interface where the mistake cannot be expressed. bidask.py M1 bars carrying SEPARATE BID and ASK OHLC, built from the tick stream in one parallel pass. Every existing bar file stores MID, which is fine for measuring returns and useless for simulating orders, because no order ever executes at the mid. Mid bars force the spread to be bolted on afterwards as an average - the approximation that let today's artifacts through. With both sides carried, the spread is whatever it actually was, including the overnight and news blowouts an average hides. Fails loudly if ask < bid. fills.py owns the ENTIRE trade lifecycle; no test may open a position any other way. Longs enter at the ask and exit at the bid, shorts the reverse. A buy stop triggers on the ask and fills at the trigger price OR the bar's open if the bar gapped past it, which is where real slippage comes from. Limits fill on the opposite side and a gap is capped in their favour. The invariant that was violated: THE OUTCOME CLOCK STARTS AT THE FILL BAR. The fill index IS the start index - they are the same variable and cannot diverge. That is what went wrong before, and it is now unrepresentable rather than merely discouraged. Same-bar ambiguity is REPORTED, not assumed away: every result carries the fraction of trades decided by a bar containing both barriers, alongside fill rate and unresolved rate. A resolution-limited result now says so itself. M1 rather than raw ticks is a deliberate, stated bound: 513M ticks per symbol is ~8 GB packed and four symbols will not fit in memory, while M1 keeps it at ~500 MB and cuts the residual ambiguity 60x versus H1. It is an approximation with a visible error bar, not an exact simulator. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
153 lines
6.7 KiB
Python
153 lines
6.7 KiB
Python
"""M1 bars carrying SEPARATE BID and ASK OHLC - the substrate a correct fill model needs.
|
|
|
|
Every bar file in this project so far stores MID prices. That is fine for measuring returns
|
|
and useless for simulating orders, because no order ever executes at the mid:
|
|
|
|
a buy fills at the ASK a buy stop triggers when the ASK reaches it
|
|
a sell fills at the BID a sell stop triggers when the BID reaches it
|
|
|
|
Mid-price bars force you to bolt the spread on afterwards as an average, which is exactly
|
|
the approximation that let three separate fill artifacts through today. With bid and ask
|
|
carried separately the spread is whatever it actually was on that bar, including the
|
|
overnight and news blowouts that an average hides.
|
|
|
|
WHY M1 AND NOT RAW TICKS
|
|
------------------------
|
|
Raw ticks would be exact, but 513M ticks per symbol is ~8 GB even packed, and four symbols
|
|
will not sit in memory. M1 keeps the file at ~500 MB per symbol and shrinks the residual
|
|
same-bar ambiguity by 60x versus H1 and 5x versus M5 - and that ambiguity is the only thing
|
|
tick resolution buys here, because within one M1 bar on these instruments the ordering of a
|
|
stop and a target is rarely decisive. Stated plainly rather than assumed: this is a bounded
|
|
approximation, not an exact simulator, and `fills.py` reports how often a bar contained both
|
|
barriers so the bound is visible in every result.
|
|
"""
|
|
import numpy as np, sys, os, time
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
from sqx import decode_iter, split_offsets, calibrate_decimals, decode, find_block, NO_VOLUME
|
|
|
|
OUT = 'c:/Users/admin/Documents/Workspaces/Market Data/bidask/'
|
|
COLS = ['time', 'bo', 'bh', 'bl', 'bc', 'ao', 'ah', 'al', 'ac', 'ticks']
|
|
_I = {c: k for k, c in enumerate(COLS)}
|
|
|
|
|
|
def _scan(args):
|
|
path, scale, step, start, stop = args
|
|
rows = []
|
|
pend = None
|
|
for ts, ai, bi, _v in decode_iter(path, start=start, stop=stop):
|
|
ask = ai / scale
|
|
bid = bi / scale
|
|
bucket = (ts // step) * step
|
|
s = np.concatenate(([0], np.flatnonzero(np.diff(bucket)) + 1))
|
|
e = np.concatenate((s[1:] - 1, [len(ts) - 1]))
|
|
part = np.column_stack([
|
|
bucket[s].astype(np.float64),
|
|
bid[s], np.maximum.reduceat(bid, s), np.minimum.reduceat(bid, s), bid[e],
|
|
ask[s], np.maximum.reduceat(ask, s), np.minimum.reduceat(ask, s), ask[e],
|
|
(e - s + 1).astype(np.float64)])
|
|
lst = [tuple(r) for r in part]
|
|
if pend is not None:
|
|
if lst and lst[0][0] == pend[0]:
|
|
lst[0] = _merge(pend, lst[0])
|
|
else:
|
|
rows.append(pend)
|
|
pend = lst.pop() if lst else pend
|
|
rows.extend(lst)
|
|
if pend is not None:
|
|
rows.append(pend)
|
|
return np.array(rows, np.float64) if rows else np.empty((0, len(COLS)))
|
|
|
|
|
|
def _merge(a, b):
|
|
o = list(a)
|
|
o[_I['bh']] = max(a[_I['bh']], b[_I['bh']]); o[_I['bl']] = min(a[_I['bl']], b[_I['bl']])
|
|
o[_I['ah']] = max(a[_I['ah']], b[_I['ah']]); o[_I['al']] = min(a[_I['al']], b[_I['al']])
|
|
o[_I['bc']] = b[_I['bc']]; o[_I['ac']] = b[_I['ac']]
|
|
o[_I['ticks']] = a[_I['ticks']] + b[_I['ticks']]
|
|
return tuple(o)
|
|
|
|
|
|
def finalise(arr):
|
|
if not len(arr):
|
|
return arr
|
|
t = arr[:, 0]
|
|
if (np.diff(t) < 0).any():
|
|
arr = arr[np.argsort(t, kind='stable')]; t = arr[:, 0]
|
|
if (np.diff(t) == 0).any():
|
|
s = np.concatenate(([0], np.flatnonzero(np.diff(t)) + 1))
|
|
e = np.concatenate((s[1:] - 1, [len(t) - 1]))
|
|
out = arr[s].copy()
|
|
out[:, _I['bh']] = np.maximum.reduceat(arr[:, _I['bh']], s)
|
|
out[:, _I['bl']] = np.minimum.reduceat(arr[:, _I['bl']], s)
|
|
out[:, _I['ah']] = np.maximum.reduceat(arr[:, _I['ah']], s)
|
|
out[:, _I['al']] = np.minimum.reduceat(arr[:, _I['al']], s)
|
|
out[:, _I['bc']] = arr[e, _I['bc']]; out[:, _I['ac']] = arr[e, _I['ac']]
|
|
out[:, _I['ticks']] = np.add.reduceat(arr[:, _I['ticks']], s)
|
|
arr = out
|
|
#--- a negative spread is impossible and means the scale or the field order is wrong;
|
|
#--- fail loudly rather than let it poison every fill downstream
|
|
bad = int((arr[:, _I['ac']] < arr[:, _I['bc']]).sum())
|
|
if bad > len(arr) // 1000:
|
|
raise RuntimeError(f"{bad:,}/{len(arr):,} bars have ask < bid - check the decode")
|
|
return arr
|
|
|
|
|
|
def build(path, sym, ref, tf_seconds=60, workers=None):
|
|
sts, sai, _, _ = decode(path, max_records=200000)
|
|
try:
|
|
dec, err = calibrate_decimals(sts, sai, ref[0], ref[1], verbose=False)
|
|
except ValueError:
|
|
size = os.path.getsize(path); dec = None
|
|
for frac in (0.5, 0.75, 0.9):
|
|
with open(path, 'rb') as fh:
|
|
off = find_block(fh, int(size * frac), size)
|
|
if off is None:
|
|
continue
|
|
it = decode_iter(path, batch=2000000, start=off)
|
|
sts, sai, _, _ = next(it); it.close()
|
|
try:
|
|
dec, err = calibrate_decimals(sts, sai, ref[0], ref[1], verbose=False); break
|
|
except ValueError:
|
|
continue
|
|
if dec is None:
|
|
raise
|
|
print(f" decimals={dec} (rel.err {err:.4%})", flush=True)
|
|
scale = 10.0 ** dec
|
|
workers = workers or max(1, (os.cpu_count() or 4) - 1)
|
|
ranges = split_offsets(path, workers)
|
|
t0 = time.time()
|
|
import multiprocessing as mp
|
|
args = [(path, scale, tf_seconds * 1000, s, e) for s, e in ranges]
|
|
parts = []
|
|
with mp.Pool(len(ranges)) as pool:
|
|
for k, p in enumerate(pool.imap(_scan, args)):
|
|
parts.append(p)
|
|
print(f" chunk {k+1}/{len(ranges)} {len(p):,} bars "
|
|
f"{(time.time()-t0)/60:.1f} min", flush=True)
|
|
arr = finalise(np.concatenate([p for p in parts if len(p)]))
|
|
sp = arr[:, _I['ac']] - arr[:, _I['bc']]
|
|
print(f" {len(arr):,} M1 bars median spread {np.median(sp):.6f} "
|
|
f"p95 {np.quantile(sp,0.95):.6f} {(time.time()-t0)/60:.1f} min")
|
|
return arr
|
|
|
|
|
|
if __name__ == '__main__':
|
|
from kit import load_rates
|
|
D = 'c:/Users/admin/Documents/Workspaces/Market Data/'
|
|
os.makedirs(OUT, exist_ok=True)
|
|
JOBS = [('EURUSD_tick_the5ers_TICK.dat', 'EURUSD'),
|
|
('USDJPY_tick_the5ers_TICK.dat', 'USDJPY'),
|
|
('XAUUSD_tick_the5ers_TICK.dat', 'XAUUSD'),
|
|
('USA500IDXUSD_tick_the5ers_TICK.dat', 'SP500')]
|
|
only = sys.argv[1] if len(sys.argv) > 1 else None
|
|
for fn, sym in JOBS:
|
|
if only and sym != only:
|
|
continue
|
|
out = f"{OUT}{sym}_M1_bidask.npz"
|
|
if os.path.exists(out):
|
|
print(f"{sym}: exists, skipping"); continue
|
|
print(f"\n=== {sym} bid/ask M1 ===", flush=True)
|
|
rt, ro, rh, rl, rc, rv, rs = load_rates(sym, 16385)
|
|
arr = build(D + fn, sym, (rt, rc))
|
|
np.savez_compressed(out, bars=arr, columns=np.array(COLS))
|
|
print(f" saved {out}")
|