713 lines
30 KiB
Python
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()
|