148 lines
7.1 KiB
Python
148 lines
7.1 KiB
Python
|
|
"""Does "cut losers, run winners" add expectancy - or only reshape it?
|
||
|
|
|
||
|
|
The claim under test is the most widely repeated one in trading: manage the trade, not the
|
||
|
|
entry. Cut losers early, let winners run, and you make more than you lose regardless of what
|
||
|
|
got you in.
|
||
|
|
|
||
|
|
Half of that is a theorem and half is an empirical question, and they must not be tested
|
||
|
|
together:
|
||
|
|
|
||
|
|
THEOREM On a martingale, E[X_tau] = X_0 for ANY stopping rule. No trailing stop, no
|
||
|
|
breakeven, no partial exit changes the MEAN. They change the SHAPE - many
|
||
|
|
small losses, rare large wins - which feels like an edge and is not one.
|
||
|
|
EMPIRICAL Markets are not exactly martingales. IF price persists once it is already
|
||
|
|
moving, then running winners DOES add expectancy, and a fixed 4-ATR take
|
||
|
|
profit is throwing that persistence away.
|
||
|
|
|
||
|
|
So the experiment is: run several exit rules over the SAME RANDOM ENTRIES. Random entries
|
||
|
|
have zero edge by construction, so:
|
||
|
|
|
||
|
|
every rule ties -> the theorem holds here, management is shape-only, and the
|
||
|
|
trader's maxim is folklore on this data
|
||
|
|
running winners wins -> real persistence, and the shipped 2:4 barrier is capping it
|
||
|
|
|
||
|
|
Random entries are what makes this decisive. On SIGNAL entries a difference between exit
|
||
|
|
rules could just be the signal; on random ones there is no signal to confuse it with.
|
||
|
|
|
||
|
|
Second question, aimed straight at the label: HOW MUCH is the take-profit cap discarding?
|
||
|
|
The network's label is the same for a +2R winner and a +20R winner, so if the favourable
|
||
|
|
excursion is fat-tailed, the target is destroying the very trades that pay for everything.
|
||
|
|
`MFE` here is maximum favourable excursion in R, measured on trades that were never stopped.
|
||
|
|
"""
|
||
|
|
import numpy as np, sys
|
||
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||
|
|
import fills, book
|
||
|
|
|
||
|
|
SYMS = ('SP500', 'XAUUSD', 'EURUSD', 'USDJPY')
|
||
|
|
|
||
|
|
|
||
|
|
def paths(bk, start, side, risk, horizon):
|
||
|
|
"""Per-trade excursion path in R, from the fill minute forward. Long uses the BID to
|
||
|
|
exit (favourable = bid rising); short uses the ASK. Same sides fills.py uses."""
|
||
|
|
n = bk.n
|
||
|
|
m = len(start)
|
||
|
|
mfe = np.zeros(m); mae = np.zeros(m)
|
||
|
|
ent = np.where(side > 0, bk.ao[start], bk.bo[start])
|
||
|
|
#--- walk the horizon once, tracking best and worst in R
|
||
|
|
best = np.zeros(m); worst = np.zeros(m)
|
||
|
|
for k in range(horizon + 1):
|
||
|
|
j = np.minimum(start + k, n - 1)
|
||
|
|
up = np.where(side > 0, (bk.bh[j] - ent), (ent - bk.al[j])) / risk
|
||
|
|
dn = np.where(side > 0, (bk.bl[j] - ent), (ent - bk.ah[j])) / risk
|
||
|
|
best = np.maximum(best, up)
|
||
|
|
worst = np.minimum(worst, dn)
|
||
|
|
return best, worst, ent
|
||
|
|
|
||
|
|
|
||
|
|
def run_rules(sym, tf='H1', mrisk=2.0, horizon_bars=200, n_trades=20000, seed=5,
|
||
|
|
commission_bp=0.32, swap_bp=0.0):
|
||
|
|
bk = fills.Book(sym)
|
||
|
|
f = book.frame(sym, tf, bk)
|
||
|
|
step = book.TF_SEC[tf] // 60
|
||
|
|
H = horizon_bars * step
|
||
|
|
atr = f.atr(14)
|
||
|
|
rng = np.random.default_rng(seed)
|
||
|
|
e = np.unique(rng.integers(300, f.n - horizon_bars - 5, n_trades))
|
||
|
|
e = e[np.isfinite(atr[e]) & (atr[e] > 0)]
|
||
|
|
side = np.where(rng.random(len(e)) < 0.5, 1, -1)
|
||
|
|
start = f.i0[e]
|
||
|
|
ref = f.c[e - 1]
|
||
|
|
risk = mrisk * atr[e]
|
||
|
|
out = {}
|
||
|
|
#--- FAR is a stand-in for "no take profit": 100R is never reached, so the trade can only
|
||
|
|
#--- end at its stop or at the horizon. That IS "let the winner run".
|
||
|
|
for name, kR in (('fixed TP 1R', 1.0), ('fixed TP 2R', 2.0), ('fixed TP 4R', 4.0),
|
||
|
|
('run winner (no TP)', 100.0)):
|
||
|
|
o = fills.simulate(bk, start, side, ref - side * risk, ref + side * kR * risk, H,
|
||
|
|
commission_bp=commission_bp,
|
||
|
|
swap_bp_long=swap_bp, swap_bp_short=swap_bp)
|
||
|
|
out[name] = o
|
||
|
|
return out, bk, start, side, risk, H, f, e
|
||
|
|
|
||
|
|
|
||
|
|
def trail(bk, start, side, risk, horizon, trail_R, commission_bp=0.32):
|
||
|
|
"""A trailing stop at `trail_R` behind the best price reached. The purest form of the
|
||
|
|
maxim: the loser is cut at a fixed distance and the winner is never taken profit on."""
|
||
|
|
n = bk.n
|
||
|
|
m = len(start)
|
||
|
|
ent = np.where(side > 0, bk.ao[start], bk.bo[start])
|
||
|
|
best = np.zeros(m)
|
||
|
|
R = np.full(m, np.nan)
|
||
|
|
live = np.ones(m, bool)
|
||
|
|
for k in range(horizon + 1):
|
||
|
|
j = np.minimum(start + k, n - 1)
|
||
|
|
#--- adverse extreme first: within one bar the stop is assumed hit before any further
|
||
|
|
#--- favourable extension, the same pessimistic tie convention used everywhere here
|
||
|
|
dn = np.where(side > 0, (bk.bl[j] - ent), (ent - bk.ah[j])) / risk
|
||
|
|
stop_at = best - trail_R
|
||
|
|
hit = live & (dn <= stop_at)
|
||
|
|
if hit.any():
|
||
|
|
R[hit] = stop_at[hit]
|
||
|
|
live[hit] = False
|
||
|
|
up = np.where(side > 0, (bk.bh[j] - ent), (ent - bk.al[j])) / risk
|
||
|
|
best = np.where(live, np.maximum(best, up), best)
|
||
|
|
if not live.any():
|
||
|
|
break
|
||
|
|
if live.any():
|
||
|
|
j = np.minimum(start[live] + horizon, n - 1)
|
||
|
|
px = np.where(side[live] > 0, bk.bc[j], bk.ac[j])
|
||
|
|
R[live] = (px - ent[live]) * side[live] / risk[live]
|
||
|
|
R = R - 2.0 * commission_bp * 1e-4 * ent / risk
|
||
|
|
return R
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == '__main__':
|
||
|
|
syms = [s for s in sys.argv[1:] if s in SYMS] or ['SP500', 'EURUSD']
|
||
|
|
print("=== EXIT RULES ON IDENTICAL RANDOM ENTRIES ===")
|
||
|
|
print(" Zero edge by construction, so every rule must tie at -cost unless price")
|
||
|
|
print(" genuinely persists. Stop is 2 ATR throughout; only the exit differs.\n")
|
||
|
|
for sym in syms:
|
||
|
|
out, bk, start, side, risk, H, f, e = run_rules(sym)
|
||
|
|
print(f" --- {sym} H1 ---")
|
||
|
|
print(f" {'rule':<22}{'n':>7}{'expR':>9}{'t':>7}{'win%':>8}{'avg win':>9}"
|
||
|
|
f"{'avg loss':>10}{'payoff':>8}")
|
||
|
|
for name, o in out.items():
|
||
|
|
R = o['R']
|
||
|
|
w = R > 0
|
||
|
|
aw = R[w].mean() if w.any() else 0.0
|
||
|
|
al = R[~w].mean() if (~w).any() else 0.0
|
||
|
|
print(f" {name:<22}{len(R):>7}{R.mean():>+9.4f}{book.tstat(R):>+7.2f}"
|
||
|
|
f"{100*w.mean():>7.1f}%{aw:>9.2f}{al:>10.2f}"
|
||
|
|
f"{abs(aw/al) if al else 0:>8.2f}")
|
||
|
|
for tR in (0.5, 1.0, 2.0):
|
||
|
|
R = trail(bk, start, side, risk, H, tR)
|
||
|
|
w = R > 0
|
||
|
|
aw = R[w].mean() if w.any() else 0.0
|
||
|
|
al = R[~w].mean() if (~w).any() else 0.0
|
||
|
|
print(f" {'trailing ' + str(tR) + 'R':<22}{len(R):>7}{R.mean():>+9.4f}"
|
||
|
|
f"{book.tstat(R):>+7.2f}{100*w.mean():>7.1f}%{aw:>9.2f}{al:>10.2f}"
|
||
|
|
f"{abs(aw/al) if al else 0:>8.2f}")
|
||
|
|
#--- how much does the shipped 4-ATR (=2R at a 2-ATR stop) cap actually discard?
|
||
|
|
best, worst, ent = paths(bk, start, side, risk, H)
|
||
|
|
print(f" MFE distribution (R): median {np.median(best):.2f} p90 {np.quantile(best,0.9):.2f}"
|
||
|
|
f" p99 {np.quantile(best,0.99):.2f} max {best.max():.1f}")
|
||
|
|
tot = best.sum()
|
||
|
|
for cap in (1.0, 2.0, 4.0):
|
||
|
|
print(f" a {cap:.0f}R cap keeps {100*np.minimum(best,cap).sum()/tot:5.1f}% of all"
|
||
|
|
f" favourable excursion; {100*(best>cap).mean():4.1f}% of trades exceed it")
|
||
|
|
print()
|