318 lines
12 KiB
Python
318 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
trade_lbm_v1 — Section G verification harness.
|
|
Runs the 6 checklist tests in order against the running daemon.
|
|
Exits non-zero if any test fails. Prints structured PASS/FAIL summary.
|
|
|
|
Assumes daemon already started on ports 5566-5570 (telemetry, cmd, snap, ack, stress).
|
|
"""
|
|
import json
|
|
import sys
|
|
import time
|
|
import zmq
|
|
import statistics
|
|
|
|
CTX = zmq.Context()
|
|
|
|
TELEMETRY_EP = "tcp://127.0.0.1:5566"
|
|
COMMAND_EP = "tcp://127.0.0.1:5567"
|
|
ACK_EP = "tcp://127.0.0.1:5569"
|
|
NX = 512
|
|
|
|
# ---- transport helpers ----
|
|
def make_sub(ep, hwm=1000):
|
|
s = CTX.socket(zmq.SUB)
|
|
s.setsockopt(zmq.RCVHWM, hwm)
|
|
s.setsockopt(zmq.LINGER, 0)
|
|
s.setsockopt(zmq.SUBSCRIBE, b"")
|
|
s.connect(ep)
|
|
return s
|
|
|
|
def make_pub(ep):
|
|
s = CTX.socket(zmq.PUB)
|
|
s.setsockopt(zmq.LINGER, 100)
|
|
s.setsockopt(zmq.SNDHWM, 1000)
|
|
s.connect(ep)
|
|
return s
|
|
|
|
def recv_telem(sub, timeout_ms=2000):
|
|
p = zmq.Poller()
|
|
p.register(sub, zmq.POLLIN)
|
|
socks = dict(p.poll(timeout_ms))
|
|
if sub in socks:
|
|
return json.loads(sub.recv())
|
|
return None
|
|
|
|
def recv_n_telem(sub, n, timeout_ms=15000):
|
|
"""Receive next n distinct telemetry frames (cycle increments). Returns list."""
|
|
out = []
|
|
seen_cycles = set()
|
|
deadline = time.time() + timeout_ms / 1000
|
|
while len(out) < n and time.time() < deadline:
|
|
t = recv_telem(sub, timeout_ms=int((deadline - time.time()) * 1000) + 50)
|
|
if t is None:
|
|
continue
|
|
c = t.get("cycle", -1)
|
|
if c not in seen_cycles:
|
|
seen_cycles.add(c)
|
|
out.append(t)
|
|
return out
|
|
|
|
def send_cmd(pub, obj):
|
|
pub.send_string(json.dumps(obj))
|
|
|
|
# ---- test cases ----
|
|
PASS = "PASS"
|
|
FAIL = "FAIL"
|
|
|
|
def test1_zero_state(pub, sub):
|
|
"""Test 1: no book, no forcing → regime_product ~ 0 (small, bounded)."""
|
|
# Daemon starts with h_f at rho=1.0 equilibrium and zero d_rho_eq,
|
|
# so the collision pulls everything toward rho_target = clamped(0+0, [0.1,10]) = 0.1.
|
|
# asymmetry against h_rho_eq=0 → eventually settles near 1.0 (h_rho ≈ 0.1).
|
|
# regime_product = coherence * asymmetry ≈ 1.0 * 1.0 ≈ 1.0 (well below elevated 164).
|
|
send_cmd(pub, {"cmd": "set_oi_drive", "value": 0.0})
|
|
send_cmd(pub, {"cmd": "set_flow_drive", "value": 0.0})
|
|
send_cmd(pub, {"cmd": "reset_equilibrium"})
|
|
time.sleep(0.5)
|
|
# Drain old telemetry, then sample.
|
|
while recv_telem(sub, 50) is not None:
|
|
pass
|
|
frames = recv_n_telem(sub, 30, 15000)
|
|
if not frames:
|
|
return FAIL, "no telemetry received"
|
|
products = [f.get("regime_product", -1) for f in frames]
|
|
last_prod = products[-1]
|
|
max_prod = max(products)
|
|
detail = f"frames={len(frames)} last_prod={last_prod:.4f} max_prod={max_prod:.4f}"
|
|
# Should be small (< 5) — not the elevated attractor (~164).
|
|
if max_prod < 5.0:
|
|
return PASS, detail
|
|
return FAIL, detail + " (expected < 5.0 for zero-state — equilibrium reads may not be wired)"
|
|
|
|
def test2_uniform_book(pub, sub):
|
|
"""Test 2: bid=ask=1.0 uniform → density_profile flat across all columns."""
|
|
bid = [1.0] * NX
|
|
ask = [1.0] * NX
|
|
send_cmd(pub, {"cmd": "set_book", "bid": bid, "ask": ask})
|
|
send_cmd(pub, {"cmd": "reset_equilibrium"}) # start clean
|
|
time.sleep(0.5)
|
|
while recv_telem(sub, 50) is not None:
|
|
pass
|
|
# Let it relax for a while (omega=1.0 default → ~10 cycles to reach equilibrium).
|
|
frames = recv_n_telem(sub, 30, 15000)
|
|
if not frames:
|
|
return FAIL, "no telemetry"
|
|
last = frames[-1]
|
|
prof = last.get("density_profile", [])
|
|
if len(prof) < 100:
|
|
return FAIL, f"density_profile too short: {len(prof)}"
|
|
mean = sum(prof) / len(prof)
|
|
spread = max(prof) - min(prof)
|
|
rel_spread = spread / mean if mean > 0 else 1e9
|
|
detail = (f"cycle={last['cycle']} prof_mean={mean:.4f} "
|
|
f"spread={spread:.4f} rel_spread={rel_spread:.4f} "
|
|
f"prod={last.get('regime_product', -1):.4f}")
|
|
# Flat = relative spread < 5% AND mean near 1.0 (RHO_EQ_NOMINAL).
|
|
if rel_spread < 0.05 and 0.7 < mean < 1.3:
|
|
return PASS, detail
|
|
return FAIL, detail + " (expected flat profile, mean ~ 1.0)"
|
|
|
|
def test3_heavy_column(pub, sub):
|
|
"""Test 3: heavy single column → persistent peak at correct column."""
|
|
# Build a book with a 10x deeper column at col=256.
|
|
target_col = 256
|
|
bid = [1.0] * NX
|
|
ask = [1.0] * NX
|
|
bid[target_col] = 10.0
|
|
ask[target_col] = 10.0
|
|
send_cmd(pub, {"cmd": "set_book", "bid": bid, "ask": ask})
|
|
time.sleep(0.5)
|
|
while recv_telem(sub, 50) is not None:
|
|
pass
|
|
frames = recv_n_telem(sub, 40, 20000)
|
|
if not frames:
|
|
return FAIL, "no telemetry"
|
|
last = frames[-1]
|
|
prof = last.get("density_profile", [])
|
|
# density_profile is subsampled every 4 columns → target col 256 maps to index 64.
|
|
if len(prof) < 100:
|
|
return FAIL, f"profile too short: {len(prof)}"
|
|
target_idx = target_col // 4
|
|
peak_val = prof[target_idx]
|
|
others = [v for i, v in enumerate(prof) if abs(i - target_idx) > 4]
|
|
other_mean = sum(others) / len(others) if others else 0
|
|
detail = (f"cycle={last['cycle']} target_idx={target_idx} peak={peak_val:.4f} "
|
|
f"others_mean={other_mean:.4f}")
|
|
if peak_val > other_mean * 1.3 and peak_val > 1.5:
|
|
return PASS, detail
|
|
return FAIL, detail + " (expected peak > 1.5 and >1.3x mean of others)"
|
|
|
|
def test4_off_grid_inject(pub, sub):
|
|
"""Test 4: inject_trade with price out of range → off_grid_mass increments only."""
|
|
# Drain
|
|
while recv_telem(sub, 50) is not None:
|
|
pass
|
|
base_frame = recv_telem(sub, 3000)
|
|
if not base_frame:
|
|
return FAIL, "no baseline telemetry"
|
|
base_off = base_frame.get("off_grid_mass", 0)
|
|
base_inj = base_frame.get("total_injections", 0)
|
|
# Set a known mid and tick. With mid=100, tick=0.5, grid covers [100 - 256*0.5, 100 + 256*0.5] = [-28, 228].
|
|
send_cmd(pub, {"cmd": "set_tick_size", "value": 0.5})
|
|
send_cmd(pub, {"cmd": "set_mid", "price": 100.0})
|
|
time.sleep(0.3)
|
|
# Send a trade way out of range:
|
|
send_cmd(pub, {"cmd": "inject_trade", "price": 99999.0, "side": "buy", "size": 5.0, "aggressor": True})
|
|
time.sleep(0.3)
|
|
frames = recv_n_telem(sub, 5, 5000)
|
|
if not frames:
|
|
return FAIL, "no telemetry after off-grid inject"
|
|
last = frames[-1]
|
|
new_off = last.get("off_grid_mass", 0)
|
|
new_inj = last.get("total_injections", 0)
|
|
detail = (f"off_grid: {base_off:.4f} -> {new_off:.4f} | "
|
|
f"injections: {base_inj} -> {new_inj}")
|
|
# off_grid_mass must increase, total_injections must NOT.
|
|
if new_off > base_off + 4.0 and new_inj == base_inj:
|
|
return PASS, detail
|
|
return FAIL, detail + " (expected off_grid +~5, injections unchanged)"
|
|
|
|
def test5_recentre(pub, sub):
|
|
"""Test 5: set_mid beyond threshold → recenter_event fires."""
|
|
# Threshold = NX/16 = 32 cols at tick=0.5 → price drift > 16.
|
|
# Reset mid anchor first by setting mid back to 100 then drifting +20.
|
|
send_cmd(pub, {"cmd": "set_tick_size", "value": 0.5})
|
|
send_cmd(pub, {"cmd": "set_mid", "price": 100.0})
|
|
time.sleep(0.3)
|
|
while recv_telem(sub, 50) is not None:
|
|
pass
|
|
# Now drift mid by +20 → 40 cols (above 32 threshold).
|
|
send_cmd(pub, {"cmd": "set_mid", "price": 120.0})
|
|
time.sleep(0.3)
|
|
frames = recv_n_telem(sub, 10, 8000)
|
|
if not frames:
|
|
return FAIL, "no telemetry after recentre attempt"
|
|
saw_event = any(f.get("recenter_event", 0) == 1 for f in frames)
|
|
last_mid = frames[-1].get("mid_price", -999)
|
|
detail = f"saw_recenter_event={saw_event} last_mid={last_mid:.2f}"
|
|
if saw_event and abs(last_mid - 120.0) < 0.01:
|
|
return PASS, detail
|
|
return FAIL, detail + " (expected recenter_event=1 and mid=120.0)"
|
|
|
|
def test6_synthetic_trade(pub, sub):
|
|
"""Test 6: synthetic trade at known price → density peak at correct column.
|
|
|
|
Important: with the trade kernel's book-as-attractor design,
|
|
new_rho = (1-omega)*rho + omega*rho_target after each collision step.
|
|
With omega=1.0 (default), one cycle wipes the injection. We must lower
|
|
omega first so the injected perturbation persists long enough to observe
|
|
in the next telemetry snapshot (10 cycles later).
|
|
"""
|
|
# Establish a clean uniform book first.
|
|
bid = [1.0] * NX
|
|
ask = [1.0] * NX
|
|
send_cmd(pub, {"cmd": "set_book", "bid": bid, "ask": ask})
|
|
send_cmd(pub, {"cmd": "set_tick_size", "value": 0.5})
|
|
send_cmd(pub, {"cmd": "set_mid", "price": 100.0})
|
|
# Slow relaxation so the trade impact persists across at least one snapshot.
|
|
send_cmd(pub, {"cmd": "set_omega_profile", "values": [0.05] * NX})
|
|
send_cmd(pub, {"cmd": "reset_equilibrium"})
|
|
time.sleep(0.5)
|
|
while recv_telem(sub, 50) is not None:
|
|
pass
|
|
# Let it relax to the (now slow) uniform target.
|
|
_ = recv_n_telem(sub, 30, 15000)
|
|
# Inject a trade at price=110 (col offset = (110-100)/0.5 = +20 → col 276).
|
|
target_price = 110.0
|
|
expected_col = int((target_price - 100.0) / 0.5 + 0.5) + NX // 2
|
|
# Drain stale telemetry just before inject so we catch the freshest frame.
|
|
while recv_telem(sub, 50) is not None:
|
|
pass
|
|
send_cmd(pub, {"cmd": "inject_trade", "price": target_price, "side": "buy",
|
|
"size": 10.0, "aggressor": True})
|
|
# Capture the first 2 frames after inject (cycles +~10, +~20).
|
|
frames = recv_n_telem(sub, 2, 4000)
|
|
if not frames:
|
|
return FAIL, "no telemetry after inject"
|
|
# Pick the frame with the strongest peak in target neighbourhood.
|
|
target_idx = expected_col // 4
|
|
best_frame = None
|
|
best_peak = -1.0
|
|
for f in frames:
|
|
prof = f.get("density_profile", [])
|
|
if not prof or target_idx >= len(prof):
|
|
continue
|
|
win = prof[max(0, target_idx - 3): target_idx + 4]
|
|
p = max(win)
|
|
if p > best_peak:
|
|
best_peak = p
|
|
best_frame = f
|
|
if best_frame is None:
|
|
return FAIL, "no usable density profile"
|
|
prof = best_frame["density_profile"]
|
|
others = [v for i, v in enumerate(prof) if abs(i - target_idx) > 8]
|
|
others_mean = sum(others) / len(others) if others else 0
|
|
peak_idx_global = max(range(max(0, target_idx - 3), target_idx + 4),
|
|
key=lambda i: prof[i])
|
|
detail = (f"target_col={expected_col} target_idx={target_idx} "
|
|
f"cycle={best_frame['cycle']} peak={best_peak:.6f} "
|
|
f"peak_idx={peak_idx_global} others_mean={others_mean:.6f} "
|
|
f"injections={best_frame.get('total_injections', 0)}")
|
|
# Trade size 10 at row 0, averaged across NY=512 rows → +0.02 baseline,
|
|
# but BGK still pulls toward 1.0, so observed bump is fraction of that.
|
|
if (best_peak > others_mean + 1e-4 and
|
|
abs(peak_idx_global - target_idx) <= 2 and
|
|
best_frame.get("total_injections", 0) >= 1):
|
|
return PASS, detail
|
|
return FAIL, detail + " (expected peak > others_mean near target_idx, injections>=1)"
|
|
|
|
def main():
|
|
print("=" * 70)
|
|
print("trade_lbm_v1 — Section G verification")
|
|
print("=" * 70)
|
|
sub = make_sub(TELEMETRY_EP)
|
|
pub = make_pub(COMMAND_EP)
|
|
# ZMQ slow-joiner: PUB needs time to discover SUB.
|
|
print("Waiting 2.0s for ZMQ PUB-SUB handshake...")
|
|
time.sleep(2.0)
|
|
|
|
# Initial sanity: any telemetry at all?
|
|
sanity = recv_telem(sub, 5000)
|
|
if sanity is None:
|
|
print("FATAL: no telemetry received from 5566 — daemon not running?")
|
|
return 1
|
|
print(f"[sanity] cycle={sanity.get('cycle')} mid={sanity.get('mid_price', 0)}")
|
|
print()
|
|
|
|
tests = [
|
|
("Test 1: zero state", test1_zero_state),
|
|
("Test 2: uniform book", test2_uniform_book),
|
|
("Test 3: heavy column", test3_heavy_column),
|
|
("Test 4: off-grid inject", test4_off_grid_inject),
|
|
("Test 5: recentre on mid", test5_recentre),
|
|
("Test 6: synthetic trade", test6_synthetic_trade),
|
|
]
|
|
results = []
|
|
for name, fn in tests:
|
|
print(f"--- {name} ---")
|
|
try:
|
|
status, detail = fn(pub, sub)
|
|
except Exception as e:
|
|
status = FAIL
|
|
detail = f"exception: {e}"
|
|
results.append((name, status, detail))
|
|
print(f" {status}: {detail}\n")
|
|
|
|
print("=" * 70)
|
|
print("SUMMARY")
|
|
print("=" * 70)
|
|
n_pass = sum(1 for _, s, _ in results if s == PASS)
|
|
for name, status, detail in results:
|
|
print(f" [{status}] {name}")
|
|
print(f"\n{n_pass}/{len(results)} passed")
|
|
return 0 if n_pass == len(results) else 1
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|