Warrior_EA/research/fracdiff.py

193 lines
7.3 KiB
Python

"""
Fractional differentiation (Lopez de Prado, AFML ch.5), fixed-width window form.
WHY THIS EXISTS IN THIS REPO
----------------------------
FeatureBuilder.mqh does NOT feed the network non-stationary prices. It feeds
(close-open)/atr, (close-close[20])/atr, (close-SMA20)/atr, volume/volume_base.
Those are integer-order differences: d = 1. They are stationary and they are
memory-less. The whole reason to move to fractional d is that the current
transform is OVER-differenced, not under-differenced.
So the question this module answers is NOT "how do we make the inputs
stationary" (they already are). It is: "what is the SMALLEST d that still
passes ADF, so we keep the level information that d=1 throws away?"
MATH
----
The backshift operator B satisfies B^k X_t = X_{t-k}. For real d,
(1 - B)^d = sum_{k=0}^{inf} C(d,k) (-B)^k
= sum_{k=0}^{inf} w_k B^k
with weights generated by the recurrence
w_0 = 1, w_k = -w_{k-1} * (d - k + 1) / k
For d in (0,1) the weights alternate in sign and decay hyperbolically, not
geometrically -- that slow decay IS the retained memory. d=1 gives
w = [1, -1, 0, 0, ...], i.e. the plain first difference: all memory beyond one
bar is discarded. That is what the EA does today.
FIXED-WIDTH WINDOW (FFD)
------------------------
The expanding-window form makes every output depend on a different number of
terms, so the effective transform drifts with t. Instead truncate the weight
vector at the first k with |w_k| < tau and renormalise nothing (the weights are
used as-is). Every output then uses the SAME l* taps, so the series is
translation-invariant and the first l* observations are dropped.
X_t^(d) = sum_{k=0}^{l*} w_k X_{t-k}
IMPORTANT: apply to LOG price, not raw price. On a CFD index a raw-price FFD
inherits the level scale (SP500 ~5000 vs EURUSD ~1.1) and will not transfer
across the fleet's 13 instruments.
"""
from __future__ import annotations
import numpy as np
# pandas is OPTIONAL. The core operates on numpy arrays so this module runs on a
# bare interpreter (the box this was authored on has numpy but no pandas).
try: # pragma: no cover
import pandas as pd
except ImportError: # pragma: no cover
pd = None
def ffd_weights(d: float, tau: float = 1e-5, max_k: int = 10_000) -> np.ndarray:
"""Fixed-width FFD weight vector w[0..l*], truncated at |w_k| < tau.
Returned in lag order: w[0] multiplies X_t, w[1] multiplies X_{t-1}, ...
"""
if d < 0:
raise ValueError("d must be >= 0")
w = [1.0]
for k in range(1, max_k):
w_k = -w[-1] * (d - k + 1) / k
if abs(w_k) < tau:
break
w.append(w_k)
return np.asarray(w, dtype=float)
def frac_diff_ffd_np(values: np.ndarray, d: float, tau: float = 1e-5) -> np.ndarray:
"""Fixed-width fractional difference of a 1-D numpy array (oldest -> newest).
The first len(w)-1 observations are NaN (insufficient taps) and must be
dropped by the caller -- they are exactly the warm-up the EA already
budgets for via HistoryBars + LabelResolutionBars.
"""
w = ffd_weights(d, tau)
width = len(w) - 1
vals = np.asarray(values, dtype=float)
out = np.full(vals.shape, np.nan)
if width >= len(vals):
return out
# correlate with reversed weights == sum_k w_k * X_{t-k}
out[width:] = np.convolve(vals, w[::-1], mode="valid")
return out
def frac_diff_ffd(series, d: float, tau: float = 1e-5):
"""pandas-friendly wrapper around frac_diff_ffd_np; falls back to numpy."""
if pd is not None and isinstance(series, pd.Series):
out = frac_diff_ffd_np(series.to_numpy(dtype=float), d, tau)
return pd.Series(out, index=series.index, name=series.name)
return frac_diff_ffd_np(np.asarray(series, dtype=float), d, tau)
def adf_tstat(x: np.ndarray) -> float:
"""ADF t-statistic, lag-1, with constant -- computed directly via OLS.
Written out rather than imported so the screen runs without statsmodels.
Regression: dX_t = a + g*X_{t-1} + b*dX_{t-1} + e_t ; t-stat on g.
Compare against the Dickey-Fuller critical value, NOT the normal table:
5% ~ -2.86, 1% ~ -3.43 for large samples with a constant.
"""
x = np.asarray(x, dtype=float)
dx = np.diff(x)
y = dx[1:]
X = np.column_stack([np.ones(len(y)), x[1:-1], dx[:-1]])
beta, *_ = np.linalg.lstsq(X, y, rcond=None)
resid = y - X @ beta
dof = len(y) - X.shape[1]
s2 = resid @ resid / dof
xtx_inv = np.linalg.inv(X.T @ X)
se_g = np.sqrt(s2 * xtx_inv[1, 1])
return float(beta[1] / se_g)
ADF_CRIT_5PCT = -2.86
def min_ffd(series, d_grid=None, tau: float = 1e-5, crit: float = ADF_CRIT_5PCT):
"""Scan d; report the ADF t-stat and the correlation to the raw level.
Returns a list of dicts (and a pandas DataFrame instead, when pandas is
installed). The row you want is the SMALLEST d whose adf_t <= crit. Its
`corr` column is the memory you KEEP; for a liquid log-price that is
typically 0.90-0.99 at d ~ 0.3-0.5, against corr ~ 0 at the d = 1 the EA
uses today.
"""
if d_grid is None:
d_grid = np.linspace(0.0, 1.0, 21)
base = np.asarray(
series.dropna().to_numpy(dtype=float) if (pd is not None and isinstance(series, pd.Series))
else series, dtype=float)
base = base[~np.isnan(base)]
rows = []
for d in d_grid:
fd = frac_diff_ffd_np(base, float(d), tau)
mask = ~np.isnan(fd)
if mask.sum() < 100:
continue
rows.append({
"d": round(float(d), 4),
"taps": len(ffd_weights(float(d), tau)),
"obs": int(mask.sum()),
"adf_t": round(adf_tstat(fd[mask]), 3),
"corr": round(float(np.corrcoef(base[mask], fd[mask])[0, 1]), 4),
})
passing = [r for r in rows if r["adf_t"] <= crit]
d_min = passing[0]["d"] if passing else None
if pd is not None:
out = pd.DataFrame(rows)
out.attrs["d_min"] = d_min
return out
return {"rows": rows, "d_min": d_min}
def ffd_log_price(ohlc, d: float, tau: float = 1e-5):
"""FFD applied to log O/H/L/C and log volume -- the network-facing columns.
Volume is log1p'd first: it is non-negative, heavy-tailed and occasionally
zero on CFD bars, and log1p keeps those bars instead of producing -inf.
"""
out = {}
for col in ("open", "high", "low", "close"):
if col in ohlc:
out[f"ffd_{col}"] = frac_diff_ffd(np.log(ohlc[col]), d, tau)
if "volume" in ohlc:
out["ffd_volume"] = frac_diff_ffd(np.log1p(ohlc["volume"]), d, tau)
return pd.DataFrame(out, index=ohlc.index) if pd is not None else out
if __name__ == "__main__":
import sys
path = sys.argv[1] if len(sys.argv) > 1 else None
if path is None:
print(__doc__)
print("usage: python fracdiff.py <ohlc.csv> # needs a 'close' column")
raise SystemExit(0)
close = np.genfromtxt(path, delimiter=",", names=True)["close"]
table = min_ffd(np.log(close))
rows = table["rows"] if isinstance(table, dict) else table.to_dict("records")
hdr = f"{'d':>6} {'taps':>6} {'obs':>8} {'adf_t':>8} {'corr':>8}"
print(hdr); print("-" * len(hdr))
for r in rows:
print(f"{r['d']:>6} {r['taps']:>6} {r['obs']:>8} {r['adf_t']:>8} {r['corr']:>8}")
print("minimum d passing ADF @5%:",
table["d_min"] if isinstance(table, dict) else table.attrs.get("d_min"))