2026-06-07 12:34:31 +07:00
|
|
|
|
"""market_tension_injector.py — feed REAL market tensions into the lattice.
|
|
|
|
|
|
|
|
|
|
|
|
Replaces the synthetic z-score/STR_CAP scheme of inject_continuous_stream.py.
|
|
|
|
|
|
|
|
|
|
|
|
The previous injectors took market variables, z-scored them away to a
|
|
|
|
|
|
dimensionless ±0.30 blob at (512,512), and asked the lattice to find
|
|
|
|
|
|
structure. That destroyed all native magnitude and unit information. A
|
|
|
|
|
|
5σ flow and a 3σ flow produced identical injections. Predictably, six
|
|
|
|
|
|
"different variables" collapsed to one transfer function.
|
|
|
|
|
|
|
|
|
|
|
|
This injector encodes four NATIVE market tensions, in physical units,
|
|
|
|
|
|
as DISTINCT spatial forcing patterns the lattice can mechanically
|
|
|
|
|
|
respond to differently:
|
|
|
|
|
|
|
|
|
|
|
|
┌──────────────────────┬────────────────────────┬─────────────────────────┐
|
|
|
|
|
|
│ tension │ physical interpretation│ lattice forcing pattern │
|
|
|
|
|
|
├──────────────────────┼────────────────────────┼─────────────────────────┤
|
|
|
|
|
|
│ funding_bps │ directional cost-to- │ x-axis DIPOLE │
|
|
|
|
|
|
│ (signed bps annual)│ carry, longs pulled vs │ (asymmetry forcing) │
|
|
|
|
|
|
│ │ shorts │ │
|
|
|
|
|
|
│ │ │ │
|
|
|
|
|
|
│ bs_ratio_signed │ flow directional │ x-axis OFFSET blob │
|
|
|
|
|
|
│ (-0.5..+0.5) │ imbalance THIS minute │ (velocity bias) │
|
|
|
|
|
|
│ │ │ │
|
|
|
|
|
|
│ activity_excess │ excitation relative to │ ISOTROPIC center blob │
|
|
|
|
|
|
│ (>= -1, unbounded) │ own 500-min baseline │ (density / energy) │
|
|
|
|
|
|
│ │ │ │
|
|
|
|
|
|
│ cvd_divergence │ coiled spring — flow │ VORTICITY RING │
|
|
|
|
|
|
│ ($ / bps signed) │ without price response │ (rotational forcing) │
|
|
|
|
|
|
└──────────────────────┴────────────────────────┴─────────────────────────┘
|
|
|
|
|
|
|
|
|
|
|
|
SCALING DISCIPLINE
|
|
|
|
|
|
No z-scoring. No rolling normalisation. Each tension is scaled to lattice
|
|
|
|
|
|
units by a FIXED PHYSICAL CONSTANT documented in MAP_SCALE below. Cross-
|
|
|
|
|
|
regime amplitude information is preserved: a 100bps funding day produces
|
|
|
|
|
|
exactly 2× the dipole strength of a 50bps day. A panic flow imbalance
|
|
|
|
|
|
produces 5× the velocity bias of a calm one. The lattice sees true
|
|
|
|
|
|
market amplitude.
|
|
|
|
|
|
|
|
|
|
|
|
ARMS
|
|
|
|
|
|
A — control. No injection. Fresh baseline of the lattice's natural
|
|
|
|
|
|
variability over the experiment window. (Re-captured even though
|
|
|
|
|
|
we have an old arm A — field state may have drifted.)
|
|
|
|
|
|
T — tension. All four patterns injected per minute, each from its native
|
|
|
|
|
|
market value via fixed physical scale.
|
|
|
|
|
|
|
|
|
|
|
|
PROTOCOL
|
|
|
|
|
|
- Run arm A first (n minutes), save parquet
|
|
|
|
|
|
- Cooldown 20 min (let field settle to a new baseline)
|
|
|
|
|
|
- Run arm T (same n minutes), save parquet
|
|
|
|
|
|
- Analyzer compares: does field state under arm T predict realized
|
|
|
|
|
|
vol / direction at h=5,15,60 BETTER than (a) arm A field, and (b) the
|
|
|
|
|
|
raw tensions themselves?
|
|
|
|
|
|
|
|
|
|
|
|
REQUIREMENTS
|
|
|
|
|
|
- Lattice daemon alive on 5556 (telemetry PUB) / 5557 (cmd SUB).
|
|
|
|
|
|
- Must be launched via WSL setsid nohup (proven pattern from contstream).
|
|
|
|
|
|
- Tag: --agent-owner=PAPERTRADER per AGENTS.md ledger (this is research,
|
|
|
|
|
|
PaperTrader scope; lattice daemon itself is RESONANCE, untouched).
|
|
|
|
|
|
|
|
|
|
|
|
Output: /mnt/d/Resonance_Engine/traj/tension_<RUN_ID>/
|
|
|
|
|
|
meta.json
|
|
|
|
|
|
funding_history.parquet (raw HL fundingHistory cached)
|
|
|
|
|
|
arm_A_no_inject.parquet
|
|
|
|
|
|
arm_T_tension.parquet
|
|
|
|
|
|
progress.log
|
|
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
import argparse, glob, json, math, threading, time, urllib.request
|
|
|
|
|
|
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 to a single day for the first run. Override with --days-glob.
|
|
|
|
|
|
DEFAULT_DAYS_GLOB = "20260415*"
|
|
|
|
|
|
|
|
|
|
|
|
# fixed physical scales — never refit, never z-score
|
|
|
|
|
|
# (the whole point of this injector)
|
|
|
|
|
|
MAP_SCALE = {
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# ---- LEVELS (4-hour rolling means) ----
|
|
|
|
|
|
# Calibrated 2026-06-08 on full April BTC distributions:
|
|
|
|
|
|
# per_strength = |val| q90 -> strength 1.0 (so q99 -> ~1.7-2.0, cap takes it).
|
|
|
|
|
|
# Mean-reverting tensions (bs_ratio, activity, cvd) compress heavily under
|
|
|
|
|
|
# the 4hr mean (10-20× vs per-minute std). Persistent ones (funding) compress
|
|
|
|
|
|
# only ~1.5×. Scales below reflect the post-aggregation distributions.
|
|
|
|
|
|
"funding_level_per_strength": 1180.0,
|
|
|
|
|
|
"funding_level_strength_cap": 2.1,
|
|
|
|
|
|
|
|
|
|
|
|
"bs_ratio_level_per_strength": 0.060,
|
|
|
|
|
|
"bs_ratio_level_strength_cap": 1.8,
|
|
|
|
|
|
|
|
|
|
|
|
"activity_level_per_strength": 0.58,
|
|
|
|
|
|
"activity_level_strength_cap": 2.2,
|
|
|
|
|
|
|
|
|
|
|
|
# cvd level: tanh keeps it bounded; scale = q90 → q90 maps to tanh(1)=0.76.
|
|
|
|
|
|
"cvd_level_tanh_scale": 1.24e6,
|
|
|
|
|
|
|
|
|
|
|
|
# ---- VELOCITIES (4hr level diff) ----
|
|
|
|
|
|
# Calibrated same way: per_strength = |vel| q90.
|
|
|
|
|
|
"funding_vel_per_strength": 900.0,
|
|
|
|
|
|
"funding_vel_strength_cap": 1.8,
|
|
|
|
|
|
|
|
|
|
|
|
"bs_ratio_vel_per_strength": 0.078,
|
|
|
|
|
|
"bs_ratio_vel_strength_cap": 1.7,
|
|
|
|
|
|
|
|
|
|
|
|
"activity_vel_per_strength": 0.87,
|
|
|
|
|
|
"activity_vel_strength_cap": 1.8,
|
|
|
|
|
|
|
|
|
|
|
|
"cvd_vel_tanh_scale": 1.78e6,
|
2026-06-07 12:34:31 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
# ROLLING WINDOWS (computed per source minute, past-only by shift)
|
|
|
|
|
|
ROLL_ACTIVITY_WIN = 500 # 500-min rolling baseline for activity_excess
|
|
|
|
|
|
ROLL_CVD_WIN = 60 # 60-min summed CVD and price change
|
|
|
|
|
|
EPS_BPS = 0.5 # epsilon to prevent /0 in cvd_div denominator
|
|
|
|
|
|
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# 4-hour aggregation window for LEVELS and VELOCITIES.
|
|
|
|
|
|
# Levels = rolling mean over last 4hr (past-only shift).
|
|
|
|
|
|
# Velocities = level(t) - level(t - LEVEL_WIN_MIN). The signal we found dominant
|
|
|
|
|
|
# was asymmetry_d240 (β=+4.77). Pre-aggregating to the same timescale tests
|
|
|
|
|
|
# whether the lattice's value is temporal smoothing (4hr-input matches it) or
|
|
|
|
|
|
# non-linear coupling (4hr-input fails, only minute-level + lattice memory works).
|
|
|
|
|
|
LEVEL_WIN_MIN = 240
|
|
|
|
|
|
|
2026-06-07 12:34:31 +07:00
|
|
|
|
# Lattice spatial geometry (lattice is 1024×1024, center 512,512)
|
|
|
|
|
|
CENTER = (512.0, 512.0)
|
2026-06-07 13:34:30 +07:00
|
|
|
|
DIPOLE_OFF = 64.0 # dipole leg offset from center (funding x, bs_ratio y)
|
|
|
|
|
|
RING_R = 192.0 # vorticity ring radius (was 96; doubled 2026-06-07 to
|
|
|
|
|
|
# register in the global vorticity_mean metric)
|
2026-06-07 12:34:31 +07:00
|
|
|
|
SIGMA = 32.0 # gaussian sigma of each injection blob
|
|
|
|
|
|
|
|
|
|
|
|
# Vorticity ring: N legs around the ring, alternating signs to create curl.
|
|
|
|
|
|
# 4 legs is the minimum for a clean dipole-quadrupole rotation. 6 is smoother.
|
|
|
|
|
|
VORTICITY_N_LEGS = 6
|
|
|
|
|
|
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# --- VELOCITY (Δlevel over 4hr) spatial geometry — distinct from levels ---
|
|
|
|
|
|
# Levels and velocities of the SAME tension must occupy distinct spatial
|
|
|
|
|
|
# signatures so the lattice can't conflate them. Velocities use a different
|
|
|
|
|
|
# radius / scale than their level counterparts.
|
|
|
|
|
|
DIPOLE_OFF_VEL = 192.0 # funding/bs_ratio velocity dipoles wider than level dipoles
|
|
|
|
|
|
SIGMA_VEL = 16.0 # tighter sigma for velocity injections (sharper, less DC)
|
|
|
|
|
|
RING_R_ACT_VEL = 320.0 # activity-velocity annular ring (same-sign legs = pure breathing)
|
|
|
|
|
|
RING_R_CVD_VEL = 96.0 # cvd-velocity counter-rotating ring (half the cvd-level radius)
|
|
|
|
|
|
ACT_VEL_N_LEGS = 4 # activity-velocity ring leg count
|
|
|
|
|
|
|
2026-06-07 12:34:31 +07:00
|
|
|
|
# Timing
|
|
|
|
|
|
PER_MINUTE_MS = 100 # 10× realtime (proven in throughput probe)
|
|
|
|
|
|
WAIT_AFTER_INJECT_MS = 80 # extra room for the 4 force-patterns to settle
|
|
|
|
|
|
COOLDOWN_BETWEEN_ARMS_S = 1200 # 20 min, same as contstream
|
|
|
|
|
|
|
|
|
|
|
|
# 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"]
|
|
|
|
|
|
|
|
|
|
|
|
# Funding fetch
|
|
|
|
|
|
HL_INFO_URL = "https://api.hyperliquid.xyz/info"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ───────────────────────── logging ─────────────────────────
|
|
|
|
|
|
RUN_ID = time.strftime("%Y%m%dT%H%M%S")
|
|
|
|
|
|
OUT_DIR = Path(f"/mnt/d/Resonance_Engine/traj/tension_{RUN_ID}")
|
|
|
|
|
|
PROGRESS = OUT_DIR / "progress.log"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ───────────────────────── ZMQ telemetry subscriber ─────────────────────────
|
|
|
|
|
|
class LatestTel:
|
|
|
|
|
|
"""Background thread holding the most-recent telemetry frame only."""
|
|
|
|
|
|
|
|
|
|
|
|
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 fetch_funding_history(coin: str, start_min: int, end_min: int,
|
|
|
|
|
|
cache: Path) -> pd.DataFrame:
|
|
|
|
|
|
"""Pull HL fundingHistory for coin between [start_min, end_min] (minutes-since-epoch).
|
|
|
|
|
|
Returns DataFrame[minute_floor_hour, funding_bps_annual] joined-ready.
|
|
|
|
|
|
Caches raw JSON in `cache`."""
|
|
|
|
|
|
if cache.exists():
|
|
|
|
|
|
log(f"using cached funding: {cache}")
|
|
|
|
|
|
return pd.read_parquet(cache)
|
|
|
|
|
|
|
|
|
|
|
|
start_ms = int(start_min) * 60 * 1000
|
|
|
|
|
|
end_ms = int(end_min) * 60 * 1000
|
|
|
|
|
|
log(f"fetching HL fundingHistory for {coin} {start_ms} -> {end_ms}")
|
|
|
|
|
|
rows_all = []
|
|
|
|
|
|
cursor = start_ms
|
|
|
|
|
|
while cursor < end_ms:
|
|
|
|
|
|
body = json.dumps({"type": "fundingHistory", "coin": coin,
|
|
|
|
|
|
"startTime": cursor, "endTime": end_ms}).encode()
|
|
|
|
|
|
req = urllib.request.Request(HL_INFO_URL, data=body,
|
|
|
|
|
|
headers={"Content-Type": "application/json"})
|
|
|
|
|
|
with urllib.request.urlopen(req, timeout=30) as r:
|
|
|
|
|
|
chunk = json.loads(r.read().decode())
|
|
|
|
|
|
if not chunk:
|
|
|
|
|
|
break
|
|
|
|
|
|
rows_all.extend(chunk)
|
|
|
|
|
|
last_ts = int(chunk[-1].get("time", 0))
|
|
|
|
|
|
if last_ts <= cursor:
|
|
|
|
|
|
break
|
|
|
|
|
|
cursor = last_ts + 1
|
|
|
|
|
|
time.sleep(0.15)
|
|
|
|
|
|
if not rows_all:
|
|
|
|
|
|
log("WARNING: empty fundingHistory response")
|
|
|
|
|
|
return pd.DataFrame(columns=["minute_hour", "funding_bps_annual"])
|
|
|
|
|
|
df = pd.DataFrame(rows_all)
|
|
|
|
|
|
df["minute_hour"] = (df["time"].astype("int64") // (1000 * 60)) # to minute-since-epoch
|
|
|
|
|
|
df["funding_rate_per_hour"] = df["fundingRate"].astype(float)
|
|
|
|
|
|
# annualized bps: per-hour rate × 8760 hours × 10000 bps
|
|
|
|
|
|
df["funding_bps_annual"] = df["funding_rate_per_hour"] * 8760 * 10000.0
|
|
|
|
|
|
df = df[["minute_hour", "funding_bps_annual"]].drop_duplicates("minute_hour").sort_values("minute_hour")
|
|
|
|
|
|
cache.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
|
|
df.to_parquet(cache)
|
|
|
|
|
|
log(f"funding rows: {len(df)} "
|
|
|
|
|
|
f"mean={df.funding_bps_annual.mean():+.2f}bps "
|
|
|
|
|
|
f"std={df.funding_bps_annual.std():+.2f}bps "
|
|
|
|
|
|
f"range={df.funding_bps_annual.min():+.2f}..{df.funding_bps_annual.max():+.2f}")
|
|
|
|
|
|
return df
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def compute_tensions(df: pd.DataFrame, funding: pd.DataFrame) -> pd.DataFrame:
|
2026-06-08 06:34:31 +07:00
|
|
|
|
"""Per minute, compute the four native tensions AND their 4-hour level
|
|
|
|
|
|
aggregates plus 4-hour velocities. Eight tensions total.
|
|
|
|
|
|
|
|
|
|
|
|
Naming:
|
|
|
|
|
|
<name> = per-minute native tension (kept for reference & analyzer)
|
|
|
|
|
|
<name>_level = 4-hour rolling mean (past-only via shift(1))
|
|
|
|
|
|
<name>_vel = level(t) - level(t - LEVEL_WIN_MIN)
|
|
|
|
|
|
"""
|
2026-06-07 12:34:31 +07:00
|
|
|
|
out = df.copy()
|
|
|
|
|
|
|
|
|
|
|
|
# --- funding: forward-fill the hourly value onto each minute ---
|
|
|
|
|
|
out["minute_hour_floor"] = (out["minute"] // 60) * 60
|
|
|
|
|
|
out = out.merge(funding.rename(columns={"minute_hour": "minute_hour_floor"}),
|
|
|
|
|
|
on="minute_hour_floor", how="left")
|
|
|
|
|
|
out["funding_bps"] = out["funding_bps_annual"].ffill()
|
|
|
|
|
|
|
|
|
|
|
|
# --- buy_sell ratio signed ---
|
|
|
|
|
|
denom = (out["taker_buy_usd"] + out["taker_sell_usd"]).replace(0, np.nan)
|
|
|
|
|
|
out["bs_ratio_signed"] = (out["taker_buy_usd"] / denom) - 0.5 # -0.5..+0.5
|
|
|
|
|
|
|
|
|
|
|
|
# --- activity_excess: trade_count / rolling-500min-mean - 1 ---
|
|
|
|
|
|
rolling_mean = out["trade_count"].astype(float).shift(1).rolling(
|
|
|
|
|
|
window=ROLL_ACTIVITY_WIN, min_periods=50
|
|
|
|
|
|
).mean()
|
|
|
|
|
|
out["activity_excess"] = (out["trade_count"].astype(float) / rolling_mean) - 1.0
|
|
|
|
|
|
|
|
|
|
|
|
# --- cvd_divergence: 60min summed signed_flow / |60min bps price change| ---
|
|
|
|
|
|
sflow = out["signed_flow_usd"].astype(float)
|
|
|
|
|
|
cvd_60 = sflow.shift(1).rolling(window=ROLL_CVD_WIN, min_periods=10).sum()
|
|
|
|
|
|
px = out["mid_price"].astype(float)
|
|
|
|
|
|
px_now = px.shift(1)
|
|
|
|
|
|
px_then = px.shift(1 + ROLL_CVD_WIN)
|
|
|
|
|
|
pct_chg_bps = ((px_now - px_then) / px_then * 10000.0).abs()
|
|
|
|
|
|
out["cvd_divergence"] = cvd_60 / (pct_chg_bps + EPS_BPS)
|
|
|
|
|
|
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# --- 4-HOUR LEVELS (rolling means, past-only via shift(1)) ---
|
|
|
|
|
|
for col in ["funding_bps", "bs_ratio_signed", "activity_excess", "cvd_divergence"]:
|
|
|
|
|
|
out[f"{col}_level"] = (out[col].astype(float).shift(1)
|
|
|
|
|
|
.rolling(LEVEL_WIN_MIN, min_periods=60).mean())
|
|
|
|
|
|
|
|
|
|
|
|
# --- 4-HOUR VELOCITIES (level(t) - level(t - LEVEL_WIN_MIN)) ---
|
|
|
|
|
|
for col in ["funding_bps", "bs_ratio_signed", "activity_excess", "cvd_divergence"]:
|
|
|
|
|
|
out[f"{col}_vel"] = out[f"{col}_level"] - out[f"{col}_level"].shift(LEVEL_WIN_MIN)
|
|
|
|
|
|
|
2026-06-07 12:34:31 +07:00
|
|
|
|
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ───────────────────────── injection encoders ─────────────────────────
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# ---- LEVEL ENCODERS (re-use original geometries, fed with 4hr-mean values) ----
|
|
|
|
|
|
def encode_funding_level(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr-mean funding → x-axis dipole at ±DIPOLE_OFF."""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = float(np.clip(val / MAP_SCALE["funding_level_per_strength"],
|
|
|
|
|
|
-MAP_SCALE["funding_level_strength_cap"],
|
|
|
|
|
|
+MAP_SCALE["funding_level_strength_cap"]))
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
2026-06-07 12:34:31 +07:00
|
|
|
|
cx, cy = CENTER
|
2026-06-08 06:34:31 +07:00
|
|
|
|
_send_inject(pub, cx - DIPOLE_OFF, cy, -s)
|
|
|
|
|
|
_send_inject(pub, cx + DIPOLE_OFF, cy, +s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encode_bsratio_level(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr-mean bs_ratio → y-axis dipole at ±DIPOLE_OFF."""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = float(np.clip(val / MAP_SCALE["bs_ratio_level_per_strength"],
|
|
|
|
|
|
-MAP_SCALE["bs_ratio_level_strength_cap"],
|
|
|
|
|
|
+MAP_SCALE["bs_ratio_level_strength_cap"]))
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
2026-06-07 12:34:31 +07:00
|
|
|
|
cx, cy = CENTER
|
2026-06-08 06:34:31 +07:00
|
|
|
|
_send_inject(pub, cx, cy - DIPOLE_OFF, -s)
|
|
|
|
|
|
_send_inject(pub, cx, cy + DIPOLE_OFF, +s)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encode_activity_level(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr-mean activity → isotropic center blob."""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = float(np.clip(val / MAP_SCALE["activity_level_per_strength"],
|
|
|
|
|
|
-MAP_SCALE["activity_level_strength_cap"],
|
|
|
|
|
|
+MAP_SCALE["activity_level_strength_cap"]))
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
2026-06-07 12:34:31 +07:00
|
|
|
|
cx, cy = CENTER
|
2026-06-08 06:34:31 +07:00
|
|
|
|
_send_inject(pub, cx, cy, s)
|
2026-06-07 12:34:31 +07:00
|
|
|
|
|
|
|
|
|
|
|
2026-06-08 06:34:31 +07:00
|
|
|
|
def encode_cvd_level(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr-mean cvd_div → 6-leg alternating-sign ring at R=192."""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = math.tanh(val / MAP_SCALE["cvd_level_tanh_scale"])
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
2026-06-07 12:34:31 +07:00
|
|
|
|
cx, cy = CENTER
|
2026-06-08 06:34:31 +07:00
|
|
|
|
sigma = SIGMA * 0.75
|
2026-06-07 12:34:31 +07:00
|
|
|
|
for k in range(VORTICITY_N_LEGS):
|
|
|
|
|
|
theta = 2 * math.pi * k / VORTICITY_N_LEGS
|
|
|
|
|
|
x = cx + RING_R * math.cos(theta)
|
|
|
|
|
|
y = cy + RING_R * math.sin(theta)
|
2026-06-08 06:34:31 +07:00
|
|
|
|
sign = s if (k % 2 == 0) else -s
|
|
|
|
|
|
_send_inject(pub, x, y, sign, sigma)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---- VELOCITY ENCODERS (new, geometrically distinct from level encoders) ----
|
|
|
|
|
|
def encode_funding_vel(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr funding velocity → x-axis dipole at ±DIPOLE_OFF_VEL=192, sigma=16.
|
|
|
|
|
|
Wider separation and tighter sigma than the level dipole → distinct signature."""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = float(np.clip(val / MAP_SCALE["funding_vel_per_strength"],
|
|
|
|
|
|
-MAP_SCALE["funding_vel_strength_cap"],
|
|
|
|
|
|
+MAP_SCALE["funding_vel_strength_cap"]))
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
|
|
|
|
|
cx, cy = CENTER
|
|
|
|
|
|
_send_inject(pub, cx - DIPOLE_OFF_VEL, cy, -s, SIGMA_VEL)
|
|
|
|
|
|
_send_inject(pub, cx + DIPOLE_OFF_VEL, cy, +s, SIGMA_VEL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encode_bsratio_vel(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr bs_ratio velocity → y-axis dipole at ±DIPOLE_OFF_VEL=192, sigma=16."""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = float(np.clip(val / MAP_SCALE["bs_ratio_vel_per_strength"],
|
|
|
|
|
|
-MAP_SCALE["bs_ratio_vel_strength_cap"],
|
|
|
|
|
|
+MAP_SCALE["bs_ratio_vel_strength_cap"]))
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
|
|
|
|
|
cx, cy = CENTER
|
|
|
|
|
|
_send_inject(pub, cx, cy - DIPOLE_OFF_VEL, -s, SIGMA_VEL)
|
|
|
|
|
|
_send_inject(pub, cx, cy + DIPOLE_OFF_VEL, +s, SIGMA_VEL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encode_activity_vel(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr activity velocity → 4-leg same-sign annular ring at R=320.
|
|
|
|
|
|
Same-sign legs = pure 'breathing' mode (expansion vs contraction),
|
|
|
|
|
|
geometrically distinct from any dipole or alternating ring."""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = float(np.clip(val / MAP_SCALE["activity_vel_per_strength"],
|
|
|
|
|
|
-MAP_SCALE["activity_vel_strength_cap"],
|
|
|
|
|
|
+MAP_SCALE["activity_vel_strength_cap"]))
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
|
|
|
|
|
cx, cy = CENTER
|
|
|
|
|
|
for k in range(ACT_VEL_N_LEGS):
|
|
|
|
|
|
theta = 2 * math.pi * k / ACT_VEL_N_LEGS
|
|
|
|
|
|
x = cx + RING_R_ACT_VEL * math.cos(theta)
|
|
|
|
|
|
y = cy + RING_R_ACT_VEL * math.sin(theta)
|
|
|
|
|
|
_send_inject(pub, x, y, s, SIGMA_VEL)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def encode_cvd_vel(pub: zmq.Socket, val: float) -> None:
|
|
|
|
|
|
"""4hr cvd_div velocity → 6-leg alternating-sign ring at R=96.
|
|
|
|
|
|
Half the level-ring radius → inner counter-rotating vorticity. Sign-flipped
|
|
|
|
|
|
relative to level so rotation opposes the level ring's curl when both fire.
|
|
|
|
|
|
"""
|
|
|
|
|
|
if not np.isfinite(val): return
|
|
|
|
|
|
s = math.tanh(val / MAP_SCALE["cvd_vel_tanh_scale"])
|
|
|
|
|
|
if abs(s) < 1e-6: return
|
|
|
|
|
|
cx, cy = CENTER
|
|
|
|
|
|
for k in range(VORTICITY_N_LEGS):
|
|
|
|
|
|
theta = 2 * math.pi * k / VORTICITY_N_LEGS + (math.pi / VORTICITY_N_LEGS) # rotated 30°
|
|
|
|
|
|
x = cx + RING_R_CVD_VEL * math.cos(theta)
|
|
|
|
|
|
y = cy + RING_R_CVD_VEL * math.sin(theta)
|
|
|
|
|
|
sign = -s if (k % 2 == 0) else s # opposite rotation to level ring
|
|
|
|
|
|
_send_inject(pub, x, y, sign, SIGMA_VEL)
|
2026-06-07 12:34:31 +07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ───────────────────────── per-arm runner ─────────────────────────
|
|
|
|
|
|
def snapshot_row(tel: LatestTel, minute: int, arm: str,
|
|
|
|
|
|
tensions: dict) -> 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,
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# per-minute (kept for reference / analyzer continuity)
|
2026-06-07 12:34:31 +07:00
|
|
|
|
"funding_bps": tensions.get("funding_bps"),
|
|
|
|
|
|
"bs_ratio_signed": tensions.get("bs_ratio_signed"),
|
|
|
|
|
|
"activity_excess": tensions.get("activity_excess"),
|
|
|
|
|
|
"cvd_divergence": tensions.get("cvd_divergence"),
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# 4-hour levels (what arm T actually injects)
|
|
|
|
|
|
"funding_bps_level": tensions.get("funding_bps_level"),
|
|
|
|
|
|
"bs_ratio_signed_level": tensions.get("bs_ratio_signed_level"),
|
|
|
|
|
|
"activity_excess_level": tensions.get("activity_excess_level"),
|
|
|
|
|
|
"cvd_divergence_level": tensions.get("cvd_divergence_level"),
|
|
|
|
|
|
# 4-hour velocities (what arm T actually injects)
|
|
|
|
|
|
"funding_bps_vel": tensions.get("funding_bps_vel"),
|
|
|
|
|
|
"bs_ratio_signed_vel": tensions.get("bs_ratio_signed_vel"),
|
|
|
|
|
|
"activity_excess_vel": tensions.get("activity_excess_vel"),
|
|
|
|
|
|
"cvd_divergence_vel": tensions.get("cvd_divergence_vel"),
|
2026-06-07 12:34:31 +07:00
|
|
|
|
}
|
|
|
|
|
|
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) -> pd.DataFrame:
|
|
|
|
|
|
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":
|
2026-06-08 06:34:31 +07:00
|
|
|
|
log(" arm T: 8 TENSION INJECTIONS per minute (4 levels + 4 velocities, 4hr window)")
|
2026-06-07 12:34:31 +07:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
for i in range(n_minutes):
|
|
|
|
|
|
t_tick = time.time()
|
|
|
|
|
|
row = df.iloc[i]
|
|
|
|
|
|
tensions = {
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# per-minute (reference)
|
2026-06-07 12:34:31 +07:00
|
|
|
|
"funding_bps": row.get("funding_bps"),
|
|
|
|
|
|
"bs_ratio_signed": row.get("bs_ratio_signed"),
|
|
|
|
|
|
"activity_excess": row.get("activity_excess"),
|
|
|
|
|
|
"cvd_divergence": row.get("cvd_divergence"),
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# 4-hour levels (injected)
|
|
|
|
|
|
"funding_bps_level": row.get("funding_bps_level"),
|
|
|
|
|
|
"bs_ratio_signed_level": row.get("bs_ratio_signed_level"),
|
|
|
|
|
|
"activity_excess_level": row.get("activity_excess_level"),
|
|
|
|
|
|
"cvd_divergence_level": row.get("cvd_divergence_level"),
|
|
|
|
|
|
# 4-hour velocities (injected)
|
|
|
|
|
|
"funding_bps_vel": row.get("funding_bps_vel"),
|
|
|
|
|
|
"bs_ratio_signed_vel": row.get("bs_ratio_signed_vel"),
|
|
|
|
|
|
"activity_excess_vel": row.get("activity_excess_vel"),
|
|
|
|
|
|
"cvd_divergence_vel": row.get("cvd_divergence_vel"),
|
2026-06-07 12:34:31 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if arm == "T" and pub is not None:
|
2026-06-08 06:34:31 +07:00
|
|
|
|
# 4 LEVELS — existing spatial geometries, fed with 4hr means
|
|
|
|
|
|
encode_funding_level (pub, tensions["funding_bps_level"])
|
|
|
|
|
|
encode_bsratio_level (pub, tensions["bs_ratio_signed_level"])
|
|
|
|
|
|
encode_activity_level(pub, tensions["activity_excess_level"])
|
|
|
|
|
|
encode_cvd_level (pub, tensions["cvd_divergence_level"])
|
|
|
|
|
|
# 4 VELOCITIES — new geometries, fed with 4hr deltas
|
|
|
|
|
|
encode_funding_vel (pub, tensions["funding_bps_vel"])
|
|
|
|
|
|
encode_bsratio_vel (pub, tensions["bs_ratio_signed_vel"])
|
|
|
|
|
|
encode_activity_vel(pub, tensions["activity_excess_vel"])
|
|
|
|
|
|
encode_cvd_vel (pub, tensions["cvd_divergence_vel"])
|
2026-06-07 12:34:31 +07:00
|
|
|
|
time.sleep(wait_inject_s)
|
|
|
|
|
|
else:
|
|
|
|
|
|
time.sleep(wait_inject_s)
|
|
|
|
|
|
|
|
|
|
|
|
records.append(snapshot_row(tel, int(row.minute), arm, tensions))
|
|
|
|
|
|
|
|
|
|
|
|
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 {}
|
|
|
|
|
|
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={cur_field.get('asymmetry','?'):.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}")
|
|
|
|
|
|
last_status = time.time()
|
|
|
|
|
|
|
|
|
|
|
|
log(f" arm {arm} COMPLETE records={len(records)} total={(time.time()-t_arm_start)/60:.1f}min")
|
|
|
|
|
|
return pd.DataFrame(records)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ───────────────────────── main ─────────────────────────
|
|
|
|
|
|
def main():
|
|
|
|
|
|
ap = argparse.ArgumentParser()
|
|
|
|
|
|
ap.add_argument("--days-glob", default=DEFAULT_DAYS_GLOB,
|
|
|
|
|
|
help="day-dir glob pattern under hl_data/minutes/")
|
|
|
|
|
|
ap.add_argument("--max-minutes", type=int, default=None,
|
|
|
|
|
|
help="cap the number of minutes used (handy for shakedown)")
|
|
|
|
|
|
ap.add_argument("--arms", default="A,T",
|
|
|
|
|
|
help="comma-separated arm names to run (default A,T)")
|
|
|
|
|
|
ap.add_argument("--skip-funding", action="store_true",
|
|
|
|
|
|
help="don't fetch funding history (uses NaN; useful for dry run)")
|
2026-06-07 13:34:30 +07:00
|
|
|
|
ap.add_argument("--cooldown-s", type=int, default=COOLDOWN_BETWEEN_ARMS_S,
|
|
|
|
|
|
help="seconds to wait between arms (only applies after arm T)")
|
2026-06-07 12:34:31 +07:00
|
|
|
|
args = ap.parse_args()
|
|
|
|
|
|
|
|
|
|
|
|
log(f"=== market_tension_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}")
|
|
|
|
|
|
|
|
|
|
|
|
# load data
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
# funding
|
|
|
|
|
|
funding = pd.DataFrame(columns=["minute_hour", "funding_bps_annual"])
|
|
|
|
|
|
if not args.skip_funding:
|
|
|
|
|
|
try:
|
|
|
|
|
|
funding = fetch_funding_history(
|
|
|
|
|
|
COIN,
|
|
|
|
|
|
start_min=int(df.minute.min()) - 120, # extra room for ffill
|
|
|
|
|
|
end_min=int(df.minute.max()) + 120,
|
|
|
|
|
|
cache=OUT_DIR / "funding_history.parquet"
|
|
|
|
|
|
)
|
|
|
|
|
|
except Exception as e:
|
|
|
|
|
|
log(f"funding fetch failed ({e}); proceeding with NaN funding")
|
|
|
|
|
|
|
|
|
|
|
|
# tensions
|
|
|
|
|
|
df = compute_tensions(df, funding)
|
2026-06-08 06:34:31 +07:00
|
|
|
|
log("tensions computed (per-minute + 4hr levels + 4hr velocities):")
|
|
|
|
|
|
for col in ["funding_bps", "bs_ratio_signed", "activity_excess", "cvd_divergence",
|
|
|
|
|
|
"funding_bps_level", "bs_ratio_signed_level",
|
|
|
|
|
|
"activity_excess_level", "cvd_divergence_level",
|
|
|
|
|
|
"funding_bps_vel", "bs_ratio_signed_vel",
|
|
|
|
|
|
"activity_excess_vel", "cvd_divergence_vel"]:
|
2026-06-07 12:34:31 +07:00
|
|
|
|
s = df[col]
|
|
|
|
|
|
finite = int(np.isfinite(s.astype(float)).sum())
|
2026-06-08 06:34:31 +07:00
|
|
|
|
log(f" {col:<26} n_finite={finite:>5}/{len(s)} "
|
2026-06-07 12:34:31 +07:00
|
|
|
|
f"mean={s.mean():+.4f} std={s.std():+.4f} "
|
|
|
|
|
|
f"min={s.min():+.4f} max={s.max():+.4f}")
|
|
|
|
|
|
|
|
|
|
|
|
# write meta
|
|
|
|
|
|
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())],
|
|
|
|
|
|
"map_scale": MAP_SCALE,
|
|
|
|
|
|
"spatial": {
|
2026-06-08 06:34:31 +07:00
|
|
|
|
"center": list(CENTER),
|
|
|
|
|
|
"dipole_off": DIPOLE_OFF, "dipole_off_vel": DIPOLE_OFF_VEL,
|
2026-06-07 13:34:30 +07:00
|
|
|
|
"ring_r": RING_R,
|
2026-06-08 06:34:31 +07:00
|
|
|
|
"ring_r_act_vel": RING_R_ACT_VEL, "ring_r_cvd_vel": RING_R_CVD_VEL,
|
|
|
|
|
|
"sigma": SIGMA, "sigma_vel": SIGMA_VEL,
|
|
|
|
|
|
"vorticity_n_legs": VORTICITY_N_LEGS, "act_vel_n_legs": ACT_VEL_N_LEGS,
|
2026-06-07 12:34:31 +07:00
|
|
|
|
},
|
|
|
|
|
|
"timing": {
|
|
|
|
|
|
"per_minute_ms": PER_MINUTE_MS,
|
|
|
|
|
|
"wait_after_inject_ms": WAIT_AFTER_INJECT_MS,
|
|
|
|
|
|
"cooldown_between_arms_s": COOLDOWN_BETWEEN_ARMS_S,
|
|
|
|
|
|
},
|
|
|
|
|
|
"rolling_windows": {
|
|
|
|
|
|
"activity_baseline_min": ROLL_ACTIVITY_WIN,
|
|
|
|
|
|
"cvd_window_min": ROLL_CVD_WIN,
|
|
|
|
|
|
"eps_bps": EPS_BPS,
|
2026-06-08 06:34:31 +07:00
|
|
|
|
"level_win_min": LEVEL_WIN_MIN,
|
2026-06-07 12:34:31 +07:00
|
|
|
|
},
|
|
|
|
|
|
"arms": args.arms.split(","),
|
|
|
|
|
|
}
|
|
|
|
|
|
(OUT_DIR / "meta.json").write_text(json.dumps(meta, indent=2))
|
|
|
|
|
|
|
|
|
|
|
|
# ZMQ setup
|
|
|
|
|
|
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}")
|
|
|
|
|
|
|
|
|
|
|
|
try:
|
2026-06-07 13:34:30 +07:00
|
|
|
|
arms_to_run = [a.strip().upper() for a in args.arms.split(",")]
|
2026-06-07 12:34:31 +07:00
|
|
|
|
for ai, arm in enumerate(arms_to_run):
|
|
|
|
|
|
if arm not in ("A", "T"):
|
|
|
|
|
|
log(f"skipping unknown arm: {arm}")
|
|
|
|
|
|
continue
|
2026-06-07 13:34:30 +07:00
|
|
|
|
# Only cooldown AFTER arm T (the perturbing arm).
|
|
|
|
|
|
# Arm A is passive, no field settling needed before/after it.
|
|
|
|
|
|
if ai > 0 and arms_to_run[ai - 1] == "T":
|
|
|
|
|
|
log(f"--- cooldown {args.cooldown_s}s (let field settle after arm T) ---")
|
|
|
|
|
|
time.sleep(args.cooldown_s)
|
2026-06-07 12:34:31 +07:00
|
|
|
|
arm_df = run_arm(tel, pub if arm == "T" else None, arm, df)
|
|
|
|
|
|
outf = OUT_DIR / f"arm_{arm}_{'tension' if arm=='T' else 'no_inject'}.parquet"
|
|
|
|
|
|
arm_df.to_parquet(outf)
|
|
|
|
|
|
log(f" saved {outf.name} ({len(arm_df)} rows)")
|
|
|
|
|
|
log(f"\n=== ALL ARMS COMPLETE ===")
|
|
|
|
|
|
log(f"output: {OUT_DIR}")
|
|
|
|
|
|
finally:
|
|
|
|
|
|
tel.stop()
|
|
|
|
|
|
pub.close(0)
|
|
|
|
|
|
ctx.term()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
main()
|