Files
resonance-engine/replay_injector.py
T
2026-06-09 08:34:31 +07:00

713 lines
30 KiB
Python

"""replay_injector.py — 3-month BTC replay into trade_lbm_v1 with
continuous Fractonaut observation.
Builds on validate_trade_v1.py replay loop. Differences:
* Source: all of March/April/May 2026 BTC parquets (chronological).
* No artificial sleep between minutes beyond MINUTE_DT (default 0.10s);
daemon runs as fast as it can take ZMQ commands.
* Fractonaut queries are RATE-LIMITED and ASYNC (background worker
thread) so a slow LLM call does not stall the replay loop.
* Trigger rules:
a) Any structural event (consolidation/breakout/support/resistance),
throttled to max 1 query per 30 replay-minutes; if multiple
events fire inside the window we pick the highest-magnitude one.
b) regime_product crossings of 1.0 (quiet->active) and 8.0
(active->spike) in either direction.
c) Start of each calendar month (March 1, April 1, May 1, UTC).
d) End of replay: synthesis query.
* Per-minute parquet row: minute, mid_price, regime_product, asy, coh,
last_trade_age_s, events_fired (JSON), fractonaut_queried (bool),
query_reason (str).
* Fractonaut chronicle: JSONL tagged with minute, mid_price,
regime_product, query_reason.
* Post-run correlation: forward returns at h=60 and h=240 by event
type / query_reason, written as a Markdown table.
Owner: RESONANCE (tagged via --agent-owner=RESONANCE marker on
command line).
"""
# --agent-owner=RESONANCE
import argparse
import gc
import json
import os
import queue
import signal
import sys
import threading
import time
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
import numpy as np
import pandas as pd
import pyarrow as pa
import pyarrow.parquet as pq
import zmq
# ============================== config ===================================
DAEMON_CMD = "tcp://127.0.0.1:5567"
DAEMON_TELEM = "tcp://127.0.0.1:5566"
FRACT_URL = "http://127.0.0.1:28822/ask"
FRACT_TO_S = 240
DATA_ROOT = Path("/mnt/d/PaperTrader/research/hl_data/minutes")
RUN_ROOT = Path("/mnt/d/Resonance_Engine/traj")
COIN = "BTC"
NX = 512
TICK_USD = 1.0
BOOK_DECAY_L = 30
MINUTE_DT = 0.10 # wall-seconds per replayed minute
RELAX_BOOT_S = 4.0 # let field absorb first book before replay
WARMUP_MIN = 30 # minutes ignored before arm_consolidation
ASK_EVENT_GAP = 30 # min replay-minutes between event queries
ASK_GLOBAL_GAP = 60 # min replay-minutes between ANY non-monthly query
# (sized so Ollama @ ~4s/query keeps up at 9 min/s replay)
ROLL_FLUSH_N = 2000 # flush parquet every N minutes
# regime_product crossing thresholds (level low, level high)
RP_THRESH = [1.0, 8.0]
# Hysteresis: regime category must persist this many minutes before a
# state-change query fires. Stops oscillation around 1.0 from triggering
# on every flap.
RP_HYSTERESIS_MIN = 3
running = True
def _sig(sig, _f):
global running
print(f"\n[REPLAY] signal {sig} — graceful shutdown")
sys.stdout.flush()
running = False
signal.signal(signal.SIGINT, _sig)
signal.signal(signal.SIGTERM, _sig)
# ============================== ZMQ ======================================
def make_pub(ctx, ep):
s = ctx.socket(zmq.PUB)
s.setsockopt(zmq.SNDHWM, 4096)
s.connect(ep)
return s
def make_sub(ctx, ep, topic=b""):
s = ctx.socket(zmq.SUB)
s.setsockopt(zmq.SUBSCRIBE, topic)
s.setsockopt(zmq.RCVHWM, 8192)
s.connect(ep)
return s
def send_cmd(pub, obj):
pub.send_string(json.dumps(obj))
def drain_telem(sub, max_ms=80):
out = []
poller = zmq.Poller(); poller.register(sub, zmq.POLLIN)
deadline = time.time() + max_ms / 1000.0
while time.time() < deadline:
socks = dict(poller.poll(timeout=10))
if sub in socks and socks[sub] == zmq.POLLIN:
try:
raw = sub.recv_string(zmq.NOBLOCK)
try: out.append(json.loads(raw))
except Exception: pass
except zmq.Again:
pass
else:
break
return out
# ============================== book =====================================
def synthesize_book(taker_buy_usd: float, taker_sell_usd: float):
bid = np.zeros(NX, dtype=np.float64)
ask = np.zeros(NX, dtype=np.float64)
half = NX // 2
offs = np.arange(1, half + 1)
decay = np.exp(-offs / BOOK_DECAY_L)
decay /= decay.sum()
bid_vals = max(taker_buy_usd, 0.0) * decay
ask_vals = max(taker_sell_usd, 0.0) * decay
bid[half-1::-1] = bid_vals
ask[half:half+len(decay)] = ask_vals
total = bid + ask
m = total.mean()
if m > 1e-12:
bid = bid / m
ask = ask / m
return bid.tolist(), ask.tolist()
# ============================== Fractonaut worker ========================
class FractWorker:
"""Background thread that drains an ask-queue and writes the chronicle.
Replay loop calls .enqueue(reason, minute, context_dict) — non-blocking;
queue is bounded so a stuck Ollama can never starve the replay.
"""
def __init__(self, chronicle_path: Path, log):
self.q = queue.Queue(maxsize=32)
self.chron = chronicle_path
self.log = log
self.thread = threading.Thread(target=self._run, daemon=True)
self.n_ok = 0
self.n_err = 0
self.n_drop = 0
self.stop = False
self.thread.start()
def enqueue(self, reason: str, minute: int, ctx: dict, question: str):
item = {
"ts": datetime.now(timezone.utc).isoformat(),
"reason": reason, "minute": minute,
"ctx": ctx, "question": question,
}
try:
self.q.put_nowait(item)
except queue.Full:
self.n_drop += 1
self.log(f"[FW] drop (queue full) reason={reason} min={minute}")
def _ask(self, question: str):
body = json.dumps({"question": question}).encode()
req = urllib.request.Request(
FRACT_URL, data=body, headers={"Content-Type": "application/json"})
t0 = time.time()
try:
with urllib.request.urlopen(req, timeout=FRACT_TO_S) as r:
data = json.loads(r.read())
return {"ok": True, "elapsed_s": round(time.time()-t0, 2),
"response": data.get("response", ""),
"turn": data.get("turn"), "model": data.get("model")}
except Exception as e:
return {"ok": False, "elapsed_s": round(time.time()-t0, 2),
"error": str(e)}
def _run(self):
while not self.stop or not self.q.empty():
try:
item = self.q.get(timeout=0.5)
except queue.Empty:
continue
res = self._ask(item["question"])
entry = {**item, "result": res}
try:
with open(self.chron, "a", encoding="utf-8") as f:
f.write(json.dumps(entry) + "\n")
except Exception as e:
self.log(f"[FW] chronicle write error: {e}")
if res["ok"]:
self.n_ok += 1
self.log(f"[FW] ok reason={item['reason']} min={item['minute']} "
f"{res['elapsed_s']}s reply={res['response'][:80]!r}")
else:
self.n_err += 1
self.log(f"[FW] err reason={item['reason']} min={item['minute']} "
f"err={res.get('error')}")
def shutdown(self, drain_timeout_s=600):
self.stop = True
t0 = time.time()
while (not self.q.empty()) and (time.time() - t0 < drain_timeout_s):
time.sleep(2)
self.thread.join(timeout=10)
# ============================== data load ================================
def load_three_months(root: Path, coin: str, log) -> pd.DataFrame:
"""Walk root/YYYYMMDD/H.parquet for YYYYMM in {202603,202604,202605}.
Concatenate, filter to coin, sort by minute, dedupe.
"""
frames = []
months = ("202603", "202604", "202605")
day_dirs = []
for m in months:
day_dirs.extend(sorted(d for d in root.iterdir() if d.name.startswith(m)))
log(f"[DATA] {len(day_dirs)} day-dirs across {months}")
for i, dd in enumerate(day_dirs):
for hp in sorted(dd.iterdir()):
if not hp.name.endswith(".parquet"):
continue
try:
df = pd.read_parquet(hp, columns=[
"minute", "coin", "mid_price", "signed_flow_usd",
"taker_buy_usd", "taker_sell_usd", "trade_count"
])
except Exception as e:
log(f"[DATA] skip {hp}: {e}")
continue
frames.append(df[df["coin"] == coin])
if (i + 1) % 10 == 0:
log(f"[DATA] loaded {i+1}/{len(day_dirs)} days")
if not frames:
raise RuntimeError("no data loaded")
df = pd.concat(frames, ignore_index=True)
df = df.drop_duplicates(subset=["minute"]).sort_values("minute").reset_index(drop=True)
df["ts_utc"] = pd.to_datetime(df["minute"] * 60, unit="s", utc=True)
log(f"[DATA] {len(df):,} BTC minute rows range=[{df['ts_utc'].iloc[0]} .. {df['ts_utc'].iloc[-1]}]")
return df
# ============================== correlation ==============================
def compute_correlations(events_parquet: Path, chronicle_path: Path,
out_md: Path, df_source: pd.DataFrame, log):
"""For each Fractonaut-queried minute, look up fwd log-returns at
h=60 and h=240 from the source minute->mid_price index and aggregate
by reason. Write a Markdown table."""
ev = pd.read_parquet(events_parquet)
ev = ev[ev["fractonaut_queried"]].copy()
src = df_source.set_index("minute")["mid_price"].astype(float)
def fwd(min_t, h):
try:
p0 = float(src.loc[min_t])
p1 = float(src.loc[min_t + h])
return float(np.log(p1 / p0))
except KeyError:
return np.nan
ev["fwd_60"] = ev["minute"].apply(lambda m: fwd(m, 60))
ev["fwd_240"] = ev["minute"].apply(lambda m: fwd(m, 240))
# For event-fire rows, drill into events_fired to extract the kind.
def first_kind(events_json):
try:
arr = json.loads(events_json) if isinstance(events_json, str) else (events_json or [])
if arr: return arr[0].get("kind", "?")
except Exception:
pass
return "?"
ev["event_kind"] = ev["events_fired"].apply(first_kind)
ev["bucket"] = np.where(
ev["query_reason"] == "event",
"event:" + ev["event_kind"],
ev["query_reason"]
)
grp = ev.groupby("bucket").agg(
n=("minute", "count"),
mean_fwd_60=("fwd_60", "mean"),
med_fwd_60=("fwd_60", "median"),
mean_fwd_240=("fwd_240","mean"),
med_fwd_240=("fwd_240", "median"),
).sort_values("n", ascending=False)
log("[CORR] bucket summary:")
log(grp.to_string())
with open(out_md, "w") as f:
f.write("# Replay correlation table\n\n")
f.write(f"generated {datetime.now(timezone.utc).isoformat()}\n\n")
f.write(f"queried minutes: n={len(ev)}\n\n")
f.write("| bucket | n | mean_fwd_60 | med_fwd_60 | mean_fwd_240 | med_fwd_240 |\n")
f.write("|---|---:|---:|---:|---:|---:|\n")
for b, row in grp.iterrows():
f.write(f"| {b} | {int(row['n'])} | {row['mean_fwd_60']:+.6f} | "
f"{row['med_fwd_60']:+.6f} | {row['mean_fwd_240']:+.6f} | "
f"{row['med_fwd_240']:+.6f} |\n")
log(f"[CORR] wrote {out_md}")
# ============================== main =====================================
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--minute-dt", type=float, default=MINUTE_DT,
help="wall seconds per replayed minute (lower = faster)")
ap.add_argument("--limit", type=int, default=0,
help="only replay first N minutes (0=all)")
ap.add_argument("--run-id", type=str, default=None,
help="override run id; default = UTC timestamp")
ap.add_argument("--dry-run", action="store_true",
help="load data + open sockets but do not send commands")
ap.add_argument("--skip-correlation", action="store_true")
ap.add_argument("--flow-drive-tc", action="store_true",
help="Test C: modulate set_flow_drive by trade_count z-score")
ap.add_argument("--virtual-recentre", type=float, default=0.0,
help="Virtual recentre: only send set_mid when |mid - last_sent_mid| > this USD threshold. 0 = baseline (every minute).")
ap.add_argument("--no-book", action="store_true",
help="Diagnostic: do NOT send set_book. Inject_trade only.")
ap.add_argument("--cs2", action="store_true",
help="Test C: per-minute set_temperature_profile, cold (cs2 down) where trade_count is high. "
"Uses tc_z (auto-enables tc_z computation).")
ap.add_argument("--cs2-alpha", type=float, default=0.30,
help="Strength of cold drive: cs2[col] = CS2_NOMINAL * (1 - alpha * clamp(tc_z,0,1)) inside the patch.")
ap.add_argument("--cs2-floor", type=float, default=0.16,
help="Hard host-side floor on cs2 to stay clear of kernel CS2_MIN=0.15.")
ap.add_argument("--cs2-half-width", type=int, default=50,
help="Cold patch half-width in cols around col 256 (mid).")
ap.add_argument("--window-start-min", type=int, default=0,
help="Slice df to start at this minute (epoch//60). 0 = no slice.")
ap.add_argument("--window-len", type=int, default=0,
help="After window-start-min slice, keep only this many minutes. 0 = keep all.")
args = ap.parse_args()
run_id = args.run_id or datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
run_dir = RUN_ROOT / f"replay_3month_{run_id}"
run_dir.mkdir(parents=True, exist_ok=True)
progress_log = run_dir / "progress.log"
def log(msg):
line = f"[{datetime.now(timezone.utc).strftime('%H:%M:%S')}] {msg}"
print(line, flush=True)
try:
with open(progress_log, "a") as f:
f.write(line + "\n")
except Exception:
pass
log(f"replay_injector starting; run_id={run_id}")
log(f"run_dir={run_dir}")
log(f"minute_dt={args.minute_dt} limit={args.limit} dry_run={args.dry_run}")
df = load_three_months(DATA_ROOT, COIN, log)
if args.window_start_min > 0:
before = len(df)
df = df[df["minute"] >= args.window_start_min].reset_index(drop=True)
log(f"[DATA] sliced from minute>={args.window_start_min}: {before:,} -> {len(df):,}")
if args.window_len > 0:
df = df.head(args.window_len).reset_index(drop=True)
log(f"[DATA] window-len truncated to first {len(df):,} minutes")
if args.limit > 0:
df = df.head(args.limit).copy()
log(f"[DATA] truncated to first {len(df):,} minutes")
# Precompute rolling trade_count z-score for --flow-drive-tc and/or --cs2
if args.flow_drive_tc or args.cs2:
tc = df["trade_count"].astype(float).fillna(0.0)
roll_mean = tc.rolling(window=240, min_periods=30).mean()
roll_std = tc.rolling(window=240, min_periods=30).std().replace(0.0, np.nan)
df["tc_z"] = ((tc - roll_mean) / roll_std).fillna(0.0).clip(-3.0, 5.0)
log(f"[CTRL] tc_z computed (flow_drive_tc={args.flow_drive_tc}, cs2={args.cs2}) "
f"mean={df['tc_z'].mean():.3f} std={df['tc_z'].std():.3f} "
f"min={df['tc_z'].min():.3f} max={df['tc_z'].max():.3f}")
if args.cs2:
log(f"[CTRL] cs2 ENABLED alpha={args.cs2_alpha:.2f} floor={args.cs2_floor:.3f} "
f"half_width={args.cs2_half_width} cols (patch cols {NX//2 - args.cs2_half_width}..{NX//2 + args.cs2_half_width})")
CS2_NOMINAL_PY = 1.0 / 3.0
log(f"[CTRL] cs2 max-cold value (tc_z=1) = {CS2_NOMINAL_PY * (1 - args.cs2_alpha):.4f} "
f"(stability bound u_max < sqrt(cs2) = {(CS2_NOMINAL_PY * (1 - args.cs2_alpha))**0.5:.4f})")
if args.virtual_recentre > 0:
log(f"[CTRL] virtual-recentre ENABLED threshold=${args.virtual_recentre:.0f}")
chronicle_path = run_dir / "fractonaut_replay_chronicle.jsonl"
events_parquet = run_dir / "replay_events.parquet"
correl_md = run_dir / "correlations.md"
ctx = zmq.Context.instance()
pub = make_pub(ctx, DAEMON_CMD)
sub = make_sub(ctx, DAEMON_TELEM)
time.sleep(0.5) # slow joiner
if not args.dry_run:
log("[CTRL] reset_equilibrium")
send_cmd(pub, {"cmd": "reset_equilibrium"})
time.sleep(0.5)
first = df.iloc[0]
log(f"[CTRL] set_mid={first['mid_price']:.2f} set_tick_size={TICK_USD}")
send_cmd(pub, {"cmd": "set_tick_size", "value": TICK_USD})
send_cmd(pub, {"cmd": "set_mid", "price": float(first["mid_price"])})
bid, ask = synthesize_book(float(first["taker_buy_usd"]),
float(first["taker_sell_usd"]))
if not args.no_book:
send_cmd(pub, {"cmd": "set_book", "bid": bid, "ask": ask})
time.sleep(0.3)
log(f"[CTRL] booting; relaxing {RELAX_BOOT_S}s")
time.sleep(RELAX_BOOT_S)
drain_telem(sub, 400)
fw = FractWorker(chronicle_path, log)
fw.enqueue("replay_start", int(df.iloc[0]["minute"]), {
"rows": len(df),
"start_ts": str(df.iloc[0]["ts_utc"]),
"end_ts": str(df.iloc[-1]["ts_utc"]),
},
"Three-month BTC replay is starting. Briefly describe the field's "
"current state before any replay injection begins. This is the "
"pre-replay baseline.")
# ─── replay loop ───
rows_out = []
last_event_query_min = -999999
last_any_query_min = -999999 # global gate (non-monthly)
last_rp_band = None # 'quiet'/'normal'/'elevated'/'spike'
band_streak = 0 # consecutive minutes in current band
pending_band_change = None # (new_band, prev_band, rp_now) waiting on hysteresis
last_month = None
armed = False
seen_event_keys = set()
pending_event = None # (mag, ev_dict, frame_dict) within the 30-min window
pending_event_min = None
minute_count = 0
t_start = time.time()
total_recentres = 0
def rp_band(rp):
if rp is None or not np.isfinite(rp): return None
if rp < 0.05: return "quiet"
if rp < 1.0: return "normal"
if rp < 8.0: return "elevated"
return "spike"
log("[LOOP] entering replay")
last_sent_mid = float(df.iloc[0]["mid_price"]) if len(df) else 0.0
n_virtual_skipped = 0
for idx, row in df.iterrows():
if not running:
log("[LOOP] interrupted")
break
mid_raw = row["mid_price"]
mid = float(mid_raw) if pd.notna(mid_raw) else np.nan
if not np.isfinite(mid) or mid <= 0:
rows_out.append({
"minute": int(row["minute"]), "mid_price": None,
"regime_product": None, "asymmetry": None, "coherence": None,
"last_trade_age_s": None, "events_fired": json.dumps([]),
"fractonaut_queried": False, "query_reason": "skip_no_mid",
})
minute_count += 1
continue
tbuy = float(row["taker_buy_usd"] or 0.0)
tsel = float(row["taker_sell_usd"] or 0.0)
bid, ask = synthesize_book(tbuy, tsel)
if not args.dry_run:
if not armed and minute_count >= WARMUP_MIN:
send_cmd(pub, {"cmd": "arm_consolidation"})
armed = True
log(f" [t={minute_count}] arm_consolidation")
# Virtual recentre: skip set_mid unless drift exceeds threshold
if args.virtual_recentre > 0:
if abs(mid - last_sent_mid) > args.virtual_recentre:
send_cmd(pub, {"cmd": "set_mid", "price": mid})
last_sent_mid = mid
else:
n_virtual_skipped += 1
else:
send_cmd(pub, {"cmd": "set_mid", "price": mid})
if not args.no_book:
send_cmd(pub, {"cmd": "set_book", "bid": bid, "ask": ask})
# Test C: modulate background flow drive by trade_count z-score
if args.flow_drive_tc:
z = float(row.get("tc_z", 0.0))
# map z in [-3, +5] to flow_drive in [0, 0.1]; baseline 0.02
fd = float(np.clip(0.02 + 0.015 * z, 0.0, 0.1))
send_cmd(pub, {"cmd": "set_flow_drive", "value": fd})
# Test C (geometric): per-minute lattice temperature.
# Cold patch around mid (col NX/2 +/- half_width) with strength
# set by tc_z clipped to [0,1]. Hard host-side floor keeps us
# above kernel CS2_MIN=0.15 so the daemon never clamps and we
# don't sit on the stability bound.
if args.cs2:
z01 = float(np.clip(row.get("tc_z", 0.0), 0.0, 1.0))
CS2_NOM = 1.0 / 3.0
cold = max(CS2_NOM * (1.0 - args.cs2_alpha * z01), args.cs2_floor)
profile = [CS2_NOM] * NX
centre = NX // 2
lo = max(0, centre - args.cs2_half_width)
hi = min(NX, centre + args.cs2_half_width + 1)
for col in range(lo, hi):
profile[col] = cold
send_cmd(pub, {"cmd": "set_temperature_profile", "values": profile})
if tbuy > 0:
send_cmd(pub, {"cmd": "inject_trade", "price": mid + TICK_USD,
"size": tbuy / mid / 100.0,
"side": "buy", "aggressor": True})
if tsel > 0:
send_cmd(pub, {"cmd": "inject_trade", "price": mid - TICK_USD,
"size": tsel / mid / 100.0,
"side": "sell", "aggressor": True})
time.sleep(args.minute_dt)
frames = drain_telem(sub, int(args.minute_dt * 1000 * 0.8) + 30)
# latest scalar state for the row + Fract context
latest = frames[-1] if frames else {}
rp = latest.get("regime_product")
asy = latest.get("asymmetry")
coh = latest.get("coherence")
age = latest.get("last_trade_age_s")
# Per-minute vel_max (peak across frames seen this minute) for cs² stability watch
vel_max_min = 0.0
for fr in frames:
v = fr.get("vel_max")
if v is not None and float(v) > vel_max_min:
vel_max_min = float(v)
# gather events fired this minute (deduped against history)
evs_fired = []
for fr in frames:
if fr.get("recenter_event"):
total_recentres += 1
for ev in (fr.get("events") or []):
key = (ev.get("kind"), ev.get("col"),
round(float(ev.get("price", 0.0)), 2))
if key in seen_event_keys:
continue
seen_event_keys.add(key)
evs_fired.append({**ev, "cycle": fr.get("cycle")})
# ─── trigger decisions ───
queried = False
reason = ""
# (a) structural events, rate-limited (30 replay-min gap)
if evs_fired and minute_count >= WARMUP_MIN:
top = max(evs_fired, key=lambda e: abs(float(e.get("mag", 0.0))))
mag = abs(float(top.get("mag", 0.0)))
if pending_event is None or mag > pending_event[0]:
pending_event = (mag, top, dict(latest))
pending_event_min = minute_count
if (pending_event is not None
and (minute_count - last_event_query_min) >= ASK_EVENT_GAP
and (minute_count - last_any_query_min) >= ASK_GLOBAL_GAP):
mag, ev, fctx = pending_event
q = (f"Structural event: {ev.get('kind')} at column {ev.get('col')} "
f"price ${float(ev.get('price', 0.0)):,.2f} magnitude {float(ev.get('mag', 0.0)):.3f}. "
f"regime_product={fctx.get('regime_product')}. "
f"last_trade_age_s={fctx.get('last_trade_age_s')}. "
f"Describe what the spatial structure shows at this moment. "
f"Is this event consistent with the surrounding density and "
f"divergence profile?")
fw.enqueue("event", int(row["minute"]), {
"kind": ev.get("kind"), "col": ev.get("col"),
"price": ev.get("price"), "mag": ev.get("mag"),
"regime_product": fctx.get("regime_product"),
"asymmetry": fctx.get("asymmetry"),
"coherence": fctx.get("coherence"),
"last_trade_age_s": fctx.get("last_trade_age_s"),
"mid_price": mid,
}, q)
queried = True
reason = "event"
last_event_query_min = minute_count
last_any_query_min = minute_count
pending_event = None
# (b) regime BAND change, hysteresis-gated. Only fire when the
# band has held for >= RP_HYSTERESIS_MIN consecutive minutes AND
# the global gate has elapsed. Stops oscillation from flooding.
cur_band = rp_band(rp)
if cur_band is not None:
if cur_band == last_rp_band:
band_streak += 1
else:
if last_rp_band is not None:
pending_band_change = (cur_band, last_rp_band, rp)
band_streak = 1
last_rp_band = cur_band
if (not queried and pending_band_change is not None
and band_streak >= RP_HYSTERESIS_MIN
and (minute_count - last_any_query_min) >= ASK_GLOBAL_GAP):
new_b, prev_b, rp_now = pending_band_change
# only ask about transitions across a meaningful threshold
rank = {"quiet":0, "normal":1, "elevated":2, "spike":3}
if abs(rank.get(new_b,0) - rank.get(prev_b,0)) >= 1:
q = (f"regime_product has settled into the {new_b} band "
f"(now {rp:.4f}, previously {prev_b}). What changed in the field?")
fw.enqueue("rp_band", int(row["minute"]), {
"new_band": new_b, "prev_band": prev_b,
"rp_now": rp, "mid_price": mid,
}, q)
queried = True
reason = f"rp_band_{prev_b}_to_{new_b}"
last_any_query_min = minute_count
pending_band_change = None
# (c) start of each month (UTC) — always queries, no gate
cur_month = pd.Timestamp(row["ts_utc"]).month
if not queried and last_month is not None and cur_month != last_month:
q = (f"A new month of replay just began (UTC month={cur_month}, "
f"mid=${mid:,.2f}). Briefly describe the field's current "
f"structural state as the new month opens.")
fw.enqueue("month_open", int(row["minute"]), {
"month": int(cur_month), "mid_price": mid,
"regime_product": rp,
}, q)
queried = True
reason = "month_open"
last_month = cur_month
rows_out.append({
"minute": int(row["minute"]), "mid_price": mid,
"regime_product": rp, "asymmetry": asy, "coherence": coh,
"last_trade_age_s": age,
"vel_max": vel_max_min,
"trade_count": float(row.get("trade_count", 0.0)),
"tc_z": float(row.get("tc_z", 0.0)) if ("tc_z" in row.index) else 0.0,
"events_fired": json.dumps(evs_fired),
"fractonaut_queried": queried,
"query_reason": reason,
})
if vel_max_min > 0.30:
log(f" [STAB] vel_max={vel_max_min:.3f} > 0.30 at min={int(row['minute'])} "
f"(tc_z={float(row.get('tc_z', 0.0)):.2f})")
minute_count += 1
if minute_count % 500 == 0:
elapsed = time.time() - t_start
rate = minute_count / max(elapsed, 1e-6)
remain = (len(df) - minute_count) / max(rate, 1e-6)
log(f" [{minute_count:7d}/{len(df):,}] "
f"rate={rate:6.1f} min/s eta={remain/3600:.2f}h "
f"rp={rp} fract: ok={fw.n_ok} q={fw.q.qsize()} drop={fw.n_drop}")
if minute_count % ROLL_FLUSH_N == 0:
try:
pd.DataFrame(rows_out).to_parquet(events_parquet, index=False)
log(f" flushed {events_parquet} ({len(rows_out)} rows)")
except Exception as e:
log(f" parquet flush error: {e}")
# final flush
if rows_out:
try:
pd.DataFrame(rows_out).to_parquet(events_parquet, index=False)
log(f"[END] wrote {events_parquet} ({len(rows_out)} rows)")
except Exception as e:
log(f"[END] parquet write error: {e}")
# synthesis query
fw.enqueue("replay_end", int(df.iloc[min(len(df)-1, minute_count-1)]["minute"]), {
"minutes_replayed": minute_count,
"wall_seconds": round(time.time() - t_start, 1),
"recentres": total_recentres,
"fractonaut_ok": fw.n_ok,
},
"Three months of BTC replay just finished. Summarise what you saw "
"across this run: typical regime_product bands, what triggered "
"the most distinctive events, and any structural pattern that "
"stood out across the chronicle.")
log(f"[END] minutes={minute_count} wall={time.time()-t_start:.1f}s "
f"recentres={total_recentres} fract_ok={fw.n_ok} err={fw.n_err} drop={fw.n_drop}"
+ (f" virtual_skipped={n_virtual_skipped}" if args.virtual_recentre > 0 else ""))
log("[END] draining Fractonaut queue (up to 10 min)...")
fw.shutdown(drain_timeout_s=600)
if not args.skip_correlation and rows_out:
try:
compute_correlations(events_parquet, chronicle_path, correl_md, df, log)
except Exception as e:
log(f"[CORR] error: {e}")
try:
pub.close(); sub.close(); ctx.term()
except Exception:
pass
log("done.")
if __name__ == "__main__":
main()