Warrior_EA/research/swing.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

137 lines
6.5 KiB
Python

"""Cost is a RATIO, and the hourly tests were measuring it at its worst possible scale.
The recurring verdict in this project - "the effect is real and the spread is bigger" - was
established almost entirely on short holds. That is the regime where cost is guaranteed to
dominate, because cost is roughly FIXED per round trip while the available move grows with
the square root of holding time:
cost / move ~ spread / (sigma * sqrt(T))
So a one-hour hold pays the same spread as a one-week hold and gets ~13x less movement to pay
it with. Concluding "nothing survives cost" from hourly tests is close to circular.
This measures the ratio directly across holding periods, then tests the swing structure that
actually gets traded: enter at a weekday/session, exit at the Friday close so no weekend
financing is paid.
COSTS CHARGED IN FULL
---------------------
spread the real bid/ask at entry and exit, from the M1 book
commission 0.32 bp per side, the realistic ECN figure
swap per night held, swept - MT5 keeps no swap history so it cannot be recovered
from data. Closing at the Friday close avoids the 2-3 night weekend charge,
which is the single largest avoidable financing item for a swing trader.
A note on what CANNOT be done: swap direction cannot be predicted from history because the
history does not exist. It can only be measured forward from the symbol spec. Treat it as a
known constant per instrument and direction, never as something to forecast.
"""
import numpy as np, sys, datetime as dt
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
import book, fills
SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500')
COMM_BP = 0.32 # per side
SWAP_BP_DAY = 2.05 # ~7.5%/yr financing, per night, in bp of notional
def scale_table(sym):
"""Mean absolute move vs round-trip cost, as a function of holding time."""
bk = fills.Book(sym)
f = book.frame(sym, 'H1', bk)
c = f.c
sp_bp = float(np.median((bk.ac - bk.bc) / bk.bc)) * 1e4
rows = []
for hrs, label in ((1, '1 hour'), (4, '4 hours'), (24, '1 day'), (72, '3 days'),
(120, '5 days'), (480, '20 days')):
i = np.arange(300, len(c) - hrs)
mv = np.abs(np.log(c[i + hrs] / c[i])) * 1e4
nights = hrs / 24.0
cost = sp_bp + 2 * COMM_BP + nights * SWAP_BP_DAY
rows.append((label, mv.mean(), sp_bp, 2 * COMM_BP, nights * SWAP_BP_DAY,
cost, mv.mean() / cost))
return sp_bp, rows
def weekly(sym, entry_dow, entry_hour, swap_bp_day=SWAP_BP_DAY):
"""Enter at a given weekday/hour, exit at the FRIDAY close of the same week.
Both directions are reported, because a swing edge must show up as a directional
asymmetry - if long and short are mirror images the only thing being measured is cost.
"""
bk = fills.Book(sym)
f = book.frame(sym, 'H1', bk)
d = np.array([dt.datetime.fromtimestamp(x / 1000, dt.UTC) for x in f.t])
dow = np.array([x.weekday() for x in d])
hour = np.array([x.hour for x in d])
wk = np.array([x.isocalendar()[0] * 100 + x.isocalendar()[1] for x in d])
ent_mask = (dow == entry_dow) & (hour == entry_hour) & (np.arange(len(d)) > 300)
ent_i = np.nonzero(ent_mask)[0]
#--- Last bar of the WEEKDAY part of the week. NOT simply the last bar of the ISO week:
#--- the broker week runs to Sunday ~23:00 (weekday 6), and an earlier version of this
#--- took that as the "Friday close" - so every trade held straight through the weekend
#--- and paid the 2-3 night weekend financing the exit was designed to avoid. It showed
#--- up as 6.9 nights on a Monday->Friday SP500 trade, which is arithmetically impossible.
#--- Restricting to weekday <= 4 (Mon-Fri) is what actually closes before the weekend.
last_of_week = {}
for k in range(len(d)):
if dow[k] <= 4:
last_of_week[wk[k]] = k
out = []
for i in ent_i:
j = last_of_week.get(wk[i], -1)
if j <= i:
continue
out.append((i, j))
if len(out) < 100:
return None
I = np.array([a for a, _ in out]); J = np.array([b for _, b in out])
#--- last minute of the exit bar, from the INDEX - see book.Frame.last_i0
si, sj = f.i0[I], f.last_i0(J)
nights = (bk.t[sj] // 86400000 - bk.t[si] // 86400000).astype(float)
mid = 0.5 * (bk.bo[si] + bk.ao[si])
res = {}
for dirn, tag in ((1, 'long'), (-1, 'short')):
if dirn > 0:
ent, exi = bk.ao[si], bk.bc[sj]
else:
ent, exi = bk.bo[si], bk.ac[sj]
gross = (exi - ent) * dirn / mid * 1e4
net = gross - 2 * COMM_BP - nights * swap_bp_day
res[tag] = (gross, net, nights)
return res, len(I)
if __name__ == '__main__':
syms = [s for s in sys.argv[1:] if s in SYMS] or list(SYMS)
print("=== 1. THE SCALE EFFECT: available movement vs round-trip cost ===")
print(" cost is ~fixed per trade; movement grows with sqrt(time). This is why short")
print(f" holds can never clear cost. swap modelled at {SWAP_BP_DAY} bp/night (~7.5%/yr).\n")
for sym in syms:
sp, rows = scale_table(sym)
print(f" {sym} median spread {sp:.2f} bp")
print(f" {'hold':>9}{'|move| bp':>11}{'spread':>9}{'comm':>7}{'swap':>8}"
f"{'total cost':>12}{'move/cost':>11}")
for lab, mv, s_, cm, sw, ct, ratio in rows:
print(f" {lab:>9}{mv:>11.1f}{s_:>9.2f}{cm:>7.2f}{sw:>8.2f}{ct:>12.2f}{ratio:>11.1f}")
print()
print("=== 2. SWING STRUCTURE: enter at a weekday/hour, exit at the FRIDAY CLOSE ===")
print(" no weekend financing. Both directions shown - a real edge is an ASYMMETRY,")
print(" not just 'the move was bigger than the cost'.\n")
print(f" {'sym':>7}{'entry':>16}{'n':>6}{'nights':>8}"
f"{'LONG net bp':>13}{'t':>7}{'SHORT net bp':>14}{'t':>7}{'asym bp':>9}")
for sym in syms:
for dow, dname in ((0, 'Mon'), (2, 'Wed')):
for h in (9, 15):
r = weekly(sym, dow, h)
if r is None:
continue
res, n = r
gl, nl, nights = res['long']
gs, ns, _ = res['short']
se = lambda x: x.std(ddof=1) / np.sqrt(len(x))
asym = 0.5 * (nl.mean() - ns.mean())
print(f" {sym:>7}{dname + ' ' + str(h) + ':00':>16}{n:>6}{nights.mean():>8.1f}"
f"{nl.mean():>+13.2f}{nl.mean()/se(nl):>+7.2f}"
f"{ns.mean():>+14.2f}{ns.mean()/se(ns):>+7.2f}{asym:>+9.2f}")