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

164 lines
8.6 KiB
Python

"""Does the drift edge survive FINANCING - and can it be harvested inside a prop drawdown limit?
Drift is the only positive, significant, cost-surviving result this project has produced
(SP500 +12.25%/yr t 2.84, XAUUSD +10.24%/yr t 2.88). But every one of those numbers was
computed charging SPREAD ONLY, and drift is a HOLD strategy - so the cost that decides it is
not the spread, it is the overnight financing on the notional, every night, for years.
a 4-hour barrier trade pays the spread once and roughly zero financing
a 4-month hold pays the spread once and financing 120 times
That is why a result can be real in R-multiples and worthless in an account. Long CFD
financing runs about SOFR + 2-3%, so against a ~12% gross drift it is not a haircut, it is
most of the edge. This prices it properly instead of assuming it away.
WHAT IS MODELLED
----------------
price return from the validated M1 bid/ask books, so the series is the same one every
other result in this project used
spread paid once on entry and once on exit - negligible over a long hold, included
anyway so the comparison is honest at short holds too
financing annual % of NOTIONAL, charged daily on the levered position. Swept, because
it is broker-specific and MT5 keeps NO history of it, so it cannot be
recovered from data and must come from the symbol spec.
leverage applied to notional, so financing scales with it. This is the whole point:
leverage multiplies the gross edge AND the financing equally, so it cannot
improve the ratio - it only buys return at the price of drawdown.
WHAT IS NOT MODELLED, AND WHY IT MATTERS
----------------------------------------
Cash-index CFDs usually apply a dividend adjustment (credited to longs, ~1.3%/yr on the S&P).
That would OFFSET part of the financing and is not in the price series, so the SP500 net
figures here are pessimistic by roughly that much. Gold has no dividend and no such offset.
Stated rather than silently assumed either way.
"""
import numpy as np, sys
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
import book, fills
SYMS = ('SP500', 'XAUUSD', 'EURUSD', 'USDJPY')
TRADING_DAYS = 252
#--- Prop rules modelled. Set to a common shape rather than any one firm's small print; the
#--- conclusion below is not sensitive to a percentage point either way, and the script sweeps
#--- the two that matter. CHECK THESE AGAINST YOUR ACTUAL PROGRAMME before acting on it.
TARGET, MAXLOSS, DAILY, HORIZON, N_PATH, BLOCK = 0.08, 0.06, 0.03, 252, 20000, 20
def first_passage(r, ann_financing, lev, rel_spread, rng):
"""-> P(hit target), P(breach), P(neither within the horizon).
Block bootstrap rather than iid resampling: daily returns cluster in volatility, and iid
draws would break exactly the clustering that produces the losing streaks a drawdown rule
is designed to catch - which would make every prop rule look far easier to survive.
"""
nb = HORIZON // BLOCK + 1
starts = rng.integers(0, len(r) - BLOCK, size=(N_PATH, nb))
idx = (starts[:, :, None] + np.arange(BLOCK)[None, None, :]).reshape(N_PATH, -1)[:, :HORIZON]
paths = r[idx]
daily_fin = ann_financing / TRADING_DAYS
step = lev * (paths - daily_fin) - 2.0 * rel_spread * lev / TRADING_DAYS
eq = np.cumprod(np.maximum(1.0 + step, 1e-9), axis=1)
hit_t = eq >= (1.0 + TARGET)
#--- total loss measured from the STARTING balance, the usual prop "max loss" rule
hit_l = (eq <= (1.0 - MAXLOSS)) | (step <= -DAILY)
first_t = np.where(hit_t.any(1), hit_t.argmax(1), HORIZON + 1)
first_l = np.where(hit_l.any(1), hit_l.argmax(1), HORIZON + 1)
win = first_t < first_l
lose = first_l < first_t
return float(win.mean()), float(lose.mean()), float((~win & ~lose).mean())
def daily(sym):
"""Daily mid closes and the median relative spread, from the validated book."""
f = book.frame(sym, 'D1')
c = f.c
r = np.diff(np.log(c))
rel_spread = float(np.median(f.spread / f.c))
return f.t[1:], r, rel_spread
def stats(r, ann_financing=0.0, lev=1.0, rel_spread=0.0, turns_per_year=1.0):
"""Equity path of a levered long, financed daily. -> dict of the numbers that decide it.
Financing is charged on NOTIONAL (lev x equity), which is what a broker actually does,
so the cost scales with leverage exactly as the return does.
"""
daily_fin = ann_financing / TRADING_DAYS
#--- compounding on the equity, not additive on the notional: a drawdown reduces the
#--- position and therefore the financing, which is how a real account behaves
step = 1.0 + lev * (r - daily_fin)
step = np.maximum(step, 1e-9) # a wipeout is absorbing, not negative
eq = np.cumprod(step)
#--- round-trip spread, amortised over the year at the stated turnover
eq *= np.exp(-turns_per_year * 2.0 * rel_spread * lev
* np.arange(len(eq)) / TRADING_DAYS)
yrs = len(r) / TRADING_DAYS
cagr = eq[-1] ** (1.0 / yrs) - 1.0
vol = np.std(lev * r) * np.sqrt(TRADING_DAYS)
peak = np.maximum.accumulate(eq)
dd = 1.0 - eq / peak
#--- the prop-relevant statistic is not the average day, it is the WORST day
worst_day = float(np.min(lev * r))
return dict(cagr=cagr, vol=vol, sharpe=(cagr / vol if vol > 0 else 0.0),
maxdd=float(dd.max()), worst_day=worst_day, eq=eq, dd=dd, yrs=yrs)
if __name__ == '__main__':
syms = [s for s in sys.argv[1:] if s in SYMS] or list(SYMS)
print("=== 1. GROSS DRIFT, unlevered, financing 0 - reproducing the known result ===")
print(f" {'sym':>7}{'years':>7}{'CAGR':>9}{'vol':>8}{'Sharpe':>8}{'maxDD':>8}"
f"{'worst day':>11}{'spread':>9}")
D = {}
for s in syms:
t, r, sp = daily(s)
D[s] = (t, r, sp)
k = stats(r, 0.0, 1.0, sp)
print(f" {s:>7}{k['yrs']:>7.1f}{100*k['cagr']:>8.2f}%{100*k['vol']:>7.1f}%"
f"{k['sharpe']:>8.2f}{100*k['maxdd']:>7.1f}%{100*k['worst_day']:>10.2f}%"
f"{1e4*sp:>8.1f}bp")
print("\n=== 2. THE QUESTION THAT DECIDES IT: net CAGR vs financing rate ===")
print(" unlevered. Typical long CFD financing is SOFR + 2-3%, so look at the 7-8% column.")
fins = (0.0, 0.03, 0.05, 0.075, 0.10)
print(f" {'sym':>7}" + "".join(f"{100*f:>11.1f}%" for f in fins) + f"{'breakeven':>12}")
for s in syms:
t, r, sp = D[s]
row = f" {s:>7}"
for fin in fins:
row += f"{100*stats(r, fin, 1.0, sp)['cagr']:>11.2f}%"
#--- the financing rate at which the whole edge is gone
lo, hi = 0.0, 0.50
for _ in range(40):
mid = 0.5 * (lo + hi)
if stats(r, mid, 1.0, sp)['cagr'] > 0:
lo = mid
else:
hi = mid
row += f"{100*lo:>11.2f}%"
print(row)
print(" 'breakeven' = the financing rate at which net CAGR hits zero. If that is below")
print(" what your broker charges, the edge does not exist in your account.")
print("\n=== 3. THE PROP QUESTION: reach the target BEFORE breaching a limit? ===")
print(f" Not 'survive the worst 25-year drawdown' - an evaluation is weeks, so this is a")
print(f" FIRST-PASSAGE problem. Block bootstrap (20-day blocks, so volatility clustering")
print(f" and autocorrelation survive) of the real daily returns, {N_PATH:,} paths each.")
print(f" Rules modelled: +{100*TARGET:.0f}% target, -{100*MAXLOSS:.0f}% total loss from")
print(f" start, -{100*DAILY:.0f}% daily loss, {HORIZON} trading days.\n")
print(f" {'sym':>7}{'fin':>6}{'lev':>6}{'CAGR':>9}{'P(target)':>11}{'P(breach)':>11}"
f"{'P(timeout)':>12}{'edge vs coin':>14}")
rng = np.random.default_rng(7)
for s in ('SP500', 'XAUUSD'):
t, r, sp = D[s]
for fin in (0.05, 0.075):
for lev in (1.0, 2.0, 3.0, 5.0):
res = first_passage(r, fin, lev, sp, rng)
#--- a fair-coin benchmark: risking the same amount with NO edge would hit
#--- +8 before -6 with probability 6/(6+8). Beating that is the whole claim.
coin = MAXLOSS / (MAXLOSS + TARGET)
print(f" {s:>7}{100*fin:>5.1f}%{lev:>6.1f}"
f"{100*stats(r, fin, lev, sp)['cagr']:>8.2f}%"
f"{100*res[0]:>10.1f}%{100*res[1]:>10.1f}%{100*res[2]:>11.1f}%"
f"{100*(res[0]-coin):>13.1f}pp")
print("\n 'edge vs coin' compares to a no-skill bet with the same barriers, which reaches")
print(" +8% before -6% with probability 6/14 = 42.9%. That is the number to beat, NOT 50%.")