Files
resonance-engine/_analyze_continuous.py
T
2026-06-07 11:34:30 +07:00

221 lines
8.7 KiB
Python

"""_analyze_continuous.py — analyze inject_continuous_stream 4-arm output.
For each arm A/B/C (read from parquet):
- merge with source data to get forward returns at h=60, h=240
- fit linear regression: |fwd_return(h)| ~ field_state_features
- report R² (out-of-sample, time-series-aware k-fold)
For arm D (raw data baseline, computed inline here):
- fit linear regression: |fwd_return(h)| ~ raw z-scores of 3 variables
- report R²
Conclusion logic:
If R²(B or C) > R²(D) + meaningful margin → lattice adds information.
If R²(B or C) <= R²(D) → lattice is a noisy filter, use raw data directly.
Usage:
python3 _analyze_continuous.py latest
python3 _analyze_continuous.py <RUN_ID>
"""
from __future__ import annotations
import glob, json, sys
from pathlib import Path
import numpy as np
import pandas as pd
from sklearn.linear_model import Ridge
from sklearn.model_selection import TimeSeriesSplit
from sklearn.metrics import r2_score
ROOT = Path("/mnt/d/Resonance_Engine/traj")
DATA_ROOT = "/mnt/d/PaperTrader/research/hl_data/minutes"
FIELD_FEATURES = [
"asymmetry", "coherence",
"stress_xx", "stress_yy", "stress_xy",
"vorticity_mean", "vel_mean", "vel_max", "vel_var",
]
VARIABLES = ["signed_flow_usd", "vwap_drift", "wallet_entropy"]
HORIZONS = [60, 240, 720, 1440]
def find_run(arg: str) -> Path:
if arg == "latest":
runs = sorted([p for p in ROOT.glob("contstream_*") if p.is_dir()])
if not runs:
sys.exit("no contstream_* runs in " + str(ROOT))
return runs[-1]
p = ROOT / (arg if arg.startswith("contstream_") else f"contstream_{arg}")
if not p.exists():
sys.exit(f"missing: {p}")
return p
def load_btc_data(meta: dict) -> pd.DataFrame:
day_dirs = sorted(glob.glob(f"{DATA_ROOT}/{meta['days_glob']}"))
dfs = []
for d in day_dirs:
for f in sorted(glob.glob(f"{d}/*.parquet")):
dfs.append(pd.read_parquet(f))
df = pd.concat(dfs, ignore_index=True)
df = df[df.coin == meta["coin"]].sort_values("minute").reset_index(drop=True)
df = df.drop_duplicates(subset=["minute"]).reset_index(drop=True)
return df
def add_forward_returns(df: pd.DataFrame, horizons: list[int]) -> pd.DataFrame:
df = df.copy()
mid = df["mid_price"].values
for h in horizons:
fwd = np.full_like(mid, np.nan, dtype=float)
fwd[: -h] = (mid[h:] - mid[:-h]) / mid[:-h]
df[f"fwd_ret_{h}"] = fwd
df[f"abs_fwd_ret_{h}"] = np.abs(fwd)
return df
def compute_one_sided_zscores(df: pd.DataFrame, vars_: list[str], win: int) -> pd.DataFrame:
out = pd.DataFrame({"minute": df["minute"].values})
for v in vars_:
s = df[v].astype(float)
roll = s.shift(1).rolling(window=win, min_periods=50)
z = (s - roll.mean()) / roll.std().replace(0, np.nan)
out[f"z_{v}"] = z.values
return out
def cv_r2(X: np.ndarray, y: np.ndarray, n_splits: int = 5) -> dict:
"""Time-series-aware k-fold R² with Ridge regression."""
mask = np.all(np.isfinite(X), axis=1) & np.isfinite(y)
X = X[mask]
y = y[mask]
if len(y) < 500:
return {"r2_mean": float("nan"), "r2_std": float("nan"), "n": len(y)}
tscv = TimeSeriesSplit(n_splits=n_splits)
scores = []
for tr, te in tscv.split(X):
model = Ridge(alpha=1.0)
model.fit(X[tr], y[tr])
scores.append(r2_score(y[te], model.predict(X[te])))
return {
"r2_mean": float(np.mean(scores)),
"r2_std": float(np.std(scores)),
"r2_folds": [float(s) for s in scores],
"n": int(len(y)),
}
def main():
arg = sys.argv[1] if len(sys.argv) > 1 else "latest"
run = find_run(arg)
print(f"=== analyzing {run.name} ===\n")
meta = json.loads((run / "meta.json").read_text())
print(f"run_id={meta['run_id']} start={meta.get('wall_iso_start')}")
print(f"coin={meta['coin']} days={meta['days_glob']} n_minutes={meta['n_minutes']}")
print(f"vars={meta['variables']} roll_win={meta['roll_win']} str_cap={meta['str_cap']}\n")
# load source data + add forward returns + z-scores
src = load_btc_data(meta)
src = add_forward_returns(src, HORIZONS)
z = compute_one_sided_zscores(src, VARIABLES, meta["roll_win"])
src = src.merge(z, on="minute", how="left")
print(f"source data: {len(src)} minutes, fwd returns at h={HORIZONS} added\n")
# ───── ARM D: raw-data baseline (offline) ─────
print("=== ARM D: RAW DATA BASELINE (no lattice) ===")
Xd = src[[f"z_{v}" for v in VARIABLES]].values
arm_d_results = {}
for h in HORIZONS:
y = src[f"abs_fwd_ret_{h}"].values
res = cv_r2(Xd, y)
arm_d_results[h] = res
print(f" h={h}min R²(|fwd_ret|)= {res['r2_mean']:+.5f} +/- {res['r2_std']:.5f} "
f"folds={[f'{x:+.4f}' for x in res.get('r2_folds',[])]} n={res['n']}")
print()
# ───── ARMS A/B/C ─────
arm_files = {
"A": run / "arm_A_no_inject.parquet",
"B": run / "arm_B_stacked.parquet",
"C": run / "arm_C_separated.parquet",
}
arm_results = {arm: {} for arm in arm_files}
for arm, fpath in arm_files.items():
if not fpath.exists():
print(f"=== ARM {arm}: skipped (no file {fpath.name}) ===\n")
continue
df_arm = pd.read_parquet(fpath)
# merge with source on minute (to attach forward returns)
m = df_arm.merge(src[["minute"] + [f"abs_fwd_ret_{h}" for h in HORIZONS]],
on="minute", how="left")
print(f"=== ARM {arm}: {fpath.name} rows={len(m)} ===")
# describe field
for ch in ["asymmetry", "coherence", "stress_xy"]:
v = m[ch].dropna()
if len(v):
print(f" {ch}: mean={v.mean():+.4f} std={v.std():.4f} "
f"min={v.min():+.4f} max={v.max():+.4f}")
# build feature matrix from field channels
X = m[FIELD_FEATURES].values
for h in HORIZONS:
y = m[f"abs_fwd_ret_{h}"].values
res = cv_r2(X, y)
arm_results[arm][h] = res
print(f" h={h}min R²(|fwd_ret|)= {res['r2_mean']:+.5f} +/- {res['r2_std']:.5f} "
f"folds={[f'{x:+.4f}' for x in res.get('r2_folds',[])]} n={res['n']}")
# also: field + raw z (combined)
m2 = m.merge(z, on="minute", how="left")
Xc = m2[FIELD_FEATURES + [f"z_{v}" for v in VARIABLES]].values
for h in HORIZONS:
y = m2[f"abs_fwd_ret_{h}"].values
res = cv_r2(Xc, y)
arm_results[arm][f"{h}_combined"] = res
print(f" h={h}min COMBINED(field+rawZ) R²= {res['r2_mean']:+.5f} n={res['n']}")
print()
# ───── verdict ─────
print("=" * 70)
print("VERDICT")
print("=" * 70)
print(f"{'metric':<35}{'h=60':>14}{'h=240':>14}")
print("-" * 70)
print(f"{'arm D (raw data only)':<35}"
f"{arm_d_results.get(60,{}).get('r2_mean',float('nan')):>+14.5f}"
f"{arm_d_results.get(240,{}).get('r2_mean',float('nan')):>+14.5f}")
for arm in ["A", "B", "C"]:
if arm not in arm_results or not arm_results[arm]:
continue
print(f"{'arm '+arm+' (field only)':<35}"
f"{arm_results[arm].get(60,{}).get('r2_mean',float('nan')):>+14.5f}"
f"{arm_results[arm].get(240,{}).get('r2_mean',float('nan')):>+14.5f}")
print(f"{'arm '+arm+' (field + rawZ combined)':<35}"
f"{arm_results[arm].get('60_combined',{}).get('r2_mean',float('nan')):>+14.5f}"
f"{arm_results[arm].get('240_combined',{}).get('r2_mean',float('nan')):>+14.5f}")
print()
# decision
print("Interpretation:")
d60 = arm_d_results.get(60, {}).get("r2_mean", float("nan"))
for arm in ["B", "C"]:
if arm not in arm_results or not arm_results[arm]:
continue
r60 = arm_results[arm].get(60, {}).get("r2_mean", float("nan"))
rc60 = arm_results[arm].get("60_combined", {}).get("r2_mean", float("nan"))
if np.isfinite(r60) and np.isfinite(d60):
delta_pure = r60 - d60
delta_comb = (rc60 - d60) if np.isfinite(rc60) else float("nan")
v_pure = "lattice ADDS info" if delta_pure > 0.005 else \
"lattice DOES NOT add info beyond raw data"
print(f" arm {arm} h=60: field-only ΔR² vs raw={delta_pure:+.5f}{v_pure}")
if np.isfinite(delta_comb):
v_comb = "combination beats raw" if delta_comb > 0.005 else \
"combination no better than raw"
print(f" arm {arm} h=60: combined ΔR² vs raw={delta_comb:+.5f}{v_comb}")
print("\n=== DONE ===")
if __name__ == "__main__":
main()