- Implemented sqx_audit.py to audit StrategyQuant X trade lists, focusing on performance metrics and cost analysis. - Created sqx_portfolio.py to evaluate portfolio performance based on uncorrelated components and their impact on risk and return. - Developed swing.py to analyze cost ratios across different holding periods and assess swing trading structures. - Introduced test_management.py to investigate the effectiveness of exit rules on random entries and their impact on expectancy.
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()
|