Warrior_EA/research/bidask.py
AnimateDread 3459db887b infra: bid/ask M1 bars and an order engine that makes the fill bug unavailable
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>
2026-08-02 00:08:30 -04:00

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}")