Warrior_EA/research/test_volume.py

170 lines
7.5 KiB
Python
Raw Permalink Normal View History

feat(ai): widen the volume feature block from 1 value to 4 The block fed exactly one number: (v[i] - v[i-1]) / v[i-1]. That is the first difference, and it cannot express three things that matter - the LEVEL relative to a baseline (two dead bars and two frantic bars both read ~0 change), and the two volume-vs-range interactions, where heavy participation that went NOWHERE (absorption) and heavy participation that travelled (continuation) mean opposite things and currently collapse onto the same value. research/test_volume.py measures each candidate's mutual information with the triple- barrier label across 3 instruments x 2 geometries, against a BLOCK-permutation null - blocks sized to the barrier horizon, because adjacent labels share almost their entire outcome window and a free shuffle yields a null so tight that everything looks significant. Finite-sample MI bias (~7/n here) is reported alongside rather than subtracted, since the permutation null already absorbs it. Result: volLevel beats the shipped change ratio outright on 4 of 6 cells (EURUSD 2:3 +0.000118 excess at p=0.006, USDJPY 1:2 +0.000284 at p=0.002); absorption is the single strongest reading anywhere in the sweep at EURUSD 1:2 (+0.000404, p=0.002) though it is null on XAUUSD; vol x range clears on 4 of 6. The shipped change ratio is itself significant on 5 of 6, so it stays. Kept OUT: a session-relative z-score against the same hour-of-day's own recent history. It was the weakest candidate - null on both EURUSD cells - and it is the only one needing per-hour rolling bookkeeping in MQL5. Not worth the state for a reading that did not survive its own null on the primary instrument. Magnitudes, stated plainly because they are the point: the excess MI is ~2e-4 nats against a label entropy near 1.05. That is under a tenth of one percent of the label's uncertainty. It is real, it repeats across instruments, and it is nowhere near an edge - this is worth having because it costs one 50-bar loop, not because it changes the answer. Prior work stands: the whole single-series feature family measured at the noise floor. m_neuronsCount is already in the fingerprint, so the width change re-keys existing caches by itself, which is correct - the input vector genuinely changed shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:34:33 -04:00
"""Is the EA's single volume feature leaving information on the table?
The EA feeds exactly one volume value per bar:
volumeChangeRatio = (v[i] - v[i-1]) / v[i-1] (clamped +/-5)
That is the first difference. It cannot express the level relative to a baseline, it is
blind to the intraday volume profile - the strongest and most reliable pattern in FX tick
volume - and it cannot distinguish "high volume, big range" (continuation) from "high
volume, small range" (absorption), which mean opposite things.
This measures whether richer representations carry more information ABOUT THE LABEL than
the shipped one. Deliberately not a "fit a model and look for edge" test: this session has
twice produced a 4-sigma result that was a lookahead, so the question here is the narrower,
harder-to-fool one - does the feature share mutual information with the outcome, beyond
what its own finite-sample bias and the label's autocorrelation would produce anyway.
Null: BLOCK permutation of the labels, block length = the barrier horizon. Barrier labels
on adjacent bars share almost all of their outcome window, so a free shuffle destroys that
autocorrelation and produces a null that is far too tight - every feature then looks
significant. Blocks preserve it.
Bias: MI of independent variables is not 0 in finite samples but approximately
(bins-1)(classes-1)/(2n). With 8 bins and 3 classes that is 7/n. Reported, not subtracted -
the permutation null already absorbs it, and quoting both is what makes the number readable.
"""
import numpy as np, sys, time
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from kit import load_rates, atr, sma, barrier_vec
BINS = 8
NPERM = 500
def rank_bin(x, bins=BINS):
"""Equal-frequency (rank) binning, so the estimate does not depend on the feature's
marginal shape - only on how the label sorts across it. Done ONCE per feature: the
bin assignment does not change when the LABELS are permuted, and re-deriving it inside
the permutation loop was what made the first version of this unrunnable."""
n = len(x)
r = np.argsort(np.argsort(x))
return (r * bins // n).clip(0, bins - 1).astype(np.int64)
def mi_binned(xb, y, bins=BINS):
"""MI from a pre-binned feature. Contingency table via one bincount - O(n), no masks."""
n = len(y)
if n < 100:
return 0.0
joint = np.bincount(xb * 3 + y, minlength=bins * 3).reshape(bins, 3).astype(float) / n
px = joint.sum(axis=1, keepdims=True)
py = joint.sum(axis=0, keepdims=True)
nz = joint > 0
return float((joint[nz] * np.log(joint[nz] / (px @ py)[nz])).sum())
def block_perm_null_multi(xbs, y, horizon, nperm=NPERM, seed=0):
"""Shuffle BLOCKS of labels, preserving within-block order.
One permutation is applied to EVERY feature before drawing the next, so all features
see the identical null draws. That makes their nulls comparable (and is what a
family-wise max-statistic would need), at the cost of correlating them - which is fine
here because each feature is judged against its own null, not against the others.
"""
n = len(y)
blk = max(int(horizon), 1)
nb = (n + blk - 1) // blk
rng = np.random.default_rng(seed)
out = np.empty((len(xbs), nperm))
starts = np.arange(nb) * blk
for b in range(nperm):
order = rng.permutation(nb)
# gather block start offsets in shuffled order, then expand to indices
perm = np.concatenate([np.arange(starts[o], min(starts[o] + blk, n)) for o in order])[:n]
yp = y[perm]
for f in range(len(xbs)):
out[f, b] = mi_binned(xbs[f][:len(yp)], yp)
return out
def session_z(v, hours, lookback_days=20):
"""Volume z-score against the SAME hour-of-day's own recent history.
Causal by construction: for each bar, statistics come only from previous occurrences
of that hour. This is the feature the EA has no way to express - the network currently
has to infer the intraday profile from the hour sin/cos features and combine it with a
raw volume ratio itself.
"""
n = len(v)
out = np.zeros(n)
for h in range(24):
m = np.nonzero(hours == h)[0]
if len(m) < 5:
continue
vh = v[m].astype(float)
# trailing mean/sd over the last `lookback_days` occurrences of this hour, shifted
# by one so the current bar never contributes to its own baseline
cs = np.concatenate([[0.0], np.cumsum(vh)])
cs2 = np.concatenate([[0.0], np.cumsum(vh * vh)])
for k in range(len(m)):
lo = max(0, k - lookback_days)
cnt = k - lo
if cnt < 5:
continue
s = cs[k] - cs[lo]
s2 = cs2[k] - cs2[lo]
mu = s / cnt
var = max(s2 / cnt - mu * mu, 1e-12)
out[m[k]] = (vh[k] - mu) / np.sqrt(var)
return np.clip(out, -5, 5)
def build_features(o, h, l, c, v, t):
a = atr(h, l, c, 14)
a = np.where(a > 0, a, np.nan)
hours = np.array([(int(x) // 3600) % 24 for x in t])
prev = np.roll(v, 1).astype(float); prev[0] = v[0]
base = sma(v.astype(float), 50)
rng_atr = (h - l) / a
vlevel = np.where(base > 0, v / base, 1.0)
F, names = [], []
F.append(np.clip(np.where(prev > 0, (v - prev) / prev, 0.0), -5, 5)); names.append("volChange (SHIPPED)")
F.append(np.clip(vlevel, 0, 5)); names.append("volLevel v/sma50")
F.append(session_z(v, hours)); names.append("volSessionZ")
# absorption: range delivered per unit of volume. Low = lots of activity, little travel.
F.append(np.clip(np.where(vlevel > 0.05, rng_atr / vlevel, 0.0), 0, 5)); names.append("absorption rng/vol")
# the interaction the shipped feature cannot express at all
F.append(np.clip(vlevel * rng_atr, 0, 5)); names.append("vol 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)
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, np.where(a > 0, a, np.nan), sl_m, tp_m, H, sp)
F, names = build_features(o, h, l, c, v, t)
m = valid & np.isfinite(a) & (a > 0)
m[:200] = False
m[-(H + 2):] = False
y = lab[m]
n = int(m.sum())
print(f"\n=== {sym} SL{sl_m}:TP{tp_m} H={H} n={n} "
f"labels Buy {100*(y==0).mean():.1f}% Sell {100*(y==1).mean():.1f}% Neu {100*(y==2).mean():.1f}% ===")
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) # Phipson & Smyth
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'):
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")