443 lines
20 KiB
Python
443 lines
20 KiB
Python
"""trade_lbm_v1 — §10 validation gate: Event A (breakout) + Event B (consolidation).
|
|
|
|
Replays per-minute BTC aggregate data from HL April parquets into trade_lbm_v1
|
|
via set_book + inject_trade + set_mid, queries Fractonaut at boundary/event
|
|
points, and verifies the §10 pass criteria.
|
|
|
|
Owner: RESONANCE.
|
|
"""
|
|
import json
|
|
import os
|
|
import sys
|
|
import time
|
|
import urllib.request
|
|
from pathlib import Path
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
import zmq
|
|
|
|
# ──────────────────────── config ────────────────────────
|
|
DAEMON_TELEM = "tcp://127.0.0.1:5566"
|
|
DAEMON_CMD = "tcp://127.0.0.1:5567"
|
|
DAEMON_ACK = "tcp://127.0.0.1:5569"
|
|
|
|
FRACT_URL = "http://127.0.0.1:28822/ask"
|
|
FRACT_TO_S = 240
|
|
|
|
PARQUET = "/mnt/d/Resonance_Engine/_tools_btc_april.parquet"
|
|
RUN_ROOT = "/mnt/d/Resonance_Engine/traj"
|
|
|
|
NX = 512
|
|
TICK_USD = 1.0
|
|
SNAPSHOT_INTERVAL = 200 # ~5 snapshots/sec
|
|
MINUTE_DT = 0.5 # wall seconds per replayed minute
|
|
WARMUP_MIN = 30 # minutes ignored before consolidation can "count"
|
|
RELAX_BOOT_S = 4.0 # let field absorb book before replay starts
|
|
|
|
BOOK_DECAY_L = 30 # exponential book width in columns
|
|
OMEGA_REPLAY = 0.3 # lets injections leave a multi-cycle trace, book still dominates
|
|
|
|
# ──────────────────────── ZMQ helpers ────────────────────────
|
|
|
|
def make_sub(ctx, ep, topic=b""):
|
|
s = ctx.socket(zmq.SUB)
|
|
s.setsockopt(zmq.SUBSCRIBE, topic)
|
|
s.setsockopt(zmq.RCVHWM, 4096)
|
|
s.connect(ep)
|
|
return s
|
|
|
|
|
|
def make_pub(ctx, ep):
|
|
s = ctx.socket(zmq.PUB)
|
|
s.setsockopt(zmq.SNDHWM, 1024)
|
|
s.connect(ep)
|
|
return s
|
|
|
|
|
|
def send_cmd(pub, obj):
|
|
pub.send_string(json.dumps(obj))
|
|
|
|
|
|
def drain_telem(sub, max_ms=200):
|
|
"""Drain any pending telemetry frames; return list of decoded dicts."""
|
|
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=20))
|
|
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
|
|
|
|
|
|
# ──────────────────────── Fractonaut helper ────────────────────────
|
|
|
|
def fractonaut_ask(question):
|
|
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, "response": data.get("response", ""),
|
|
"turn": data.get("turn"), "model": data.get("model"),
|
|
"elapsed_s": round(time.time() - t0, 2)}
|
|
except Exception as e:
|
|
return {"ok": False, "error": str(e),
|
|
"elapsed_s": round(time.time() - t0, 2)}
|
|
|
|
|
|
# ──────────────────────── book synthesis ────────────────────────
|
|
|
|
def synthesize_book(taker_buy_usd: float, taker_sell_usd: float):
|
|
"""Build NX bid+ask arrays from per-minute taker totals.
|
|
Exponentially-decaying depth around mid; bids below, asks above."""
|
|
bid = np.zeros(NX, dtype=np.float64)
|
|
ask = np.zeros(NX, dtype=np.float64)
|
|
half = NX // 2
|
|
# offsets 1..half on each side
|
|
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
|
|
# bids at half-1..0 (closer to mid is index half-1)
|
|
bid[half-1::-1] = bid_vals
|
|
ask[half:half+len(decay)] = ask_vals
|
|
# normalise so mean total ~ 1.0 to keep rho_eq in clamp range
|
|
total = bid + ask
|
|
m = total.mean()
|
|
if m > 1e-12:
|
|
bid = bid / m
|
|
ask = ask / m
|
|
return bid.tolist(), ask.tolist()
|
|
|
|
|
|
# ──────────────────────── data loader ────────────────────────
|
|
|
|
def load_btc(start_min, end_min):
|
|
df = pd.read_parquet(PARQUET)
|
|
df = df[(df["minute"] >= start_min) & (df["minute"] <= end_min)]
|
|
return df.sort_values("minute").reset_index(drop=True)
|
|
|
|
|
|
# ──────────────────────── one event replay ────────────────────────
|
|
|
|
def run_event(run_id: str, label: str, start_min: int, end_min: int,
|
|
opening_q: str, closing_q: str,
|
|
tag_event_filter, log_dir: Path):
|
|
"""Replay minutes [start_min, end_min] and record events + Fractonaut answers."""
|
|
print(f"\n══════ {label} (minutes {start_min}…{end_min}, {end_min-start_min+1} mins) ══════")
|
|
df = load_btc(start_min, end_min)
|
|
if len(df) == 0:
|
|
print(f" no data for window"); return None
|
|
print(f" loaded {len(df)} BTC minutes mid: ${df['mid_price'].iloc[0]:,.2f} → ${df['mid_price'].iloc[-1]:,.2f}")
|
|
|
|
ctx = zmq.Context.instance()
|
|
sub = make_sub(ctx, DAEMON_TELEM)
|
|
ack = make_sub(ctx, DAEMON_ACK)
|
|
pub = make_pub(ctx, DAEMON_CMD)
|
|
time.sleep(2.0) # PUB-SUB slow-joiner
|
|
|
|
# ─── reset + boot ───
|
|
send_cmd(pub, {"cmd": "reset_equilibrium"}); time.sleep(0.2)
|
|
send_cmd(pub, {"cmd": "set_tick_size", "value": TICK_USD}); time.sleep(0.05)
|
|
send_cmd(pub, {"cmd": "set_snapshot_interval", "interval": SNAPSHOT_INTERVAL}); time.sleep(0.05)
|
|
send_cmd(pub, {"cmd": "set_omega_profile", "values": [OMEGA_REPLAY] * NX}); time.sleep(0.05)
|
|
|
|
# anchor mid + first book
|
|
first = df.iloc[0]
|
|
send_cmd(pub, {"cmd": "set_mid", "price": float(first["mid_price"])}); time.sleep(0.1)
|
|
bid, ask = synthesize_book(float(first["taker_buy_usd"]), float(first["taker_sell_usd"]))
|
|
send_cmd(pub, {"cmd": "set_book", "bid": bid, "ask": ask}); time.sleep(0.1)
|
|
print(f" booted; relaxing {RELAX_BOOT_S}s before replay…")
|
|
time.sleep(RELAX_BOOT_S)
|
|
drain_telem(sub, 200)
|
|
|
|
fract_log = log_dir / "fractonaut_validation.jsonl"
|
|
events_log = log_dir / f"events_{label}.jsonl"
|
|
frames_log = log_dir / f"frames_{label}.jsonl"
|
|
fract_log.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
def write_fract(entry):
|
|
with open(fract_log, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
def write_event(entry):
|
|
with open(events_log, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
def write_frame(entry):
|
|
with open(frames_log, "a") as f:
|
|
f.write(json.dumps(entry) + "\n")
|
|
|
|
# ─── opening question (give Fractonaut current field state) ───
|
|
drain_telem(sub, 100)
|
|
frames = drain_telem(sub, 1000)
|
|
snap = frames[-1] if frames else {}
|
|
cycle = snap.get("cycle", 0)
|
|
coh = snap.get("coherence", 0.0)
|
|
asy = snap.get("asymmetry", 0.0)
|
|
rprod = snap.get("regime_product", 0.0)
|
|
open_full = (f"{opening_q}\n\n"
|
|
f"[trade_lbm_v1 field snapshot] cycle={cycle} "
|
|
f"coherence={coh:.4f} asymmetry={asy:.4f} "
|
|
f"regime_product={rprod:.4f} "
|
|
f"book mid=${float(first['mid_price']):,.2f} "
|
|
f"taker_buy=${float(first['taker_buy_usd']):,.0f} "
|
|
f"taker_sell=${float(first['taker_sell_usd']):,.0f}")
|
|
print(f" Fractonaut OPENING …", flush=True)
|
|
fa = fractonaut_ask(open_full)
|
|
print(f" fractonaut: {fa.get('elapsed_s')}s ok={fa['ok']}")
|
|
write_fract({"event_label": label, "phase": "open", "ts": time.time(),
|
|
"question": open_full, "reply": fa})
|
|
|
|
# ─── replay loop ───
|
|
seen_event_keys = set() # dedupe events
|
|
detected_events = []
|
|
minute_count = 0
|
|
t_start = time.time()
|
|
first_post_warmup_event = None
|
|
last_recentre = 0
|
|
total_recentres = 0
|
|
sum_asymmetry = 0.0
|
|
sum_regime = 0.0
|
|
n_samples = 0
|
|
asked_for_kind = set() # at most one Fractonaut query per event-kind per window
|
|
armed = False # send arm_consolidation once warmup elapses
|
|
|
|
for idx, row in df.iterrows():
|
|
mid = float(row["mid_price"])
|
|
tbuy = float(row["taker_buy_usd"])
|
|
tsel = float(row["taker_sell_usd"])
|
|
sflow = float(row["signed_flow_usd"])
|
|
tcnt = int(row["trade_count"])
|
|
bid, ask = synthesize_book(tbuy, tsel)
|
|
|
|
# Arm the consolidation detector once warmup elapses. Per §10:
|
|
# detector must not fire in the first WARMUP_MIN minutes.
|
|
if not armed and minute_count >= WARMUP_MIN:
|
|
send_cmd(pub, {"cmd": "arm_consolidation"})
|
|
armed = True
|
|
print(f" [t={minute_count:4d} min] arm_consolidation sent")
|
|
|
|
# update mid (may trigger recentre), update book, inject net flow as one trade
|
|
send_cmd(pub, {"cmd": "set_mid", "price": mid})
|
|
send_cmd(pub, {"cmd": "set_book", "bid": bid, "ask": ask})
|
|
# inject: split into buy and sell halves at mid (we lose price granularity)
|
|
# scale: 1 unit of "size" maps to a rho perturbation of size/NY=size/512.
|
|
# Use USD/MID/100 → ~hundreds of size units per minute.
|
|
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(MINUTE_DT)
|
|
frames = drain_telem(sub, 50)
|
|
for fr in frames:
|
|
sum_asymmetry += abs(fr.get("asymmetry", 0.0))
|
|
sum_regime += abs(fr.get("regime_product", 0.0))
|
|
n_samples += 1
|
|
if fr.get("recenter_event"):
|
|
total_recentres += 1
|
|
for ev in (fr.get("events") or []):
|
|
key = (ev.get("kind"), ev.get("col"), round(ev.get("price", 0), 2))
|
|
if key in seen_event_keys:
|
|
continue
|
|
seen_event_keys.add(key)
|
|
entry = {"label": label, "minute_idx": minute_count,
|
|
"minute_unix": int(row["minute"]) * 60,
|
|
"cycle": fr.get("cycle"), "mid": mid,
|
|
"event": ev,
|
|
"field_asy": fr.get("asymmetry"),
|
|
"field_coh": fr.get("coherence"),
|
|
"regime_product": fr.get("regime_product")}
|
|
detected_events.append(entry)
|
|
write_event(entry)
|
|
if tag_event_filter(ev) and minute_count >= WARMUP_MIN and \
|
|
first_post_warmup_event is None:
|
|
first_post_warmup_event = entry
|
|
print(f" [t={minute_count:4d} min] event {ev.get('kind')} "
|
|
f"col={ev.get('col')} price=${ev.get('price', 0):,.2f}")
|
|
|
|
# Mid-replay Fractonaut query: one per event-kind per window.
|
|
ev_kind = ev.get("kind")
|
|
if ev_kind not in asked_for_kind and minute_count >= WARMUP_MIN:
|
|
asked_for_kind.add(ev_kind)
|
|
# Compose context from this telemetry frame
|
|
dens = fr.get("density_profile") or []
|
|
div = fr.get("divergence_profile") or []
|
|
vort = fr.get("vorticity_profile") or []
|
|
def stats(a):
|
|
if not a: return "n/a"
|
|
arr = np.array(a)
|
|
return f"min={arr.min():.4f} max={arr.max():.4f} mean={arr.mean():.4f} std={arr.std():.4f}"
|
|
mid_q = (
|
|
f"Event detector fired: {ev_kind} at col={ev.get('col')} "
|
|
f"price=${ev.get('price', 0):,.2f} magnitude={ev.get('mag', 0):.3f} "
|
|
f"persisted_rows={ev.get('rows', 0)}.\n"
|
|
f"[trade_lbm_v1 field snapshot at this moment] "
|
|
f"cycle={fr.get('cycle')} coherence={fr.get('coherence'):.4f} "
|
|
f"asymmetry={fr.get('asymmetry'):.4f} "
|
|
f"regime_product={fr.get('regime_product'):.4f} "
|
|
f"mid=${mid:,.2f}\n"
|
|
f"density_profile (every 4th col, {len(dens)} pts): {stats(dens)}\n"
|
|
f"divergence_profile (every 4th col, {len(div)} pts): {stats(div)}\n"
|
|
f"vorticity_profile (every 4th col, {len(vort)} pts): {stats(vort)}\n\n"
|
|
f"Does the field configuration support this reading? "
|
|
f"What do you observe in the density and vorticity profiles?"
|
|
)
|
|
print(f" Fractonaut MID-{ev_kind} …", flush=True)
|
|
fa_mid = fractonaut_ask(mid_q)
|
|
print(f" fractonaut: {fa_mid.get('elapsed_s')}s ok={fa_mid['ok']}")
|
|
write_fract({"event_label": label, "phase": f"event_{ev_kind}",
|
|
"ts": time.time(), "minute_idx": minute_count,
|
|
"event": ev, "question": mid_q, "reply": fa_mid})
|
|
|
|
# periodic frame log (every 10 min)
|
|
if minute_count % 10 == 0 and frames:
|
|
last = frames[-1]
|
|
write_frame({
|
|
"label": label, "minute_idx": minute_count,
|
|
"minute_unix": int(row["minute"]) * 60,
|
|
"mid": mid, "cycle": last.get("cycle"),
|
|
"coherence": last.get("coherence"),
|
|
"asymmetry": last.get("asymmetry"),
|
|
"regime_product": last.get("regime_product"),
|
|
"vel_mean": last.get("vel_mean"),
|
|
"vorticity_mean": last.get("vorticity_mean"),
|
|
"recenter": last.get("recenter_event"),
|
|
"n_events_pending": len(last.get("events") or []),
|
|
})
|
|
|
|
minute_count += 1
|
|
|
|
# ─── closing question ───
|
|
drain_telem(sub, 200)
|
|
frames = drain_telem(sub, 1000)
|
|
snap = frames[-1] if frames else {}
|
|
last_mid = float(df["mid_price"].iloc[-1])
|
|
end_summary = (f"{closing_q}\n\n"
|
|
f"[replay summary] minutes={minute_count} "
|
|
f"end_mid=${last_mid:,.2f} "
|
|
f"recentres={total_recentres} events_detected={len(detected_events)} "
|
|
f"mean_|asymmetry|={sum_asymmetry/max(n_samples,1):.4f} "
|
|
f"mean_|regime_product|={sum_regime/max(n_samples,1):.4f}")
|
|
print(f" Fractonaut CLOSING …", flush=True)
|
|
fa = fractonaut_ask(end_summary)
|
|
print(f" fractonaut: {fa.get('elapsed_s')}s ok={fa['ok']}")
|
|
write_fract({"event_label": label, "phase": "close", "ts": time.time(),
|
|
"question": end_summary, "reply": fa})
|
|
|
|
elapsed = time.time() - t_start
|
|
print(f" replay done in {elapsed:.1f}s events={len(detected_events)} "
|
|
f"recentres={total_recentres}")
|
|
|
|
return {
|
|
"label": label,
|
|
"minutes": minute_count,
|
|
"n_events": len(detected_events),
|
|
"first_post_warmup_event": first_post_warmup_event,
|
|
"events": detected_events,
|
|
"recentres": total_recentres,
|
|
"elapsed_s": round(elapsed, 1),
|
|
"mean_asy": sum_asymmetry / max(n_samples, 1),
|
|
"mean_regime_product": sum_regime / max(n_samples, 1),
|
|
}
|
|
|
|
|
|
# ──────────────────────── entry ────────────────────────
|
|
|
|
def main():
|
|
run_id = time.strftime("%Y%m%d_%H%M%S")
|
|
log_dir = Path(f"{RUN_ROOT}/validation_{run_id}")
|
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
print(f"RUN_ID={run_id} log_dir={log_dir}")
|
|
|
|
# Event B: 2026-04-03 18:29 UTC → 2026-04-04 06:29 UTC (12h, 0.305% excursion)
|
|
b = run_event(
|
|
run_id, "EVENT_B_consolidation",
|
|
start_min=29587349, end_min=29587349 + 720,
|
|
opening_q=("Replay starting. Describe the field state as the book loads "
|
|
"for the first time. This is a 12-hour consolidation window "
|
|
"(BTC moves <0.5% total). What do you expect to see in the "
|
|
"density and vorticity profiles?"),
|
|
closing_q=("Replay complete for this consolidation window. What "
|
|
"structural features persisted across the full 12 hours? "
|
|
"What appeared and dissolved? Did the field hold or break?"),
|
|
tag_event_filter=lambda ev: ev.get("kind") == "consolidation",
|
|
log_dir=log_dir)
|
|
|
|
# Event A: 2026-04-07 22:29 → 23:29 UTC (+3.19% in 60 min); 3h window centred = 21:00→24:00
|
|
a_centre_start = 29593260 # 2026-04-07 21:00 UTC
|
|
a = run_event(
|
|
run_id, "EVENT_A_breakout",
|
|
start_min=a_centre_start, end_min=a_centre_start + 180,
|
|
opening_q=("Replay starting. Describe the field state as the book loads "
|
|
"for the first time. This is a 3-hour breakout window — BTC "
|
|
"moves +3.19% in ~60 min starting roughly 89 minutes into the "
|
|
"window. What pre-breakout structure do you expect to detect?"),
|
|
closing_q=("Replay complete for this breakout window. What structural "
|
|
"features persisted across the 3 hours? What appeared and "
|
|
"dissolved? Did the field signal the breakout in advance, "
|
|
"during, or after?"),
|
|
tag_event_filter=lambda ev: ev.get("kind") == "breakout",
|
|
log_dir=log_dir)
|
|
|
|
# ─── verdicts ───
|
|
print("\n" + "=" * 70)
|
|
print("PASS / FAIL")
|
|
print("=" * 70)
|
|
|
|
# Event B: consolidation event must fire at least once AND not in first 30 min
|
|
b_pass = False
|
|
if b is not None:
|
|
consol_events = [e for e in b["events"] if e["event"]["kind"] == "consolidation"]
|
|
early = [e for e in consol_events if e["minute_idx"] < WARMUP_MIN]
|
|
late = [e for e in consol_events if e["minute_idx"] >= WARMUP_MIN]
|
|
b_pass = (len(late) >= 1) and (len(early) == 0)
|
|
print(f" EVENT B: consolidation events: early(<30min)={len(early)} "
|
|
f"late(>=30min)={len(late)} → {'PASS' if b_pass else 'FAIL'}")
|
|
|
|
# Event A: breakout event fires within 30 min of the move; col matches direction.
|
|
# Move starts at minute 89, ends at minute 149. "Within 30 min of the actual
|
|
# move" = between minute 89 and 119. Direction = UP, so col > NX/2 expected.
|
|
a_pass = False
|
|
if a is not None:
|
|
bo = [e for e in a["events"] if e["event"]["kind"] == "breakout"]
|
|
in_window = [e for e in bo if 89 <= e["minute_idx"] <= 119]
|
|
right_side = [e for e in in_window if e["event"].get("col", 0) > NX // 2]
|
|
a_pass = len(right_side) >= 1
|
|
print(f" EVENT A: breakout events: total={len(bo)} in [89,119]={len(in_window)} "
|
|
f"on UP side={len(right_side)} → {'PASS' if a_pass else 'FAIL'}")
|
|
|
|
print(f"\nOVERALL: {'PASS' if (b_pass and a_pass) else 'FAIL'}\n")
|
|
|
|
summary = {
|
|
"run_id": run_id, "ts": time.time(),
|
|
"log_dir": str(log_dir),
|
|
"event_B": {**(b or {}), "pass": b_pass, "events": None,
|
|
"first_post_warmup_event": (b or {}).get("first_post_warmup_event")},
|
|
"event_A": {**(a or {}), "pass": a_pass, "events": None,
|
|
"first_post_warmup_event": (a or {}).get("first_post_warmup_event")},
|
|
}
|
|
with open(log_dir / "summary.json", "w") as f:
|
|
json.dump(summary, f, indent=2, default=str)
|
|
print(f"summary written: {log_dir / 'summary.json'}")
|
|
print(f"fractonaut log: {log_dir / 'fractonaut_validation.jsonl'}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|