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

172 lines
8.4 KiB
Python

"""What price does at every harvestable time point - DIRECTION, MAGNITUDE, and COST.
Direction by clock was already tested and largely closed: two survivors (XAUUSD hour 1,
EURUSD hour 13) that were real, split-half stable, and ~2x too small to cross the spread.
Re-run here anyway, because it is cheap and because it now runs against nine instruments and
an honest cost model - but it is not where the value is expected.
The untested half is MAGNITUDE, and it is better motivated:
* a bigger move in an UNKNOWN direction is worth exactly zero expectancy, so magnitude
seasonality is not an edge by itself and nobody arbitrages it away - which is precisely
why it survives where directional effects do not
* but cost in R is spread / risk, and risk is set from expected movement. So the ratio
EXCURSION / SPREAD is the tradeability of an hour, and it varies enormously across the
clock. Every result in this project died to that ratio; this measures it directly.
THE THREE COLUMNS THAT MATTER
-----------------------------
drift mean log return in the bucket - the directional question (mostly closed)
|move| mean absolute move, in ATR units - magnitude, scale-free across instruments
move/sp mean absolute move divided by the spread paid in that bucket. THIS is the number
that says whether an hour is worth trading at all, and it is the one nothing in
this project has ever looked at.
CONTROLS, BECAUSE 24 HOURS x 4 SYMBOLS IS 96 CHANCES TO FIND NOTHING
--------------------------------------------------------------------
family-wise the null is a random CIRCULAR ROTATION of the bucket labels within each
period (day / week / year). That preserves the return series exactly, and
the within-period dependence and volatility clustering with it, and destroys
ONLY the alignment between a bucket and its label. The statistic compared
against is the MAXIMUM |t| over the whole family, so finding one good cell in
96 is not evidence.
split-half every survivor re-measured on the first and second half of its own history.
This killed two of four survivors last time and is the cheapest real filter
available.
"""
import numpy as np, sys, datetime as dt
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
import book, fills
SYMS = ('EURUSD', 'USDJPY', 'XAUUSD', 'SP500')
#--- broker time is UTC+2 with no DST in these tick files, so an "hour" here is a broker hour.
#--- Sessions are stated in broker time to match; subtract 2 for UTC.
SESSIONS = {'Asia': range(1, 10), 'London': range(10, 17), 'NY': range(15, 23)}
def buckets(t_ms, kind):
"""Bucket index per bar, plus the PERIOD each bucket rotates within for the null."""
d = np.array([dt.datetime.fromtimestamp(x / 1000, dt.UTC) for x in t_ms])
if kind == 'hour':
b = np.array([x.hour for x in d])
period = np.array([x.toordinal() for x in d]) # rotate within the day
n = 24
elif kind == 'dow':
b = np.array([x.weekday() for x in d])
period = np.array([x.isocalendar()[0] * 100 + x.isocalendar()[1] for x in d])
n = 7
elif kind == 'month':
b = np.array([x.month - 1 for x in d])
period = np.array([x.year for x in d])
n = 12
else:
raise ValueError(kind)
return b, period, n
def rotate_null(b, period, n, rng):
"""Rotate bucket labels by a random offset WITHIN each period.
Keeps every return exactly where it is and keeps the period's internal shape; only the
label alignment moves. A plain shuffle would destroy volatility clustering and make every
bucket look more significant than it is.
"""
_, inv = np.unique(period, return_inverse=True)
off = rng.integers(0, n, size=inv.max() + 1)
return (b + off[inv]) % n
def tstats(vals, b, n):
"""Per-bucket mean and t."""
m = np.full(n, np.nan); tt = np.zeros(n); cnt = np.zeros(n, int)
for k in range(n):
s = vals[b == k]
cnt[k] = len(s)
if len(s) > 30:
m[k] = s.mean()
se = s.std(ddof=1) / np.sqrt(len(s))
tt[k] = s.mean() / max(se, 1e-12)
return m, tt, cnt
def analyse(sym, tf='H1', kind='hour', nperm=400, seed=3):
bk = fills.Book(sym)
f = book.frame(sym, tf, bk)
c = f.c
r = np.zeros(len(c)); r[1:] = np.diff(np.log(c))
atr = f.atr(14)
ok = np.isfinite(atr) & (atr > 0) & np.isfinite(r) & (np.arange(len(c)) > 300)
#--- magnitude, scale-free: the bar's own range in ATR units, and |return| in ATR units
mag = np.where(ok, (f.h - f.l) / np.maximum(atr, 1e-12), np.nan)
absr = np.where(ok, np.abs(c - f.o) / np.maximum(atr, 1e-12), np.nan)
#--- what that movement costs: the spread actually quoted in that bar, same units
sp_atr = np.where(ok, f.spread / np.maximum(atr, 1e-12), np.nan)
b, period, n = buckets(f.t[ok], kind)
rr, mm, aa, ss = r[ok], mag[ok], absr[ok], sp_atr[ok]
dm, dt_, cnt = tstats(rr, b, n)
gm, _, _ = tstats(mm, b, n)
am, _, _ = tstats(aa, b, n)
sm, _, _ = tstats(ss, b, n)
#--- family-wise null on the DIRECTIONAL statistic only; magnitude differences across the
#--- clock are not in doubt and do not need a significance test to be useful
rng = np.random.default_rng(seed)
maxt = np.zeros(nperm)
for p in range(nperm):
bn = rotate_null(b, period, n, rng)
_, tn, _ = tstats(rr, bn, n)
maxt[p] = np.nanmax(np.abs(tn))
return dict(sym=sym, kind=kind, n=n, cnt=cnt, drift=dm, t=dt_, rng_atr=gm,
absr=am, sp=sm, bar=float(np.quantile(maxt, 0.95)),
r=rr, b=b, t_ms=f.t[ok])
def split_half(res, k):
"""Same bucket, first half vs second half of its own history."""
m = res['b'] == k
x = res['r'][m]
if len(x) < 200:
return np.nan, np.nan, np.nan, np.nan
h = len(x) // 2
a, bb = x[:h], x[h:]
ta = a.mean() / max(a.std(ddof=1) / np.sqrt(len(a)), 1e-12)
tb = bb.mean() / max(bb.std(ddof=1) / np.sqrt(len(bb)), 1e-12)
return a.mean(), ta, bb.mean(), tb
if __name__ == '__main__':
syms = [s for s in sys.argv[1:] if s in SYMS] or list(SYMS)
for kind in ('hour', 'dow', 'month'):
print(f"\n{'='*100}\n=== {kind.upper()} - direction, magnitude, and what movement costs "
f"(broker time = UTC+2) ===\n{'='*100}")
for sym in syms:
res = analyse(sym, kind=kind)
print(f"\n {sym} family-wise |t| bar (95th pct of max over {res['n']} buckets) "
f"= {res['bar']:.2f}")
print(f" {'bkt':>4}{'n':>7}{'drift bp':>10}{'t':>7}{'':>3}"
f"{'range/ATR':>11}{'|move|/ATR':>12}{'spread/ATR':>12}{'move/spread':>13}")
for k in range(res['n']):
if res['cnt'][k] < 100 or not np.isfinite(res['drift'][k]):
continue
flag = ' *' if abs(res['t'][k]) > res['bar'] else ' '
ratio = res['absr'][k] / res['sp'][k] if res['sp'][k] > 0 else 0
print(f" {k:>4}{res['cnt'][k]:>7}{1e4*res['drift'][k]:>10.2f}"
f"{res['t'][k]:>7.2f}{flag:>3}{res['rng_atr'][k]:>11.3f}"
f"{res['absr'][k]:>12.3f}{res['sp'][k]:>12.4f}{ratio:>13.1f}")
surv = [k for k in range(res['n'])
if res['cnt'][k] >= 100 and abs(res['t'][k]) > res['bar']]
if surv:
print(f" survivors past the family-wise bar -> split-half:")
for k in surv:
a, ta, bb, tb = split_half(res, k)
same = 'SAME sign' if a * bb > 0 else 'SIGN FLIP - dead'
print(f" bucket {k:>2}: H1 {1e4*a:+7.2f}bp (t{ta:+5.2f}) "
f"H2 {1e4*bb:+7.2f}bp (t{tb:+5.2f}) {same}")
else:
print(" no bucket clears the family-wise bar")
#--- the practical read, independent of any significance test
good = int(np.nanargmax(res['absr'] / np.where(res['sp'] > 0, res['sp'], np.nan)))
bad = int(np.nanargmin(res['absr'] / np.where(res['sp'] > 0, res['sp'], np.nan)))
gr = res['absr'][good] / res['sp'][good]
br = res['absr'][bad] / res['sp'][bad]
print(f" movement per unit of spread: BEST bucket {good} at {gr:.1f}x, "
f"WORST bucket {bad} at {br:.1f}x -> {gr/br:.1f}x spread between them")