Spread is the one microstructure channel that survived the API audit - FX-available, and
genuinely historical in the tester ("During testing, the spread is not modeled but is taken
from historical data"), unlike swap (no history), signed tick flow (empty on FX) or depth
of market (absent on retail FX, never replayed).
test_spread.py measures four spread features against the triple-barrier label with the same
block-permutation null as test_volume.py. spr/atr - cost relative to the volatility a trade
must overcome - is the strongest reading anywhere in this project so far: significant on 5
of 8 instrument/geometry cells and 2-4x the magnitude of any volume feature.
Which immediately looked too good, because the label is computed WITH the spread charged
inside the barriers. A bar with high spread/ATR has its barriers shifted more adversely and
is mechanically likelier to resolve as a loss - so the feature would partly predict its own
cost model, which is not tradeable information.
Tested directly by relabelling at zero cost and re-measuring the identical feature:
EURUSD 2:3 +0.000655 -> +0.000485 (p 0.030 -> 0.066, loses significance)
EURUSD 1:2 +0.000626 -> +0.000399 (p 0.003 -> 0.017)
USDJPY 2:3 +0.000418 -> +0.000164 (never significant either way)
USDJPY 1:2 +0.000876 -> +0.000532 (p 0.003 -> 0.003)
XAUUSD 2:3 +0.000769 -> +0.000744 (p 0.027 -> 0.027)
XAUUSD 1:2 +0.000876 -> +0.000698 (p 0.003 -> 0.003)
So roughly 20-40% of it WAS the tautology, and the majority is not. What remains is a
volatility-regime reading: spread is near-fixed while ATR is not, so spr/atr is high
exactly when realised volatility is running below its own ATR estimate - which genuinely
predicts whether ATR-scaled barriers get reached at all.
Note what that does and does not buy. Like volume, spread is UNSIGNED: it informs Neutral
vs directional, never Buy vs Sell. It is the best-measured feature in this project and it
still cannot pick a side.
No EA changes in this commit - measurement only, and the MQL5 side already has an
uncompiled backlog.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
80 lines
3.8 KiB
Python
80 lines
3.8 KiB
Python
"""Does the spread series carry information about the barrier outcome?
|
|
|
|
Spread is the one microstructure channel that survived the API audit: it is FX-available,
|
|
and - unusually - it is genuinely historical in the Strategy Tester ("During testing, the
|
|
spread is not modeled but is taken from historical data"). Everything else that looked
|
|
promising is either absent on FX (signed tick flow, real volume), absent on retail FX and
|
|
never replayed in the tester (depth of market), or has no history at all (swap).
|
|
|
|
Same machinery and same discipline as test_volume.py: mutual information with the triple-
|
|
barrier label, block-permutation null sized to the barrier horizon, finite-sample bias
|
|
quoted rather than subtracted.
|
|
|
|
Note on what spread can and cannot be: it is UNSIGNED, like volume. A widening spread says
|
|
liquidity is withdrawing, not which way price will go. So the realistic hope here is a
|
|
regime/filter term - "is this a bar worth trading at all" - not a directional signal.
|
|
"""
|
|
import numpy as np, sys, time
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
from kit import load_rates, atr, sma, barrier_vec
|
|
from test_volume import rank_bin, mi_binned, block_perm_null_multi, NPERM
|
|
|
|
|
|
def build_spread_features(h, l, c, spr, a):
|
|
s = spr.astype(float)
|
|
base = sma(s, 50)
|
|
prev = np.roll(s, 1); prev[0] = s[0]
|
|
F, names = [], []
|
|
F.append(np.clip(np.where(base > 0, s / base, 1.0), 0, 5)); names.append("sprLevel s/sma50")
|
|
F.append(np.clip(np.where(prev > 0, (s - prev) / prev, 0.0), -5, 5)); names.append("sprChange")
|
|
# cost relative to the volatility the trade must overcome - the term that actually decides
|
|
# whether a setup is affordable, and the one that varies most across sessions
|
|
F.append(np.clip(np.where(a > 0, s / a, 0.0), 0, 5)); names.append("spr/atr (cost)")
|
|
# widening WHILE the bar travels: liquidity withdrawing into a move, a stress signature
|
|
rng_atr = np.where(a > 0, (h - l) / a, 0.0)
|
|
F.append(np.clip(np.where(base > 0, (s / base) * rng_atr, 0.0), 0, 5)); names.append("sprLevel x range")
|
|
return [np.nan_to_num(f) for f in F], names
|
|
|
|
|
|
def run(sym, tf, sl_m, tp_m, H):
|
|
t, o, h, l, c, v, spr = load_rates(sym, tf)
|
|
a = atr(h, l, c, 14)
|
|
an = np.where(a > 0, a, np.nan)
|
|
tick = np.nanmin(np.abs(np.diff(np.unique(np.round(c, 8)))))
|
|
sp = np.nanmedian(spr) * tick
|
|
if not np.isfinite(sp):
|
|
sp = 0.0
|
|
lab, valid = barrier_vec(h, l, c, an, sl_m, tp_m, H, sp)
|
|
F, names = build_spread_features(h, l, c, spr, an)
|
|
m = valid & np.isfinite(a) & (a > 0) & np.isfinite(spr) & (spr > 0)
|
|
m[:200] = False
|
|
m[-(H + 2):] = False
|
|
y = lab[m]
|
|
n = int(m.sum())
|
|
if n < 5000:
|
|
print(f"\n=== {sym} SL{sl_m}:TP{tp_m} - only {n} usable bars, skipped ===")
|
|
return
|
|
print(f"\n=== {sym} SL{sl_m}:TP{tp_m} H={H} n={n} "
|
|
f"median spread {np.nanmedian(spr):.1f} pts ({sp/np.nanmedian(a):.3f} ATR) ===")
|
|
print(f" finite-sample MI bias ~ 7/n = {7.0/n:.6f} nats")
|
|
xbs = [rank_bin(f[m]) for f in F]
|
|
obs = [mi_binned(xb, y) for xb in xbs]
|
|
nulls = block_perm_null_multi(xbs, y, H)
|
|
print(f"{'feature':<22}{'MI (nats)':>12}{'null mean':>12}{'null p95':>11}{'excess':>10}{'p':>8}")
|
|
for k, nm in enumerate(names):
|
|
null = nulls[k]
|
|
p = (1 + int((null >= obs[k]).sum())) / (NPERM + 1)
|
|
star = ' *' if p < 0.05 else ''
|
|
print(f"{nm:<22}{obs[k]:>12.6f}{null.mean():>12.6f}{np.quantile(null,0.95):>11.6f}"
|
|
f"{obs[k]-null.mean():>+10.6f}{p:>8.3f}{star}", flush=True)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
t0 = time.time()
|
|
for sym in ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500'):
|
|
for (s, p, H) in [(2, 3, 96), (1, 2, 64)]:
|
|
try:
|
|
run(sym, 16385, s, p, H)
|
|
except Exception as ex:
|
|
print(f"{sym} {s}:{p} FAILED {ex}")
|
|
print(f"\ntotal {time.time()-t0:.0f}s")
|