Warrior_EA/research/sqx_audit.py
AnimateDread 8ccbddb051 Add new research scripts for trading strategy analysis
- 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.
2026-08-02 12:25:20 -04:00

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