- 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.
187 lines
8.7 KiB
Python
187 lines
8.7 KiB
Python
"""Audit a StrategyQuant X trade list on its own terms.
|
|
|
|
The question is not "is the equity curve up" - it is up. The questions that decide whether an
|
|
equity curve is an EDGE are:
|
|
|
|
1. Is the per-trade expectancy distinguishable from zero, on the OUT-OF-SAMPLE segment alone?
|
|
SQX searches an enormous number of candidates and reports the survivors, so the in-sample
|
|
numbers carry no information at all about the next trade. Only OOS does, and only if it was
|
|
never used for selection.
|
|
2. What is the return relative to the risk actually taken, and to the obvious alternative
|
|
(holding the instrument)?
|
|
3. Does it survive the cost that was not charged? SQX models spread; commission and financing
|
|
have to be checked separately, and this project has already found commission ~= spread.
|
|
4. Would it survive the account rules it is meant to be traded under?
|
|
|
|
Nothing here is a criticism of SQX. It is the same protocol every family in research/ was held to.
|
|
"""
|
|
import numpy as np, sys, datetime as dt
|
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
|
|
|
PATH = ('C:/Users/admin/AppData/Local/Temp/2/claude/'
|
|
'c--Users-admin-Documents-Workspaces-Warrior-EA/'
|
|
'bac693d9-0f8d-41a4-ab36-865894af56f2/scratchpad/sqx_trades.csv')
|
|
START_BALANCE = 5000.0
|
|
#--- the5ers-style limits the strategy would have to live inside
|
|
DAILY_LIMIT_PCT, TOTAL_LIMIT_PCT = 4.0, 8.0
|
|
#--- cost not charged by the backtest, per side, in index points of an SP500 CFD
|
|
COMMISSION_PT = 0.30 # ~ typical CFD commission per side expressed in points at 5-6k index
|
|
SWAP_PT_NIGHT = 0.55 # ~7.5%/yr financing on a 6000 index = ~1.23 pt/night; long side only
|
|
|
|
|
|
def load():
|
|
rows = []
|
|
with open(PATH, encoding='utf-8') as f:
|
|
head = f.readline().strip().split(';')
|
|
for line in f:
|
|
p = line.strip().split(';')
|
|
if len(p) < 15:
|
|
continue
|
|
rows.append(dict(zip(head, p)))
|
|
for r in rows:
|
|
r['OpenTime'] = dt.datetime.strptime(r['OpenTime'], '%Y.%m.%d %H:%M:%S')
|
|
r['CloseTime'] = dt.datetime.strptime(r['CloseTime'], '%Y.%m.%d %H:%M:%S')
|
|
for k in ('OpenPrice', 'Size', 'ClosePrice', 'PL', 'Balance', 'MAE', 'MFE'):
|
|
r[k] = float(r[k])
|
|
rows.sort(key=lambda r: r['CloseTime'])
|
|
return rows
|
|
|
|
|
|
def tstat(x):
|
|
x = np.asarray(x, float)
|
|
if len(x) < 3:
|
|
return 0.0
|
|
return float(x.mean() / max(x.std(ddof=1) / np.sqrt(len(x)), 1e-12))
|
|
|
|
|
|
def block_of(r):
|
|
return r['Sample']
|
|
|
|
|
|
def segment_stats(rows, label):
|
|
pl = np.array([r['PL'] for r in rows])
|
|
n = len(pl)
|
|
if n == 0:
|
|
return
|
|
wins = pl[pl > 0]
|
|
loss = pl[pl <= 0]
|
|
yrs = (rows[-1]['CloseTime'] - rows[0]['OpenTime']).days / 365.25
|
|
print(f" {label:<12}{n:>5}{pl.sum():>11.0f}{pl.mean():>9.2f}{tstat(pl):>8.2f}"
|
|
f"{100.0*len(wins)/n:>8.1f}%{wins.mean() if len(wins) else 0:>9.1f}"
|
|
f"{loss.mean() if len(loss) else 0:>9.1f}{n/max(yrs,0.01):>8.1f}")
|
|
|
|
|
|
def equity_path(rows, start=START_BALANCE):
|
|
eq = [start]
|
|
for r in rows:
|
|
eq.append(eq[-1] + r['PL'])
|
|
return np.array(eq)
|
|
|
|
|
|
def drawdown(eq):
|
|
peak = np.maximum.accumulate(eq)
|
|
dd = (peak - eq) / peak * 100.0
|
|
return dd.max(), int(np.argmax(dd))
|
|
|
|
|
|
def daily_pl(rows):
|
|
"""P/L grouped by CLOSE date - the closest thing a trade list allows to a daily loss.
|
|
NOTE this UNDERSTATES the real daily swing: it counts only realised P/L, while a prop daily
|
|
limit is measured on EQUITY, which moves with open positions too. The MAE column shows how
|
|
much worse the intraday equity path got."""
|
|
d = {}
|
|
for r in rows:
|
|
d.setdefault(r['CloseTime'].date(), 0.0)
|
|
d[r['CloseTime'].date()] += r['PL']
|
|
return d
|
|
|
|
|
|
if __name__ == '__main__':
|
|
rows = load()
|
|
print(f"=== SQX strategy 2.19.106 - {len(rows)} trades, "
|
|
f"{rows[0]['OpenTime']:%Y-%m-%d} .. {rows[-1]['CloseTime']:%Y-%m-%d} ===\n")
|
|
|
|
print("=== 1. PERFORMANCE BY SAMPLE BLOCK (only OOS carries information) ===")
|
|
print(f" {'block':<12}{'n':>5}{'total':>11}{'mean':>9}{'t':>8}{'win%':>9}"
|
|
f"{'avg win':>9}{'avg loss':>9}{'/yr':>8}")
|
|
for blk in ('IST', 'ISV1', 'OOS1'):
|
|
segment_stats([r for r in rows if block_of(r) == blk], blk)
|
|
segment_stats(rows, 'ALL')
|
|
print()
|
|
|
|
print("=== 2. RETURN vs RISK vs THE OBVIOUS ALTERNATIVE ===")
|
|
eq = equity_path(rows)
|
|
yrs = (rows[-1]['CloseTime'] - rows[0]['OpenTime']).days / 365.25
|
|
total_ret = (eq[-1] / eq[0] - 1) * 100
|
|
cagr = ((eq[-1] / eq[0]) ** (1 / yrs) - 1) * 100
|
|
mdd, mdd_i = drawdown(eq)
|
|
bh = (rows[-1]['ClosePrice'] / rows[0]['OpenPrice'] - 1) * 100
|
|
bh_cagr = ((rows[-1]['ClosePrice'] / rows[0]['OpenPrice']) ** (1 / yrs) - 1) * 100
|
|
print(f" span {yrs:.1f} years")
|
|
print(f" balance {eq[0]:.0f} -> {eq[-1]:.0f}")
|
|
print(f" total return {total_ret:+.1f}% CAGR {cagr:+.2f}%")
|
|
print(f" max drawdown (closed) {mdd:.2f}% (worst at trade {mdd_i})")
|
|
print(f" return / maxDD {total_ret/max(mdd,1e-9):.2f}")
|
|
print(f" SP500 buy & hold {bh:+.1f}% CAGR {bh_cagr:+.2f}% <- same period, no skill")
|
|
print()
|
|
|
|
print("=== 3. THE COST THAT WAS NOT CHARGED ===")
|
|
nights = np.array([max((r['CloseTime'].date() - r['OpenTime'].date()).days, 0) for r in rows])
|
|
size = np.array([r['Size'] for r in rows])
|
|
is_long = np.array([r['Type'] == 'Buy' for r in rows])
|
|
comm = 2.0 * COMMISSION_PT * size
|
|
swap = nights * SWAP_PT_NIGHT * size * is_long # financing charged on longs
|
|
pl = np.array([r['PL'] for r in rows])
|
|
print(f" commission @ {COMMISSION_PT} pt/side total {comm.sum():>9.0f} "
|
|
f"({comm.mean():.2f}/trade)")
|
|
print(f" financing @ {SWAP_PT_NIGHT} pt/night total {swap.sum():>9.0f} "
|
|
f"(mean {nights.mean():.2f} nights, longs only)")
|
|
net = pl - comm - swap
|
|
print(f" gross profit {pl.sum():>9.0f} mean {pl.mean():+.2f} t {tstat(pl):+.2f}")
|
|
print(f" NET of commission + financing {net.sum():>9.0f} mean {net.mean():+.2f} t {tstat(net):+.2f}")
|
|
oos = np.array([block_of(r) == 'OOS1' for r in rows])
|
|
print(f" NET, OOS ONLY {net[oos].sum():>9.0f} mean {net[oos].mean():+.2f} "
|
|
f"t {tstat(net[oos]):+.2f} (n={oos.sum()})")
|
|
eqn = np.concatenate([[START_BALANCE], START_BALANCE + np.cumsum(net)])
|
|
mddn, _ = drawdown(eqn)
|
|
cagrn = ((eqn[-1] / eqn[0]) ** (1 / yrs) - 1) * 100
|
|
print(f" net CAGR {cagrn:+.2f}% net maxDD {mddn:.2f}% net final {eqn[-1]:.0f}")
|
|
print()
|
|
|
|
print("=== 4. WOULD IT SURVIVE THE ACCOUNT RULES? ===")
|
|
dl = daily_pl(rows)
|
|
worst = sorted(dl.items(), key=lambda kv: kv[1])[:5]
|
|
#--- daily loss as a % of the balance at the START of that day
|
|
bal_by_date = {}
|
|
run = START_BALANCE
|
|
for r in rows:
|
|
run += r['PL']
|
|
bal_by_date[r['CloseTime'].date()] = run
|
|
print(f" worst realised days (limit {DAILY_LIMIT_PCT}% of ~5-7k = 200-280):")
|
|
for d, v in worst:
|
|
b = bal_by_date.get(d, START_BALANCE)
|
|
print(f" {d} {v:+8.2f} = {100.0*v/max(b,1):+.2f}% of balance")
|
|
print(f" max closed-equity drawdown {mdd:.2f}% vs {TOTAL_LIMIT_PCT}% limit -> "
|
|
f"{'BREACH' if mdd > TOTAL_LIMIT_PCT else 'ok'}")
|
|
#--- the intraday path is worse than the closed curve: add each trade's MAE at its worst point
|
|
mae = np.array([r['MAE'] for r in rows])
|
|
eq_open = eq[:-1] + mae # equity if every trade sat at its own MAE
|
|
peak = np.maximum.accumulate(eq[:-1])
|
|
dd_open = ((peak - eq_open) / peak * 100.0).max()
|
|
print(f" max drawdown INCLUDING open-trade excursion {dd_open:.2f}% vs {TOTAL_LIMIT_PCT}% "
|
|
f"-> {'BREACH' if dd_open > TOTAL_LIMIT_PCT else 'ok'}")
|
|
print()
|
|
|
|
print("=== 5. FILL-MODEL RED FLAGS (this project's own bug class) ===")
|
|
zero = [r for r in rows if r['TimeInTrade'] == '0s']
|
|
print(f" trades opened AND closed inside one bar ('0s'): {len(zero)} of {len(rows)}")
|
|
print(" every one is a loss:", all(r['PL'] < 0 for r in zero))
|
|
print(" -> entry is a LIMIT order and the SL is hit in the same H1 bar. Whether that is")
|
|
print(" possible at all depends on the intrabar path, which an H1 backtest does not have.")
|
|
worse = [r for r in rows if r['MAE'] < r['PL'] - 1e-9 and r['CloseType'] == 'SL']
|
|
print(f" SL trades whose MAE is WORSE than the realised loss: {len(worse)} of "
|
|
f"{sum(1 for r in rows if r['CloseType']=='SL')}")
|
|
ratio = np.array([r['MAE'] / r['PL'] for r in worse])
|
|
print(f" mean MAE/loss ratio {ratio.mean():.2f}x (max {ratio.max():.2f}x)")
|
|
print(" -> price traded well beyond the stop but the fill was booked AT the stop.")
|
|
print(" That is zero slippage on the exit, on a gapping index CFD.")
|