"""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()