Files
resonance-engine/regime_classifier_injector.py
T
2026-06-08 13:34:30 +07:00

609 lines
24 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""regime_classifier_injector.py — 3-channel lattice regime classifier.
DIRECTIVE FROM CLAUDE DESKTOP — Option B (2026-06-08)
After 6h of 8-tension encoding confirming the predictive-feature path is dead,
abandon "lattice as predictor". Use the lattice as a BINARY REGIME CLASSIFIER
on top of trade_count (the strongest validated raw signal).
INPUTS — three channels, injected per minute (no 4hr aggregation, no levels/vels):
1. trade_count z-score (rolling 500min one-sided) → CENTER (512, 512)
2. taker_buy_usd z-score (rolling 500min one-sided) → (400, 512)
3. taker_sell_usd z-score (rolling 500min one-sided) → (624, 512)
Strength = z / 3 capped ±1.0. Sigma = 32. No exotic geometry: just three
gaussian blobs along x, with buy at left and sell at right so an imbalance
creates a directional asymmetry in the stress field.
Why these three:
- trade_count: validated +1.0 Spearman rank stability IS/OOS.
- taker_buy + taker_sell SEPARATELY (not signed_flow): signed_flow shows
zero predictive value; the components separately carry volume information
the lattice can integrate.
OUTPUTS:
Same 9 telemetry channels as before (asymmetry, coherence, stress_xx, ...).
Downstream analyzer uses arm A free-running quantiles to set p10/p90
thresholds for HIGH/LOW/NEUTRAL regime labels.
SUCCESS CRITERION (downstream test, not in this script):
|fwd_60| volatility ratio for (HIGH TENSION ∩ top-decile trade_count) minutes
vs (top-decile trade_count alone). Current best is 2.42x. Beat that or the
lattice is redundant.
OUTPUT FILES:
/mnt/d/Resonance_Engine/traj/regime_<RUN_ID>/
meta.json
arm_A_no_inject.parquet
arm_T_3ch.parquet
progress.log
"""
from __future__ import annotations
import argparse, glob, json, queue, threading, time, urllib.request, urllib.error
from pathlib import Path
import numpy as np
import pandas as pd
import zmq
# ───────────────────────── config ─────────────────────────
DATA_ROOT = "/mnt/d/PaperTrader/research/hl_data/minutes"
COIN = "BTC"
DEFAULT_DAYS_GLOB = "20260415*"
# z-score rolling window (one-sided, past-only via shift(1))
Z_WIN_MIN = 500
Z_MIN_PERIODS = 100
# strength = z / Z_DENOM capped to STR_CAP
Z_DENOM = 3.0
STR_CAP = 1.0
# Lattice spatial layout (1024×1024)
CENTER = (512.0, 512.0)
TAKER_BUY_XY = (400.0, 512.0) # left of center
TAKER_SELL_XY = (624.0, 512.0) # right of center
SIGMA = 32.0
# Timing
PER_MINUTE_MS = 100 # 10× realtime
WAIT_AFTER_INJECT_MS = 60 # only 3 injects, less settling time needed
COOLDOWN_BETWEEN_ARMS_S = 1200 # 20 min
# ZMQ
TEL_ADDR = "tcp://127.0.0.1:5556"
CMD_ADDR = "tcp://127.0.0.1:5557"
CHANNELS = ["asymmetry", "coherence", "stress_xx", "stress_yy", "stress_xy",
"vorticity_mean", "vel_mean", "vel_max", "vel_var"]
# Fractonaut HTTP API (read-only observer, port 28822 — note: 28821 in directive
# was wrong; actual code uses 28822)
FRACTO_URL = "http://127.0.0.1:28822/ask"
FRACTO_TIMEOUT_S = 180 # Ollama can take 30-60s for gemma3:4b responses
FRACTO_QUEUE_MAX = 64 # cap pending queries — drop new ones if full
RUN_ID = time.strftime("%Y%m%dT%H%M%S")
OUT_DIR = Path(f"/mnt/d/Resonance_Engine/traj/regime_{RUN_ID}")
PROGRESS = OUT_DIR / "progress.log"
FRACTO_LOG = OUT_DIR / "fractonaut_observations.jsonl"
def log(msg: str) -> None:
line = f"[{time.strftime('%Y-%m-%dT%H:%M:%S')}] {msg}"
print(line, flush=True)
OUT_DIR.mkdir(parents=True, exist_ok=True)
with PROGRESS.open("a") as f:
f.write(line + "\n")
# ───────────────────────── Fractonaut querier ─────────────────────────
class FractoQuerier:
"""Fire-and-forget background querier. Submit() returns immediately;
a worker thread serializes calls to /ask (Ollama only handles one
request at a time anyway) and appends responses to fractonaut_observations.jsonl.
"""
def __init__(self, url: str = FRACTO_URL):
self.url = url
self.q: queue.Queue = queue.Queue(maxsize=FRACTO_QUEUE_MAX)
self.dropped = 0
self.submitted = 0
self.responded = 0
self.errors = 0
self._stop = threading.Event()
self._t = threading.Thread(target=self._worker, daemon=True)
self._t.start()
def submit(self, question: str, ctx: dict) -> None:
"""Non-blocking. Drops the query if the queue is full."""
item = {
"submit_wall": time.time(),
"question": question,
"context": ctx,
}
try:
self.q.put_nowait(item)
self.submitted += 1
except queue.Full:
self.dropped += 1
def _worker(self) -> None:
while not self._stop.is_set():
try:
item = self.q.get(timeout=1.0)
except queue.Empty:
continue
self._call(item)
self.q.task_done()
def _call(self, item: dict) -> None:
body = json.dumps({"question": item["question"]}).encode()
req = urllib.request.Request(
self.url, data=body,
headers={"Content-Type": "application/json"}, method="POST",
)
t0 = time.time()
resp_text = None
err = None
try:
with urllib.request.urlopen(req, timeout=FRACTO_TIMEOUT_S) as r:
resp_json = json.loads(r.read())
resp_text = resp_json.get("response", "")
self.responded += 1
except Exception as e:
err = str(e)
self.errors += 1
elapsed = time.time() - t0
entry = {
"submit_wall": item["submit_wall"],
"response_wall": time.time(),
"elapsed_s": elapsed,
"context": item["context"],
"question": item["question"],
"response": resp_text,
"error": err,
}
OUT_DIR.mkdir(parents=True, exist_ok=True)
with FRACTO_LOG.open("a") as f:
f.write(json.dumps(entry) + "\n")
def drain(self, max_wait_s: float = 600.0) -> None:
"""Block until queue is empty or timeout."""
deadline = time.time() + max_wait_s
while time.time() < deadline:
if self.q.empty():
# let in-flight call finish
time.sleep(1)
if self.q.empty():
return
time.sleep(2)
def stop(self) -> None:
self._stop.set()
self._t.join(timeout=5)
def stats(self) -> str:
return (f"fracto submitted={self.submitted} responded={self.responded} "
f"dropped={self.dropped} errors={self.errors} pending={self.q.qsize()}")
# ───────────────────────── ZMQ telemetry ─────────────────────────
class LatestTel:
def __init__(self, addr: str):
self.ctx = zmq.Context.instance()
self.sock = self.ctx.socket(zmq.SUB)
self.sock.connect(addr)
self.sock.setsockopt(zmq.SUBSCRIBE, b"")
self.sock.setsockopt(zmq.RCVHWM, 2000)
self.latest: dict | None = None
self.latest_wall: float = 0.0
self.n_seen = 0
self._stop = threading.Event()
self._t = threading.Thread(target=self._run, daemon=True)
self._t.start()
def _run(self):
while not self._stop.is_set():
if self.sock.poll(100):
try:
raw = self.sock.recv_string(zmq.NOBLOCK)
self.latest = json.loads(raw)
self.latest_wall = time.time()
self.n_seen += 1
except Exception:
pass
def snapshot(self) -> tuple[dict | None, float]:
return self.latest, self.latest_wall
def stop(self):
self._stop.set()
self._t.join(timeout=2)
try:
self.sock.close(0)
except Exception:
pass
# ───────────────────────── data loading ─────────────────────────
def load_btc(days_glob: str) -> pd.DataFrame:
day_dirs = sorted(glob.glob(f"{DATA_ROOT}/{days_glob}"))
if not day_dirs:
raise RuntimeError(f"no day dirs matching {days_glob} in {DATA_ROOT}")
log(f"loading {len(day_dirs)} day dirs ({day_dirs[0].split('/')[-1]} .. {day_dirs[-1].split('/')[-1]})")
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 == COIN].sort_values("minute").drop_duplicates("minute").reset_index(drop=True)
log(f"loaded {len(df)} unique minutes of {COIN}")
return df
def compute_zscores(df: pd.DataFrame) -> pd.DataFrame:
"""Rolling 500-min one-sided z-scores for trade_count, taker_buy_usd,
taker_sell_usd. Past-only (shift(1) before rolling)."""
out = df.copy()
for col in ["trade_count", "taker_buy_usd", "taker_sell_usd"]:
s = out[col].astype(float).shift(1)
mu = s.rolling(Z_WIN_MIN, min_periods=Z_MIN_PERIODS).mean()
sd = s.rolling(Z_WIN_MIN, min_periods=Z_MIN_PERIODS).std()
out[f"{col}_z"] = (out[col].astype(float) - mu) / sd.replace(0, np.nan)
return out
# ───────────────────────── injection ─────────────────────────
def _send_inject(pub: zmq.Socket, x: float, y: float,
strength: float, sigma: float = SIGMA) -> None:
payload = {"cmd": "inject_density",
"x": float(x), "y": float(y),
"sigma": float(sigma),
"strength": float(strength)}
pub.send_string(json.dumps(payload))
def _z_to_strength(z: float) -> float | None:
if not np.isfinite(z):
return None
s = float(np.clip(z / Z_DENOM, -STR_CAP, +STR_CAP))
if abs(s) < 1e-6:
return None
return s
def encode_trade_count(pub: zmq.Socket, z: float) -> None:
s = _z_to_strength(z)
if s is None: return
cx, cy = CENTER
_send_inject(pub, cx, cy, s)
def encode_taker_buy(pub: zmq.Socket, z: float) -> None:
s = _z_to_strength(z)
if s is None: return
x, y = TAKER_BUY_XY
_send_inject(pub, x, y, s)
def encode_taker_sell(pub: zmq.Socket, z: float) -> None:
s = _z_to_strength(z)
if s is None: return
x, y = TAKER_SELL_XY
_send_inject(pub, x, y, s)
# ───────────────────────── per-arm runner ─────────────────────────
def classify_regime(asym: float, coh: float,
asym_p10: float | None, asym_p90: float | None,
coh_p10: float | None, coh_p90: float | None) -> str:
"""Return one of HIGH / LOW / NEUTRAL. Returns NEUTRAL if thresholds
not yet known or telemetry invalid."""
if any(v is None or not np.isfinite(v) for v in
(asym, coh, asym_p10, asym_p90, coh_p10, coh_p90)):
return "NEUTRAL"
if asym > asym_p90 or coh < coh_p10:
return "HIGH"
if asym < asym_p10 and coh > coh_p90:
return "LOW"
return "NEUTRAL"
def snapshot_row(tel: LatestTel, minute: int, arm: str,
row: pd.Series) -> dict:
snap, snap_wall = tel.snapshot()
rec = {
"minute": int(minute),
"arm": arm,
"snap_wall": snap_wall,
"snap_age_ms": (time.time() - snap_wall) * 1000 if snap_wall else None,
# raw values for downstream analysis
"trade_count": float(row.get("trade_count", np.nan)),
"taker_buy_usd": float(row.get("taker_buy_usd", np.nan)),
"taker_sell_usd": float(row.get("taker_sell_usd", np.nan)),
"mid_price": float(row.get("mid_price", np.nan)),
# z-scores actually injected
"trade_count_z": row.get("trade_count_z"),
"taker_buy_usd_z": row.get("taker_buy_usd_z"),
"taker_sell_usd_z": row.get("taker_sell_usd_z"),
}
if snap is None:
for ch in CHANNELS:
rec[ch] = None
else:
for ch in CHANNELS:
rec[ch] = snap.get(ch)
return rec
def run_arm(tel: LatestTel, pub: zmq.Socket | None, arm: str,
df: pd.DataFrame,
fracto: FractoQuerier | None = None,
thresholds: dict | None = None) -> pd.DataFrame:
"""Run one arm.
If `fracto` and `thresholds` are supplied, regime labels (HIGH/LOW/NEUTRAL)
are computed each minute, transitions are detected, and a Fractonaut /ask
query is fired (non-blocking) on every transition.
"""
log(f"\n=== arm {arm}: starting ({len(df)} minutes) ===")
if arm == "A":
log(" arm A: NO INJECTIONS — sampling state at cadence only")
elif arm == "T":
log(" arm T: 3 Z-SCORE INJECTIONS per minute (trade_count center, buy left, sell right)")
if fracto is not None and thresholds is not None:
log(f" fractonaut transitions ENABLED thresholds={thresholds}")
records: list[dict] = []
t_arm_start = time.time()
last_status = t_arm_start
n_minutes = len(df)
step_s = PER_MINUTE_MS / 1000.0
wait_inject_s = WAIT_AFTER_INJECT_MS / 1000.0
prev_regime = "NEUTRAL"
n_transitions = 0
for i in range(n_minutes):
t_tick = time.time()
row = df.iloc[i]
if arm == "T" and pub is not None:
encode_trade_count(pub, row.get("trade_count_z"))
encode_taker_buy (pub, row.get("taker_buy_usd_z"))
encode_taker_sell (pub, row.get("taker_sell_usd_z"))
time.sleep(wait_inject_s)
else:
time.sleep(wait_inject_s)
rec = snapshot_row(tel, int(row.minute), arm, row)
# Regime classification & transition detection (arm T only — arm A is the
# threshold source, we don't ask Fractonaut about its own baseline)
if arm == "T" and fracto is not None and thresholds is not None:
asym = rec.get("asymmetry")
coh = rec.get("coherence")
label = classify_regime(
asym, coh,
thresholds.get("asym_p10"), thresholds.get("asym_p90"),
thresholds.get("coh_p10"), thresholds.get("coh_p90"),
)
rec["regime"] = label
if label != prev_regime and label in ("HIGH", "LOW"):
n_transitions += 1
tc_z = row.get("trade_count_z", float("nan"))
buy_z = row.get("taker_buy_usd_z", float("nan"))
sell_z = row.get("taker_sell_usd_z", float("nan"))
question = (
f"Field just entered {label} TENSION state "
f"(from {prev_regime}). "
f"asymmetry={asym:.1f} coherence={coh:.4f} "
f"trade_count_z={tc_z:.2f} buy_z={buy_z:.2f} sell_z={sell_z:.2f}. "
f"Have you seen this configuration before? "
f"What pattern does this resemble and what followed last time?"
)
fracto.submit(question, {
"event": "regime_transition",
"minute": int(row.minute),
"from": prev_regime, "to": label,
"asymmetry": float(asym) if asym is not None else None,
"coherence": float(coh) if coh is not None else None,
"trade_count_z": float(tc_z) if np.isfinite(tc_z) else None,
"taker_buy_usd_z": float(buy_z) if np.isfinite(buy_z) else None,
"taker_sell_usd_z": float(sell_z) if np.isfinite(sell_z) else None,
"stress_xx": rec.get("stress_xx"),
"stress_yy": rec.get("stress_yy"),
})
elif label != prev_regime and label == "NEUTRAL" and prev_regime in ("HIGH", "LOW"):
n_transitions += 1
question = (
f"Field just exited {prev_regime} TENSION back to NEUTRAL. "
f"asymmetry={asym:.1f} coherence={coh:.4f}. "
f"How did the configuration evolve while in {prev_regime}? "
f"Did anything notable change before the return to baseline?"
)
fracto.submit(question, {
"event": "regime_transition",
"minute": int(row.minute),
"from": prev_regime, "to": label,
"asymmetry": float(asym) if asym is not None else None,
"coherence": float(coh) if coh is not None else None,
})
prev_regime = label
records.append(rec)
spent = time.time() - t_tick
remaining = step_s - spent
if remaining > 0:
time.sleep(remaining)
if time.time() - last_status > 60:
elapsed = time.time() - t_arm_start
pct = (i + 1) / n_minutes * 100
rate_per_s = (i + 1) / elapsed if elapsed > 0 else 0
eta_s = (n_minutes - i - 1) / rate_per_s if rate_per_s > 0 else 0
cur_field = tel.latest or {}
asym = cur_field.get('asymmetry', float('nan'))
extras = ""
if fracto is not None and arm == "T":
extras = f" trans={n_transitions} " + fracto.stats()
log(f" arm {arm} {i+1}/{n_minutes} ({pct:.1f}%) "
f"rate={rate_per_s:.1f}min/s ETA={eta_s/60:.1f}min "
f"asym={asym:.3f} "
f"latest_tel_age={(time.time()-tel.latest_wall)*1000 if tel.latest_wall else -1:.0f}ms "
f"tel_seen={tel.n_seen}{extras}")
last_status = time.time()
log(f" arm {arm} COMPLETE records={len(records)} total={(time.time()-t_arm_start)/60:.1f}min")
if fracto is not None and arm == "T":
log(f" arm T transitions={n_transitions} {fracto.stats()}")
return pd.DataFrame(records)
# ───────────────────────── main ─────────────────────────
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--days-glob", default=DEFAULT_DAYS_GLOB,
help="day-dir glob under hl_data/minutes/")
ap.add_argument("--max-minutes", type=int, default=None,
help="cap minutes (shakedown)")
ap.add_argument("--arms", default="A,T",
help="comma-separated arms to run (default A,T)")
ap.add_argument("--cooldown-s", type=int, default=COOLDOWN_BETWEEN_ARMS_S,
help="seconds between arms (only after arm T)")
args = ap.parse_args()
log(f"=== regime_classifier_injector run_id={RUN_ID} ===")
log(f"out_dir={OUT_DIR}")
log(f"args: days_glob={args.days_glob} max_minutes={args.max_minutes} "
f"arms={args.arms}")
df = load_btc(args.days_glob)
if args.max_minutes:
df = df.head(args.max_minutes).reset_index(drop=True)
log(f"capped to first {args.max_minutes} minutes")
df = compute_zscores(df)
log("z-scores computed (past-only 500min rolling):")
for col in ["trade_count_z", "taker_buy_usd_z", "taker_sell_usd_z"]:
s = df[col]
finite = int(np.isfinite(s.astype(float)).sum())
log(f" {col:<22} n_finite={finite:>5}/{len(s)} "
f"mean={s.mean():+.4f} std={s.std():+.4f} "
f"min={s.min():+.4f} max={s.max():+.4f}")
meta = {
"run_id": RUN_ID,
"coin": COIN,
"data_root": DATA_ROOT,
"days_glob": args.days_glob,
"n_minutes": int(len(df)),
"minute_range": [int(df.minute.min()), int(df.minute.max())],
"z_win_min": Z_WIN_MIN,
"z_denom": Z_DENOM,
"str_cap": STR_CAP,
"spatial": {
"center": list(CENTER),
"taker_buy_xy": list(TAKER_BUY_XY),
"taker_sell_xy": list(TAKER_SELL_XY),
"sigma": SIGMA,
},
"timing": {
"per_minute_ms": PER_MINUTE_MS,
"wait_after_inject_ms": WAIT_AFTER_INJECT_MS,
"cooldown_between_arms_s": COOLDOWN_BETWEEN_ARMS_S,
},
"arms": args.arms.split(","),
}
(OUT_DIR / "meta.json").write_text(json.dumps(meta, indent=2))
tel = LatestTel(TEL_ADDR)
ctx = zmq.Context.instance()
pub = ctx.socket(zmq.PUB)
pub.connect(CMD_ADDR)
log(f"ZMQ connected: SUB {TEL_ADDR}, PUB {CMD_ADDR}")
log("warming up SUB socket (3s) ...")
time.sleep(3)
log(f" initial tel_seen={tel.n_seen} latest_age_ms="
f"{(time.time()-tel.latest_wall)*1000 if tel.latest_wall else -1:.0f}")
# Fractonaut querier (fire-and-forget). It is OK if the HTTP service is down
# — the worker will just log errors; the run keeps going.
fracto = FractoQuerier()
log(f"FractoQuerier ready; HTTP target = {FRACTO_URL}")
arm_A_telemetry: pd.DataFrame | None = None
thresholds: dict | None = None
try:
arms_to_run = [a.strip().upper() for a in args.arms.split(",")]
for ai, arm in enumerate(arms_to_run):
if arm not in ("A", "T"):
log(f"skipping unknown arm: {arm}")
continue
if ai > 0 and arms_to_run[ai - 1] == "T":
log(f"--- cooldown {args.cooldown_s}s ---")
time.sleep(args.cooldown_s)
if arm == "T" and arm_A_telemetry is not None:
# Derive thresholds from arm A telemetry distribution
arm_A_clean = arm_A_telemetry.dropna(subset=["asymmetry", "coherence"])
thresholds = {
"asym_p10": float(arm_A_clean["asymmetry"].quantile(0.10)),
"asym_p90": float(arm_A_clean["asymmetry"].quantile(0.90)),
"coh_p10": float(arm_A_clean["coherence"].quantile(0.10)),
"coh_p90": float(arm_A_clean["coherence"].quantile(0.90)),
}
log(f"derived thresholds from arm A: {thresholds}")
# START-OF-T summary query
fracto.submit(
("Arm A free-running baseline complete. "
"Arm T market injection beginning now. "
"Three channels: trade_count at centre (512,512), "
"taker_buy at x=400, taker_sell at x=624, each as "
"rolling-500min z-score capped ±1. "
"Summarise what you observed during arm A. "
"What is the baseline field character?"),
{"event": "arm_T_start", "thresholds": thresholds,
"arm_A_rows": int(len(arm_A_clean))},
)
arm_df = run_arm(
tel,
pub if arm == "T" else None,
arm, df,
fracto=fracto if arm == "T" else None,
thresholds=thresholds if arm == "T" else None,
)
outf = OUT_DIR / f"arm_{arm}_{'3ch' if arm=='T' else 'no_inject'}.parquet"
arm_df.to_parquet(outf)
log(f" saved {outf.name} ({len(arm_df)} rows)")
if arm == "A":
arm_A_telemetry = arm_df
elif arm == "T":
# END-OF-T summary query
fracto.submit(
("Arm T complete. Injection has been running for the full "
"April dataset. What patterns did you observe during the "
"injection period that were absent or different from the "
"arm A baseline? What field configurations appeared "
"repeatedly at HIGH TENSION moments?"),
{"event": "arm_T_end", "arm_T_rows": int(len(arm_df))},
)
log(f"\n=== ALL ARMS COMPLETE ===")
log(f"output: {OUT_DIR}")
log(f"draining FractoQuerier (timeout 600s) ... {fracto.stats()}")
fracto.drain(max_wait_s=600)
log(f"fracto drain complete {fracto.stats()}")
finally:
fracto.stop()
tel.stop()
pub.close(0)
ctx.term()
if __name__ == "__main__":
main()