Warrior_EA/research/altdata/screen.py

234 lines
9.6 KiB
Python
Raw Permalink Normal View History

"""First-pass MI screen: do alt-data features carry information about
next-week excursion RANGE (the proven predictable channel), on D1 bars?
Method (mirrors the project's established discipline):
- as-of join: feature value = last row with published <= bar time (never
observed; the vintage rule)
- target: sum of true ranges over the NEXT 5 bars / ATR14(t), 3 equal-
frequency bins (fat-tail safe, pins H(Y)=ln 3)
- MI plugin estimator on a 3x3 table
- null: CIRCULAR SHIFT of the feature series (offset uniform in
[63, N-63]) - preserves the autocorrelation of both sides, destroys only
the pairing; weekly step-function features make a plain shuffle
anti-conservative
- family-wise bar: null of the MAXIMUM MI across all features per draw
- controls: trailing 5-bar range (POSITIVE - must clear or the harness is
broken, per the excursion-target lesson) and white noise (negative)
Usage:
python -m altdata.screen # all 4 tick-bar symbols
python -m altdata.screen --sym SP500 --perms 499
"""
import argparse
import numpy as np
import pandas as pd
from .common import DATA_ROOT
BARS = DATA_ROOT.parent / "bars"
research(altdata): macro/rates/country data is NULL vs forward range on all four symbols Screened yields (DGS2/DGS10), curve slope, inflation breakevens, Fed policy, the Fed-ECB policy differential, and monthly US unemployment and CPI - all on ALFRED first prints, 499 permutations, against forward 5-day range. NOT ONE macro feature clears the family-wise bar on any symbol. The only thing that clears anywhere is the trailing-range positive control, which is what it is there to do. Best a-priori candidate, the Fed-ECB differential on EURUSD, came in at MI 0.00170 p=0.088 - nothing. The two features flagged INCREMENTAL (dgs2_chg5 on SP500) have null marginal MI and are isolated conditional cells at the expected false-positive rate, not findings. The `distinct` column quantifies the power argument instead of asserting it: unemployment takes 51-66 distinct values across 3,745-6,159 bars, CPI 174-277, against 6,159 for a continuous feature. A monthly series pasted onto daily bars carries about 1% of the resolution, and it showed - the monthly features were among the weakest in every table. The contrast with the implied-vol screen is the useful part: the options market FORWARD-LOOKING view of an instrument (gvz_chg5 on gold, MI|vol 0.0197) carries real information about its range, while the economy BACKWARD-LOOKING state carries none. Mismatched timescales - rate levels move over months, 5-day range moves daily. Also makes load_bars fall back to htf/{SYM}_D1_mid.npz when the tick-derived build is absent (the 2026-08-16 disk cleanup removed bars/ but htf/ survived), with need_ticks=True turning that fallback into a loud failure for the order-flow screen rather than silently testing flow features on OHLC data. No EA change: nothing survived to wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:21:50 -04:00
HTF = DATA_ROOT.parent / "htf"
FIN_SYMS = {"SP500", "EURUSD", "USDJPY", "USDCAD", "GBPUSD", "VIX", "BTCUSD"}
# USD-base pairs: futures are quoted FX/USD, so net-long futures = short the pair
SIGN_FLIP = {"USDJPY", "USDCAD"}
research(altdata): macro/rates/country data is NULL vs forward range on all four symbols Screened yields (DGS2/DGS10), curve slope, inflation breakevens, Fed policy, the Fed-ECB policy differential, and monthly US unemployment and CPI - all on ALFRED first prints, 499 permutations, against forward 5-day range. NOT ONE macro feature clears the family-wise bar on any symbol. The only thing that clears anywhere is the trailing-range positive control, which is what it is there to do. Best a-priori candidate, the Fed-ECB differential on EURUSD, came in at MI 0.00170 p=0.088 - nothing. The two features flagged INCREMENTAL (dgs2_chg5 on SP500) have null marginal MI and are isolated conditional cells at the expected false-positive rate, not findings. The `distinct` column quantifies the power argument instead of asserting it: unemployment takes 51-66 distinct values across 3,745-6,159 bars, CPI 174-277, against 6,159 for a continuous feature. A monthly series pasted onto daily bars carries about 1% of the resolution, and it showed - the monthly features were among the weakest in every table. The contrast with the implied-vol screen is the useful part: the options market FORWARD-LOOKING view of an instrument (gvz_chg5 on gold, MI|vol 0.0197) carries real information about its range, while the economy BACKWARD-LOOKING state carries none. Mismatched timescales - rate levels move over months, 5-day range moves daily. Also makes load_bars fall back to htf/{SYM}_D1_mid.npz when the tick-derived build is absent (the 2026-08-16 disk cleanup removed bars/ but htf/ survived), with need_ticks=True turning that fallback into a loud failure for the order-flow screen rather than silently testing flow features on OHLC data. No EA change: nothing survived to wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 17:21:50 -04:00
def load_bars(sym: str, need_ticks: bool = False) -> pd.DataFrame:
"""D1 bars, preferring the tick-derived build and falling back to mid bars.
The tick-derived `bars/{SYM}_D1_ticks.npz` carries microstructure columns
(ticks, upticks, rvol, spread stats) that only a tick pass can produce.
`htf/{SYM}_D1_mid.npz` is OHLC only - enough for anything measured against
range or return, which is every screen except the order-flow one.
need_ticks=True makes the fallback a LOUD failure instead of handing back a
frame that is missing the very columns the caller is about to test. The
2026-08-16 disk cleanup removed bars/ while htf/ survived, and silently
screening flow features on OHLC-only data would be worse than an error.
"""
p = BARS / f"{sym}_D1_ticks.npz"
if p.exists():
z = np.load(p)
df = pd.DataFrame(z["bars"], columns=list(z["columns"]))
df["time"] = pd.to_datetime(df["time"], unit="ms")
return df
if need_ticks:
raise FileNotFoundError(
f"{p} is missing and this screen needs tick-derived columns. "
f"Rebuild with: python ticks_to_bars.py (source ticks: SQX "
f"user/data/History, or the terminal's bases/*/ticks).")
q = HTF / f"{sym}_D1_mid.npz"
if not q.exists():
raise FileNotFoundError(f"no D1 bars for {sym}: neither {p} nor {q}")
z = np.load(q)
return pd.DataFrame({
"time": pd.to_datetime(z["t"], unit="ms"),
"open": z["o"], "high": z["h"], "low": z["l"], "close": z["c"],
"volume": z["v"], "spread": z["spread"],
})
def true_range(df: pd.DataFrame) -> pd.Series:
prev_close = df["close"].shift(1)
return pd.concat([df["high"] - df["low"],
(df["high"] - prev_close).abs(),
(df["low"] - prev_close).abs()], axis=1).max(axis=1)
def cot_features(sym: str) -> pd.DataFrame | None:
path = DATA_ROOT / "cot" / f"{sym}_cot.csv"
if not path.exists():
return None
c = pd.read_csv(path, parse_dates=["observed", "published"])
oi = c["Open_Interest_All"].replace(0, np.nan)
if sym in FIN_SYMS:
spec = c["Lev_Money_Positions_Long_All"] - c["Lev_Money_Positions_Short_All"]
comm = c["Dealer_Positions_Long_All"] - c["Dealer_Positions_Short_All"]
else:
spec = c["M_Money_Positions_Long_All"] - c["M_Money_Positions_Short_All"]
comm = (c["Prod_Merc_Positions_Long_All"] - c["Prod_Merc_Positions_Short_All"])
sign = -1.0 if sym in SIGN_FLIP else 1.0
out = pd.DataFrame({"published": c["published"]})
out["cot_spec_net"] = sign * spec / oi
out["cot_comm_net"] = sign * comm / oi
out["cot_idx_1y"] = out["cot_spec_net"].rolling(52, min_periods=26).rank(pct=True)
out["cot_idx_3y"] = out["cot_spec_net"].rolling(156, min_periods=78).rank(pct=True)
out["cot_chg_4w"] = out["cot_spec_net"].diff(4)
return out
def fred_features() -> list[tuple[str, pd.DataFrame]]:
# NFCI deliberately excluded: revised series with no ALFRED archive
specs = [
("vix", "VIXCLS", "level"), ("vix_chg5", "VIXCLS", "diff5"),
("t10y2y", "T10Y2Y", "level"), ("usd_chg5", "DTWEXBGS", "diff5"),
("bei5_chg20", "T5YIE", "diff20"), ("dff_chg20", "DFF", "diff20"),
]
out = []
for name, sid, kind in specs:
f = pd.read_csv(DATA_ROOT / "fred" / f"{sid}.csv",
parse_dates=["observed", "published"])
v = f["value"]
if kind == "diff5":
v = v.diff(5)
elif kind == "diff20":
v = v.diff(20)
out.append((name, pd.DataFrame({"published": f["published"], name: v})))
return out
def asof_join(bars: pd.DataFrame, feats: pd.DataFrame, col_prefix: str = "") -> pd.DataFrame:
feats = feats.sort_values("published").dropna()
feats["published"] = feats["published"].astype("datetime64[ns]")
cols = [c for c in feats.columns if c != "published"]
left = bars[["time"]].astype({"time": "datetime64[ns]"})
joined = pd.merge_asof(left, feats, left_on="time",
right_on="published", direction="backward")
return joined[cols]
def _tercile(x: np.ndarray) -> np.ndarray:
ranks = pd.Series(x).rank(method="first")
return (np.ceil(ranks * 3 / len(x)).astype(int) - 1).to_numpy()
def mi_3x3(x: np.ndarray, y_bin: np.ndarray) -> float:
"""MI (nats) between equal-frequency 3-binned x and pre-binned y."""
x_bin = _tercile(x)
table = np.zeros((3, 3))
for i in range(3):
for j in range(3):
table[i, j] = np.sum((x_bin == i) & (y_bin == j))
p = table / table.sum()
px, py = p.sum(1, keepdims=True), p.sum(0, keepdims=True)
with np.errstate(divide="ignore", invalid="ignore"):
terms = p * np.log(p / (px @ py))
return float(np.nansum(terms))
def cmi_3x3x3(x: np.ndarray, y_bin: np.ndarray, z_bin: np.ndarray) -> float:
"""I(X;Y|Z): the INCREMENTAL information beyond the conditioning variable.
X is tercile-binned WITHIN each Z stratum, so X-Z correlation cannot
degenerate the strata. I(X;Y|Z) = sum_z p(z) I(X;Y|Z=z).
"""
out, n = 0.0, len(x)
for z in range(3):
m = z_bin == z
if m.sum() < 60:
continue
out += (m.sum() / n) * mi_3x3(x[m], y_bin[m])
return out
def screen(sym: str, perms: int, target: str, rng: np.random.Generator) -> None:
bars = load_bars(sym)
tr = true_range(bars)
atr = tr.ewm(alpha=1 / 14, min_periods=14).mean()
trailing = tr.rolling(5).sum() / atr # conditioning var
if target == "range":
y_raw = tr.shift(-1).rolling(5).sum().shift(-4) / atr # bars t+1..t+5
else: # dir: signed forward 5-bar move - the closed-for-price channel
y_raw = (bars["close"].shift(-5) - bars["close"]) / atr
feats = pd.DataFrame(index=bars.index)
cot = cot_features(sym)
if cot is not None:
feats = pd.concat([feats, asof_join(bars, cot)], axis=1)
for name, f in fred_features():
feats[name] = asof_join(bars, f)[name]
feats["CTRL_trailing_range"] = trailing # positive control for range target;
# for the conditional column it must collapse to ~0 (conditioned on itself)
feats["CTRL_noise"] = rng.standard_normal(len(bars))
valid = y_raw.notna() & trailing.notna()
y_bin = _tercile(y_raw[valid].to_numpy())
z_bin = _tercile(trailing[valid].to_numpy())
print(f"\n=== {sym}: {int(valid.sum())} D1 bars, target={target}, "
f"{perms} circular-shift perms; 'MI|vol' = conditional on trailing range ===")
results, null_max = [], np.zeros(perms)
for name in feats.columns:
x = feats.loc[valid, name].to_numpy(dtype=float)
ok = ~np.isnan(x)
if ok.sum() < 500:
print(f" {name:22s} skipped ({ok.sum()} rows)")
continue
xo, yo, zo = x[ok], y_bin[ok], z_bin[ok]
n = len(xo)
obs = mi_3x3(xo, yo)
obs_c = cmi_3x3x3(xo, yo, zo)
null = np.empty(perms)
null_c = np.empty(perms)
for k in range(perms):
xs = np.roll(xo, rng.integers(63, n - 63))
null[k] = mi_3x3(xs, yo)
null_c[k] = cmi_3x3x3(xs, yo, zo)
p = (np.sum(null >= obs) + 1) / (perms + 1)
p_c = (np.sum(null_c >= obs_c) + 1) / (perms + 1)
results.append((name, obs, p, obs_c, p_c))
null_max = np.maximum(null_max, null) # family-wise: max across features per draw
fam_bar = np.quantile(null_max, 0.95)
print(f" family-wise 5% bar (max-MI over {len(results)} features): {fam_bar:.5f}")
for name, obs, p, obs_c, p_c in sorted(results, key=lambda r: -r[1]):
fam = "FAMILY" if obs > fam_bar else " "
print(f" {name:22s} MI {obs:.5f} p={p:.4f} {fam} | "
f"MI|vol {obs_c:.5f} p={p_c:.4f}")
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--sym", nargs="*", default=["SP500", "EURUSD", "USDJPY", "XAUUSD"])
ap.add_argument("--perms", type=int, default=199)
ap.add_argument("--target", choices=["range", "dir"], default="range")
ap.add_argument("--seed", type=int, default=7)
args = ap.parse_args()
rng = np.random.default_rng(args.seed)
for sym in args.sym:
screen(sym, args.perms, args.target, rng)
if __name__ == "__main__":
main()