123 lines
5.4 KiB
Python
123 lines
5.4 KiB
Python
|
|
"""Why does entering at a breakout do WORSE than entering at a random nearby bar?
|
||
|
|
|
||
|
|
The drift run found it in both directions at once: real minus local control is -0.045 R on
|
||
|
|
average and reaches -0.28 in a cell, for longs AND shorts. A directional edge cannot be
|
||
|
|
negative on both sides, so this is not about direction - it is about the MOMENT.
|
||
|
|
|
||
|
|
The suspect is barrier scaling. A breakout bar has an elevated ATR by construction. Stops and
|
||
|
|
targets are set at multiples of that ATR, so they are sized for a volatility that is, on
|
||
|
|
average, about to fall back. The trade then sits inside barriers that are too wide for the
|
||
|
|
market it is actually in.
|
||
|
|
|
||
|
|
This matters well beyond the research track: the EA sizes its own SL/TP from ATR presets, and
|
||
|
|
if ATR at a signal is systematically unrepresentative of the ATR that follows, every one of
|
||
|
|
those presets is mis-scaled in the same direction.
|
||
|
|
|
||
|
|
WHAT IS MEASURED
|
||
|
|
----------------
|
||
|
|
ratio ATR at entry / realised ATR over the holding window - >1 means the barriers were
|
||
|
|
set from a volatility that did not persist
|
||
|
|
unres fraction that never touched either barrier - the direct symptom of barriers too
|
||
|
|
wide for the market
|
||
|
|
held bars held
|
||
|
|
win fraction that reached the target
|
||
|
|
|
||
|
|
and then the fix that follows from the diagnosis: size the barriers from a SLOWER ATR
|
||
|
|
(a 100-bar average rather than a 14-bar one), which is not elevated at a breakout, and see
|
||
|
|
whether the real-minus-control gap closes.
|
||
|
|
"""
|
||
|
|
import numpy as np, sys
|
||
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||
|
|
import fills, book, wyckoff
|
||
|
|
|
||
|
|
SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500')
|
||
|
|
|
||
|
|
|
||
|
|
def trade(bk, f, bars, d, risk_src, mrisk, kR, H, step):
|
||
|
|
ok = (bars > 300) & (bars < f.n - 5) & np.isfinite(risk_src[bars]) & (risk_src[bars] > 0)
|
||
|
|
bars, d = bars[ok], d[ok]
|
||
|
|
start = f.i0[bars]
|
||
|
|
ent = np.where(d > 0, bk.ao[start], bk.bo[start])
|
||
|
|
risk = mrisk * risk_src[bars]
|
||
|
|
o = fills.simulate(bk, start, d, ent - d * risk, ent + d * kR * risk, H * step,
|
||
|
|
entry=fills.MARKET)
|
||
|
|
if o is None:
|
||
|
|
return None
|
||
|
|
o['indep'] = book.nonoverlap(o['idx'], o['exit_idx'] - o['idx'])
|
||
|
|
o['bars'] = bars[np.nonzero(o['filled'])[0][o['kept']]]
|
||
|
|
return o
|
||
|
|
|
||
|
|
|
||
|
|
def realised_atr(f, bars, H):
|
||
|
|
"""Mean true range over the H bars AFTER entry - the volatility the trade actually met."""
|
||
|
|
tr = np.maximum(f.h - f.l, np.maximum(np.abs(f.h - np.roll(f.c, 1)),
|
||
|
|
np.abs(f.l - np.roll(f.c, 1))))
|
||
|
|
tr[0] = f.h[0] - f.l[0]
|
||
|
|
cs = np.concatenate([[0.0], np.cumsum(tr)])
|
||
|
|
j = np.minimum(bars + H, len(tr))
|
||
|
|
return (cs[j] - cs[bars]) / np.maximum(j - bars, 1)
|
||
|
|
|
||
|
|
|
||
|
|
def describe(f, o, H, src):
|
||
|
|
R = o['R'][o['indep']]
|
||
|
|
b = o['bars'][o['indep']]
|
||
|
|
ratio = src[b] / np.maximum(realised_atr(f, b, H), 1e-12)
|
||
|
|
return (R.mean(), book.tstat(R), o['unresolved'], float(np.median(ratio)),
|
||
|
|
float(np.mean(o['bars_held'][o['indep']])), float((R > 0.5).mean()), len(R))
|
||
|
|
|
||
|
|
|
||
|
|
def run(sym, tf, mrisk=3.0, kR=2.0, H=200, seed=3, span=250, slow=100):
|
||
|
|
bk = fills.Book(sym)
|
||
|
|
f = book.frame(sym, tf, bk)
|
||
|
|
step = book.TF_SEC[tf] // 60
|
||
|
|
ev = wyckoff.breakouts(f, score=False)
|
||
|
|
if ev is None:
|
||
|
|
return None
|
||
|
|
fast = f.atr(14)
|
||
|
|
#--- a SLOW ATR is not elevated by the breakout bar itself
|
||
|
|
sl = f.atr(slow)
|
||
|
|
rng = np.random.default_rng(seed)
|
||
|
|
out = []
|
||
|
|
for name, src in (('atr14', fast), (f'atr{slow}', sl)):
|
||
|
|
for d0 in (+1, -1):
|
||
|
|
m = ev['d'] == d0
|
||
|
|
if m.sum() < 150:
|
||
|
|
continue
|
||
|
|
bars = ev['i'][m] + 1
|
||
|
|
ctl = np.clip(bars + rng.integers(-span, span + 1, len(bars)), 301, f.n - 6)
|
||
|
|
a = trade(bk, f, bars, np.full(m.sum(), d0), src, mrisk, kR, H, step)
|
||
|
|
b = trade(bk, f, ctl, np.full(m.sum(), d0), src, mrisk, kR, H, step)
|
||
|
|
if a is None or b is None:
|
||
|
|
continue
|
||
|
|
out.append((name, d0, describe(f, a, H, src), describe(f, b, H, src)))
|
||
|
|
return out
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
syms = [s for s in sys.argv[1:] if s in SYMS] or list(SYMS)
|
||
|
|
print("=== WHY BREAKOUT ENTRIES UNDERPERFORM NEARBY RANDOM ONES ===")
|
||
|
|
print(" 'ratio' = ATR used for the barriers / ATR actually realised. >1 means the")
|
||
|
|
print(" barriers were sized from a volatility that did not persist.\n")
|
||
|
|
print(f" {'sym':>7}{'tf':>4}{'sizedby':>9}{'side':>6}"
|
||
|
|
f"{'REAL':>9}{'ctrl':>9}{'gap':>9}"
|
||
|
|
f"{'ratio R':>9}{'ratio C':>9}{'unres R':>9}{'unres C':>9}{'win R':>8}{'win C':>8}")
|
||
|
|
gaps = {}
|
||
|
|
for sym in syms:
|
||
|
|
for tf in ('H1',):
|
||
|
|
r = run(sym, tf)
|
||
|
|
if not r:
|
||
|
|
continue
|
||
|
|
for name, d0, A, B in r:
|
||
|
|
gap = A[0] - B[0]
|
||
|
|
gaps.setdefault(name, []).append(gap)
|
||
|
|
print(f" {sym:>7}{tf:>4}{name:>9}{'long' if d0>0 else 'short':>6}"
|
||
|
|
f"{A[0]:>+9.4f}{B[0]:>+9.4f}{gap:>+9.4f}"
|
||
|
|
f"{A[3]:>9.2f}{B[3]:>9.2f}{100*A[2]:>8.1f}%{100*B[2]:>8.1f}%"
|
||
|
|
f"{100*A[5]:>7.1f}%{100*B[5]:>7.1f}%")
|
||
|
|
print()
|
||
|
|
for name, g in gaps.items():
|
||
|
|
g = np.array(g)
|
||
|
|
print(f" sized by {name:>7}: mean real-control {g.mean():+.4f}, "
|
||
|
|
f"positive {int((g>0).sum())}/{len(g)}")
|
||
|
|
print("\n If the gap closes when the barriers are sized from the slow ATR, the effect")
|
||
|
|
print(" was barrier mis-scaling at the signal bar, not the signal itself.")
|