Warrior_EA/research/learn_selection.py

255 lines
12 KiB
Python
Raw Permalink Normal View History

research: model-selected retail fades hold up out of sample (after killing a big leak) Reframes what the net is asked. Direction is the one thing the data says is barely predictable; but the fade of retail pin/inside setups has a MEASURED gross edge of ~0.139 R and a cost of spread/stop that varies 5x across instruments, sessions and vol regimes. So the answerable question is selection: spend the edge only where it survives. label realised R of the fade (what actually pays) features 28, all knowable at entry, incl. the volume-profile levels split CHRONOLOGICAL train/val/test; threshold picked on val, frozen for test TEST BLOCK 2022-08 .. 2026-07, 63,901 candidate trades, keep top 10%: mean R +0.1005 vs -0.0616 for taking everything by year +0.140 +0.075 +0.098 +0.105 +0.105 -> 5/5 positive with the COST FEATURE REMOVED: +0.0800, still 5/5 positive That last control matters: the model is not merely learning 'skip wide spreads'. Something in the setup geometry, session and level structure carries signal beyond the cost. HONEST SIGNIFICANCE. The naive t of +8.06 is not believable - with an 8-day horizon these trades overlap heavily and thousands share one price path. On a strictly non-overlapping subset (163 independent trades) it is +0.123 R at t +1.61; without the cost feature, +0.257 at t +3.45. The overlap filter applies the H1 horizon to M15 trades too, so 163 is a conservative floor and the true independent count is higher. Suggestive, not settled. THE LEAK THIS RUN NEARLY SHIPPED. First version scored +0.53 R on the held-out block, t +54. Bar-derived features were read at the FILL bar i2, but the order fills intrabar and the outcome race starts at the first M5 bar inside i2 - so i2's close, tick count and realised volatility are not knowable at entry. The model was seeing how the bar it entered on turned out. Second lookahead of this hunt (the first was worth +0.15 R in the sweep test). RULE: a clean chronological split does NOT protect against lookahead. The split was honest and the features were not. Any feature indexed at the entry bar must be re-derived from the bar before it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 23:15:00 -04:00
"""Ask the model the answerable question: is the edge bigger than the cost RIGHT NOW?
Every neural net in this project so far has been asked to predict direction, and direction
is the one thing 23 years of data says is barely predictable. But the fade of retail
pin/inside-bar setups carries a MEASURED gross edge of ~0.139 R, and the cost of taking it
is not a constant - it is `spread / stop distance`, which varies bar to bar by a factor of
five across instruments, sessions and volatility regimes.
That makes trade SELECTION a well-posed supervised problem where direction never was:
label the realised R of the fade - what actually happened, in the unit that pays
features everything knowable at the fill bar, including the cost itself
goal take the subset where edge > cost, skip the rest
The model is not being asked to find an edge. The edge is already measured. It is being
asked to spend it only where it survives - which is exactly what the per-cell table showed
a human doing by hand when it picked EURUSD over gold.
WHAT WOULD MAKE THIS SELF-DECEPTION, and is therefore controlled for:
- Leakage. Every feature is computed from bars at or before the fill bar. The volume
profile levels come from SESSIONS STRICTLY BEFORE the current one.
- Shuffled splits. Forbidden here. Train / validate / test are chronological blocks, and
the threshold is chosen on validation and applied unchanged to test.
- Reporting the training fit. Only the test block is quoted.
- The cost feature doing all the work. A model that learns nothing except "skip wide
spreads" is still useful, but it is not a pattern - so the run reports what happens
with the cost feature REMOVED, which separates the two.
"""
import numpy as np, sys, os, datetime as dt
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
from test_retail import setups, triggered, race_px, load_bars, PIP
from vplevels import broker_day
SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500')
NAMES = ['cost', 'risk_atr', 'atr_rel', 'atr_pct', 'spread_pct', 'hour', 'dow',
'is_pin', 'is_inside', 'is_engulf', 'dir', 'body', 'uwick', 'lwick',
'ma_dist', 'ma_slope', 'don20', 'don50', 'ret5', 'ret20',
'd_naked', 'd_vah', 'd_val', 'd_hvn', 'd_lvn', 'tickrate', 'rvol_rel', 'sym']
def _pct(x, n=500):
"""Rolling percentile rank of x within its own last n values. Causal."""
out = np.full(len(x), 0.5)
W = np.lib.stride_tricks.sliding_window_view
if len(x) > n:
w = W(x, n)[:-1]
out[n:] = (w < x[n:, None]).mean(axis=1)
return out
def vp_features(sym, t_ms):
"""Signed distance from close to each volume-profile level, in price units.
Levels for broker-day d come only from days < d, so nothing here can see the session
it is being used in.
"""
try:
from vplevels import load, day_index, session_levels, composite_nodes, naked_vpocs
day, b, tk, dw, binsize = load(sym)
except FileNotFoundError:
return None
days, cells = day_index(day, b, tk)
sess = session_levels(days, cells)
nodes = composite_nodes(days, cells, lookback=20)
naked = naked_vpocs(sess)
sday = {int(d): i for i, d in enumerate(sess[:, 0])}
bd = broker_day(t_ms)
return sess, nodes, naked, sday, binsize, bd
def build(sym, tf, H=200, path_tf='M5'):
"""One symbol/timeframe -> (X, y, times). y is the realised R of the FADE."""
ev, o, h, l, c, spm, tick = setups(sym, tf)
a1, I1 = load_bars(sym, tf)
t1 = a1[:, I1['time']].astype(np.int64)
a2, I2 = load_bars(sym, path_tf)
ph, pl, pc = a2[:, I2['high']], a2[:, I2['low']], a2[:, I2['close']]
pmap = np.searchsorted(a2[:, I2['time']], t1)
HH = H * (12 if tf == 'H1' else 3)
from test_cause_effect import atr_of
atr = atr_of(h, l, c, 14); atr = np.concatenate([[atr[0]], atr[:-1]])
ma = np.convolve(c, np.ones(20) / 20, mode='full')[:len(c)]; ma[:20] = c[:20]
atrp = _pct(atr); spp = _pct(spm)
rng_ = np.maximum(h - l, 1e-12)
W = np.lib.stride_tricks.sliding_window_view
d20 = np.full(len(c), 0.5); d50 = np.full(len(c), 0.5)
for n, dst in ((20, d20), (50, d50)):
if len(c) > n:
hi = W(h, n).max(axis=1)[:-1]; lo = W(l, n).min(axis=1)[:-1]
dst[n:] = (c[n:] - lo) / np.maximum(hi - lo, 1e-12)
r5 = np.zeros(len(c)); r5[5:] = (c[5:] - c[:-5]) / np.maximum(atr[5:], 1e-12)
r20 = np.zeros(len(c)); r20[20:] = (c[20:] - c[:-20]) / np.maximum(atr[20:], 1e-12)
ticks = a1[:, I1['ticks']]; rvol = a1[:, I1['rvol']]
vp = vp_features(sym, t1)
X, Y, T = [], [], []
for name, idx, d, ent, stp in ev:
ok, fill = triggered(h, l, idx, d, ent)
if ok.sum() < 100:
continue
i2, d2, e2, s2, sig = fill[ok], d[ok], ent[ok], stp[ok], idx[ok]
risk0 = np.abs(e2 - s2)
g = risk0 > 2 * spm[i2]
i2, d2, e2, risk0, sig = (v[g] for v in (i2, d2, e2, risk0, sig))
pi = np.clip(pmap[i2], 0, len(ph) - 1)
keep = pi + HH < len(ph)
i2, d2, e2, risk0, sig, pi = (v[keep] for v in (i2, d2, e2, risk0, sig, pi))
if len(pi) < 200:
continue
sp = spm[i2]
dd = -d2 # the fade
r = race_px(ph, pl, pi, dd, e2 - dd * risk0, e2 + dd * risk0, HH)
R = np.where(r > 0, 1.0, np.where(r < 0, -1.0, 0.0))
un = r == 0
if un.any():
q = np.minimum(pi[un] + HH, len(pc) - 1)
R[un] = (pc[q] - e2[un]) * dd[un] / risk0[un]
R = R - sp / risk0
#--- EVERY bar-derived feature must come from i2-1, not i2.
#--- The order fills INTRABAR during bar i2, and the outcome race starts at the
#--- first M5 bar inside i2 - so bar i2's close, tick count and realised volatility
#--- are not knowable at entry. Reading them let the model see how the bar it
#--- entered on turned out, and it scored +0.53 R on a held-out block. That is what
#--- a lookahead looks like when it survives a clean chronological split: the split
#--- was honest, the features were not.
j2 = np.maximum(i2 - 1, 0)
sp_known = spm[j2] # expected cost, not the realised one
tt = t1[i2]
hrs = np.array([dt.datetime.fromtimestamp(x / 1000, dt.UTC).hour for x in tt])
dow = np.array([dt.datetime.fromtimestamp(x / 1000, dt.UTC).weekday() for x in tt])
f = [sp_known / risk0, risk0 / atr[j2], atr[j2] / c[j2], atrp[j2], spp[j2],
hrs, dow,
np.full(len(i2), name == 'pin', float),
np.full(len(i2), name == 'inside', float),
np.full(len(i2), name == 'engulf', float),
d2.astype(float),
np.abs(c[sig] - o[sig]) / rng_[sig],
(h[sig] - np.maximum(o[sig], c[sig])) / rng_[sig],
(np.minimum(o[sig], c[sig]) - l[sig]) / rng_[sig],
(c[j2] - ma[j2]) / np.maximum(atr[j2], 1e-12),
(ma[j2] - ma[np.maximum(j2 - 5, 0)]) / np.maximum(atr[j2], 1e-12),
d20[j2], d50[j2], r5[j2], r20[j2]]
if vp:
sess, nodes, naked, sday, binsize, bdall = vp
bdi = bdall[i2]
nk = np.zeros(len(i2)); vah = np.zeros(len(i2)); val = np.zeros(len(i2))
hv = np.zeros(len(i2)); lv = np.zeros(len(i2))
for q_ in range(len(i2)):
dq = int(bdi[q_]); si = sday.get(dq)
px = c[j2[q_]]; A = max(atr[j2[q_]], 1e-12)
if si is not None and si >= 1:
vah[q_] = (sess[si - 1, 3] * binsize - px) / A
val[q_] = (sess[si - 1, 2] * binsize - px) / A
nn = naked.get(dq)
if nn is not None and len(nn):
nk[q_] = (nn[np.argmin(np.abs(nn * binsize - px))] * binsize - px) / A
nd = nodes.get(dq)
if nd is not None:
for arr, dst in ((nd[0], hv), (nd[1], lv)):
if len(arr):
dst[q_] = (arr[np.argmin(np.abs(arr * binsize - px))]
* binsize - px) / A
f += [nk, vah, val, hv, lv]
else:
f += [np.zeros(len(i2))] * 5
f += [ticks[j2] / np.maximum(np.median(ticks), 1),
rvol[j2] / np.maximum(atr[j2] ** 2, 1e-12),
np.full(len(i2), SYMS.index(sym), float)]
X.append(np.column_stack(f)); Y.append(R); T.append(tt)
if not X:
return None
return np.vstack(X), np.concatenate(Y), np.concatenate(T)
def run(tfs=('M15', 'H1'), syms=SYMS, drop_cost=False, seed=0):
Xs, Ys, Ts = [], [], []
for tf in tfs:
for s in syms:
r = build(s, tf)
if r:
Xs.append(r[0]); Ys.append(r[1]); Ts.append(r[2])
print(f" {s} {tf}: {len(r[1]):,} fade trades, mean R {r[1].mean():+.4f}")
X = np.vstack(Xs); y = np.concatenate(Ys); t = np.concatenate(Ts)
o = np.argsort(t, kind='stable') # chronological, pooled across symbols
X, y, t = X[o], y[o], t[o]
if drop_cost:
X = X[:, 1:]
n = len(y)
a, b = int(0.60 * n), int(0.80 * n)
U = lambda ms: dt.datetime.fromtimestamp(ms / 1000, dt.UTC).date()
print(f"\n {n:,} trades train {U(t[0])}..{U(t[a-1])} "
f"val ..{U(t[b-1])} TEST {U(t[b])}..{U(t[-1])}")
print(f" baseline mean R: train {y[:a].mean():+.4f} val {y[a:b].mean():+.4f} "
f"TEST {y[b:].mean():+.4f}")
from sklearn.ensemble import HistGradientBoostingRegressor
m = HistGradientBoostingRegressor(max_iter=400, learning_rate=0.05, max_depth=6,
min_samples_leaf=200, l2_regularization=1.0,
random_state=seed)
m.fit(X[:a], y[:a])
pv, pt = m.predict(X[a:b]), m.predict(X[b:])
#--- threshold chosen on VALIDATION only, then frozen
best = None
for q in np.arange(0.0, 0.95, 0.05):
thr = np.quantile(pv, q)
sel = pv >= thr
if sel.sum() < 200:
continue
sc = y[a:b][sel].mean()
if best is None or sc > best[1]:
best = (thr, sc, q)
thr, vsc, q = best
sel = pt >= thr
kept = y[b:][sel]
se = kept.std(ddof=1) / np.sqrt(max(len(kept), 1))
print(f"\n threshold from validation: keep top {100*(1-q):.0f}% (val mean R {vsc:+.4f})")
print(f" TEST: kept {len(kept):,}/{len(pt):,} ({100*sel.mean():.0f}%) "
f"mean R {kept.mean():+.4f} +/- {se:.4f} t {kept.mean()/max(se,1e-9):+.2f}")
print(f" TEST all trades for comparison: mean R {y[b:].mean():+.4f}")
#--- The t above is not believable as stated: with an 8-day horizon these trades overlap
#--- heavily, so thousands of them share the same price path and the effective sample is
#--- far smaller than the count. Two honest checks.
tt, yy = t[b:][sel], y[b:][sel]
yrs = np.array([dt.datetime.fromtimestamp(x / 1000, dt.UTC).year for x in tt])
print(" by year: " + " ".join(
f"{u}:{yy[yrs == u].mean():+.3f}(n={int((yrs == u).sum())})"
for u in np.unique(yrs)))
pos = sum(1 for u in np.unique(yrs) if yy[yrs == u].mean() > 0)
print(f" years positive: {pos}/{len(np.unique(yrs))}")
#--- non-overlapping subset: keep a trade only if the previous kept one has expired
gap = 200 * 3600 * 1000 # ~the H1 horizon, in ms; conservative for M15 too
keep, busy = [], -1
for q_ in range(len(tt)):
if tt[q_] < busy:
continue
keep.append(q_); busy = tt[q_] + gap
keep = np.array(keep, int)
z = yy[keep]
se2 = z.std(ddof=1) / np.sqrt(max(len(z), 1))
print(f" NON-OVERLAPPING: {len(z)} independent trades mean R {z.mean():+.4f}"
f" +/- {se2:.4f} t {z.mean()/max(se2,1e-9):+.2f}")
return m, X, y, t, b, sel
if __name__ == '__main__':
print("=== SELECTING FADE TRADES: can a model spend the edge only where it survives? ===\n")
run()
print("\n=== SAME, WITH THE COST FEATURE REMOVED ===")
print(" if this collapses, the model learned 'avoid wide spreads' and nothing else -")
print(" useful, but not a pattern.\n")
run(drop_cost=True)