"""One substrate for signals and fills, so the two can never disagree. Every earlier test in this project computed its signal on one set of bars (mid prices, built by `ticks_to_bars.py`) and then raced the outcome on another (M5 mid bars), bolting on an average spread at the end. Three of the four results retracted on 2026-08-02 lived in the seam between those two representations. Here there is one source of truth - the M1 BID/ASK book from `bidask.py` - and everything else is derived from it: HTF bars mid OHLC aggregated from the same M1 bars, for computing signals only i0[k] the index in the M1 book of the FIRST minute of HTF bar k so `i0[e]` is, exactly and by construction, the first minute that can be traded after HTF bar `e-1` has closed. A test computes its signal on bars up to `e-1`, hands `i0[e]` to `fills.simulate` as the start index, and there is no arithmetic left in which to hide a lookahead. WHY MID BARS FOR SIGNALS ------------------------ A signal is a statement about where the market is, and the mid is the least arbitrary answer. Using the bid (or the ask) would make every long and short setup asymmetric by half a spread for reasons that have nothing to do with the hypothesis. Execution is where the spread is paid, and execution is `fills.py`'s job, on the real bid and ask. BROKER TIME ----------- Timestamps are broker time (UTC+2, no DST in the tick files). Hour and 15-minute buckets are unaffected by the offset; the daily bucket is not, so D1 uses an explicit offset and any session/hour analysis must state which clock it means. """ import numpy as np, os, sys BIDASK = 'c:/Users/admin/Documents/Workspaces/Market Data/bidask/' CACHE = 'c:/Users/admin/Documents/Workspaces/Market Data/htf/' SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500') TF_SEC = {'M1': 60, 'M5': 300, 'M15': 900, 'M30': 1800, 'H1': 3600, 'H4': 14400, 'D1': 86400} #--- broker time is UTC+2 and the trading day rolls at 00:00 broker, so no shift is needed #--- for D1 once timestamps are already broker time. Kept explicit rather than assumed. D1_OFFSET_SEC = 0 class Frame: """Mid OHLC on one timeframe, plus the M1 index each bar starts at.""" __slots__ = ('sym', 'tf', 't', 'o', 'h', 'l', 'c', 'v', 'i0', 'n', 'spread') def __init__(self, sym, tf, t, o, h, l, c, v, i0, spread): self.sym, self.tf = sym, tf self.t, self.o, self.h, self.l, self.c, self.v = t, o, h, l, c, v self.i0 = i0 self.spread = spread # mean ask-bid over the bar, for cost bookkeeping only self.n = len(t) def last_i0(self, k): """M1 index of the LAST minute of HTF bar k. NOT `i0[k] + bars_per_htf - 1`. The M1 array is not dense - weekends, holidays and session gaps are simply absent - so adding 59 array positions to an hourly bar can land DAYS later. That produced a Monday->Friday trade reporting 7 nights held on 2026-08-02, and silently exited trades at the wrong price. The next bar's start minus one is the only correct answer, because it is defined by the index, not by arithmetic on an assumed bar count. """ k = np.asarray(k) nxt = np.minimum(k + 1, self.n - 1) out = np.where(k + 1 < self.n, self.i0[nxt] - 1, self.i0[k]) return np.maximum(out, self.i0[k]) def atr(self, n=14, shift=True): """ATR ending at the PREVIOUS bar by default - usable at bar i's open.""" a = _atr(self.h, self.l, self.c, n) return np.concatenate([[a[0]], a[:-1]]) if shift else a def _atr(h, l, c, n=14): pc = np.roll(c, 1); pc[0] = c[0] tr = np.maximum(h - l, np.maximum(np.abs(h - pc), np.abs(l - pc))) out = np.convolve(tr, np.ones(n) / n, mode='full')[:len(tr)] out[:n] = tr[:n].mean() return out def _resample(bk, tf): step = TF_SEC[tf] * 1000 off = (D1_OFFSET_SEC * 1000) if tf == 'D1' else 0 key = (bk.t - off) // step s = np.concatenate(([0], np.flatnonzero(np.diff(key)) + 1)) e = np.concatenate((s[1:] - 1, [bk.n - 1])) mo = 0.5 * (bk.bo + bk.ao); mh = 0.5 * (bk.bh + bk.ah) ml = 0.5 * (bk.bl + bk.al); mc = 0.5 * (bk.bc + bk.ac) t = (key[s] * step + off).astype(np.int64) o = mo[s] h = np.maximum.reduceat(mh, s) l = np.minimum.reduceat(ml, s) c = mc[e] v = (e - s + 1).astype(np.float64) # minutes traded, a volume proxy sp = bk.ac - bk.bc spm = np.add.reduceat(sp, s) / v return t, o, h, l, c, v, s.astype(np.int64), spm def frame(sym, tf, bk=None): """HTF mid bars for `sym`, cached on disk. Pass an open Book to avoid reloading it.""" if tf == 'M1' and bk is not None: mo = 0.5 * (bk.bo + bk.ao); mh = 0.5 * (bk.bh + bk.ah) ml = 0.5 * (bk.bl + bk.al); mc = 0.5 * (bk.bc + bk.ac) return Frame(sym, tf, bk.t, mo, mh, ml, mc, np.ones(bk.n), np.arange(bk.n, dtype=np.int64), bk.ac - bk.bc) os.makedirs(CACHE, exist_ok=True) fn = f"{CACHE}{sym}_{tf}_mid.npz" if os.path.exists(fn): z = np.load(fn) return Frame(sym, tf, z['t'], z['o'], z['h'], z['l'], z['c'], z['v'], z['i0'], z['spread']) import fills bk = bk or fills.Book(sym) t, o, h, l, c, v, i0, sp = _resample(bk, tf) np.savez_compressed(fn, t=t, o=o, h=h, l=l, c=c, v=v, i0=i0, spread=sp) return Frame(sym, tf, t, o, h, l, c, v, i0, sp) def rolling_span(h, l, L): """(max high, min low) over the L bars ENDING AT i-1. Never includes bar i.""" from numpy.lib.stride_tricks import sliding_window_view n = len(h) hi = np.full(n, np.nan); lo = np.full(n, np.nan) if n > L: hi[L:] = sliding_window_view(h, L).max(axis=1)[:-1] lo[L:] = sliding_window_view(l, L).min(axis=1)[:-1] return hi, lo def find_ranges(h, l, atr, Ls=(12, 18, 24, 36, 48, 72, 96, 144), theta=0.60): """Longest qualifying consolidation window ending at i-1, per bar. Same definition as `test_cause_effect.find_ranges` so results here remain comparable to the earlier work.""" n = len(h) L_out = np.zeros(n, np.int32) hi_out = np.full(n, np.nan); lo_out = np.full(n, np.nan) for L in sorted(Ls, reverse=True): hi, lo = rolling_span(h, l, L) comp = (hi - lo) / np.maximum(atr * np.sqrt(L), 1e-12) ok = (L_out == 0) & np.isfinite(comp) & (comp <= theta) L_out[ok] = L; hi_out[ok] = hi[ok]; lo_out[ok] = lo[ok] return L_out, hi_out, lo_out def nonoverlap(idx, span): """Keep a maximal set of events whose [i, i+span) windows do not intersect. Overlapping trades share price path, so their outcomes are correlated and a naive t on them is inflated - 6,197 overlapping trades once gave t +8.06 where 163 independent ones gave +1.61. Every headline number in this file is computed on the independent subset. """ idx = np.asarray(idx); span = np.asarray(span) order = np.argsort(idx, kind='stable') keep = np.zeros(len(idx), bool) busy = -1 for q in order: if idx[q] > busy: keep[q] = True busy = idx[q] + int(span[q]) return keep def tstat(x): x = np.asarray(x, float) if len(x) < 3: return 0.0 se = x.std(ddof=1) / np.sqrt(len(x)) return float(x.mean() / max(se, 1e-12)) def slope_t(y, x): """OLS slope of y on x and its t - the dose-response statistic.""" x = np.asarray(x, float); y = np.asarray(y, float) if len(x) < 5 or x.std() < 1e-9: return 0.0, 0.0 X = np.column_stack([np.ones(len(x)), x]) beta, *_ = np.linalg.lstsq(X, y, rcond=None) resid = y - X @ beta s2 = resid @ resid / max(len(x) - 2, 1) se = np.sqrt(s2 * np.linalg.inv(X.T @ X)[1, 1]) return float(beta[1]), float(beta[1] / max(se, 1e-12)) if __name__ == '__main__': sys.stdout.reconfigure(encoding='utf-8', errors='replace') import fills, datetime as dt only = [s for s in sys.argv[1:] if s in SYMS] or list(SYMS) for s in only: bk = fills.Book(s) print(f"\n=== {s} === M1 bars {bk.n:,}") for tf in ('M5', 'M15', 'H1', 'H4', 'D1'): f = frame(s, tf, bk) a = dt.datetime.fromtimestamp(f.t[0] / 1000, dt.UTC) b = dt.datetime.fromtimestamp(f.t[-1] / 1000, dt.UTC) #--- i0 must be strictly increasing and index the right minute, or every fill #--- in every test built on this is off by an unknown amount assert (np.diff(f.i0) > 0).all(), f"{s} {tf}: i0 not increasing" #--- f.t is the BUCKET START; the first minute that actually traded inside it #--- can be later (a quiet Sunday open, a holiday). Containment is the invariant #--- that matters: i0 must point inside its own bucket and nowhere else. step = TF_SEC[tf] * 1000 off = bk.t[f.i0] - f.t assert (off >= 0).all() and (off < step).all(), \ f"{s} {tf}: i0 outside its bucket (max off {off.max()/1000:.0f}s)" print(f" {tf:>3} {f.n:>9,} bars {a:%Y-%m-%d}..{b:%Y-%m-%d}" f" median spread/ATR14 " f"{np.nanmedian(f.spread / np.maximum(f.atr(), 1e-12)):.4f}")