164 lines
8.6 KiB
Python
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%.")
|