# arb-2: per-run estimates of sigma and order rate inside the arb/retail classifier Replica: github.com/gtmcknight/amm-sim at 4812a6382311624e1506aca131ca35c378f930d9, `research/solved`, run under plain `python3 -I` (CPython 3.12.14), no other setup. Taylor (gtmcknight) wrote the replica, the solve, `real.py` and `pol_mid.json`; this item only changes the classifier. **Gate P passes.** `python3 -I gates.py` printed exactly the README values (gaussian+plain 443.455598 / 571.463409 / 525.168028 on seeds 10000..10002, and every other column matched too). Unchanged `real.py pol_mid.json plain:soft=1` on 10000..12047 gives mean edge 532.386, +2.828 se 0.041 vs HA, which is the README's +2.828. ## Problem (quoted) From README "Open", item 3: > 3. **Arb or retail.** Worth 2.7. Tried, no gain: forbidding the quote zone where the two overlap (`DP_MASK`, costs 0.6 to 2.4 with perfect information and leaves the error at 13 to 15%), buying more pins with tighter quotes (kappa above 1e-5 costs more than it returns), a quote-aware fill likelihood. A table classifier on the filter's own features gets 18.8% against the lattice's 16.2%, so the error is near what those features allow. Not tried: choosing quotes against the whole lattice posterior instead of its mean and sd, per-run estimates of sigma and order rate inside the classifier, the router likelihood per price hypothesis. Also from the README: "**What separates +4.26 from #1 is one call:** is the first trade of a step the arb or retail. The filter is wrong 16% of the time. With the true answer the same code scores +6.98." ## Idea Counting the "Not tried" list in the order written, the second idea is **"per-run estimates of sigma and order rate inside the classifier"**. That is the one implemented here. `real.py` uses two run-independent constants in the classifier: `SIG2 = 0.000945 ** 2` (9.45 bps a step), which grows the prior variance and so sets the arb likelihood `L_arb`, and `RHO = 0.26`, the retail weight in `L_ret`. The replica draws each run's `sigma = cr.uniform(0.000882, 0.001008)` and `lam = cr.uniform(0.6, 1.0)` (`replica2.py`). Each one is estimated online, from what `afterSwap` sees, and fed back into the classifier: - **sigma (`sig=W`)**: at each first trade with pi >= `pinmin` (default 0.99), take the arb reading `lp_arb` as an exact price pin. Between consecutive pins, accumulate `sum (Δ ln p)^2` and `sum Δt`. Pin times are stopping times of the random walk, so by Wald's second identity the ratio is unbiased for sigma^2 if the pins are true arbs. It is shrunk toward 9.45 bps with a prior worth `W` steps, and it replaces `SIG2` in the filter's variance growth, which drives `L_arb`. `jmax` optionally skips implausibly large pin jumps. - **order rate (`rate=W`)**: the soft count of first-of-step trades the classifier calls retail, `sum (1 - pi)`, divided by steps elapsed, is this run's retail-first rate `r`. It is shrunk toward `r0` with a prior worth `W` steps, and `RHO_run = RHO * (r_hat / r0)^rgam`. On the choose set, `r` correlates 0.88 with the run's true `lam` (mean r = 0.330, hence `r0 = 0.33`). - Two diagnostics that are **not policies** bound what the idea can be worth: `sigoracle=1` uses the run's true sigma, and `lamoracle=1` sets `RHO * (lam/0.8)^rgam` from the true lam. With all new options off (or `rate=1,rgam=0`, which leaves rho at exactly RHO), the policy matches `real.py plain:soft=1` on all 2048 report seeds (paired difference 0.0000, se 0.0000). ## Change (unified diff, complete) The change is a sibling file, `real_rs.py`, a copy of `real.py` with the diff below. `real.py plain:soft=1` is untouched and still scores. `real_rs.py pol_mid.json plain:soft=1` runs as before, and `SEED0=60000 NSEED=1024 python3 -I real_rs.py pol_mid.json plain:soft=1,rate=500,r0=0.33,rgam=1.5` runs the chosen version. The last hunk only guards the vs-HA print, because the HA rows in `calib_out_2048.json` exist only for 10000..12047. Resulting sha256 of real_rs.py: `341951afd984cae87648de94247ca021fcf974fb662771643216ec05339b5981`. ```diff --- real.py 2026-11-09 21:35:21.448807739 +0000 +++ real_rs.py 2026-11-09 21:50:23.228749031 +0000 @@ -7,7 +7,13 @@ Rival: absolute spot dead-reckoned (router identity on own retail trades, band clip against the price estimate). Policy: table lookup at the estimates. Oracle arguments are used for diagnostics only. -usage: python3 -I real.py [variant ...] variants: ce | qm: ... +usage: python3 -I real_rs.py [variant ...] variants: ce | qm: ... seeds SEED0.. (default 10000) + +Per-run estimates inside the classifier (README Open 3, untried idea 2): + sig=W sigma^2 from confident arb pins (pi >= pinmin): by Wald's identity sum (dln p)^2 / sum dt between pins + is unbiased for sigma^2 at stopping times; shrunk to 9.45 bps with a prior worth W steps. + rate=W the retail weight RHO scaled by this run's rate of own first-of-step retail trades (soft count), + relative to r0, shrunk with a prior worth W steps, exponent rgam. """ import sys, os, math, json, time from multiprocessing import Pool @@ -58,9 +64,15 @@ self.s2h = 100.0 self.fb = self.fa = 0.003 self.sp = 100.0 # own spot after our last callback + # per-run estimates + self.lpin = math.log(100.0); self.tpin = -1; self.ss = 0.0; self.tt = 0 + self.sig2 = SIG2; self.nret = 0.0; self.rho = RHO + self.nsw = 0; self.nlab = 0; self.ntrue = 0; self.nok = 0 # diagnostics self.nmis = 0; self.ncls = 0; self.se = 0.0; self.nse = 0; self.se2 = 0.0 def init(self, *a): + if self.o.get('sigoracle', 0): self.sig2 = a[0] * a[0] # diagnostic: true sigma, not a policy + if self.o.get('lamoracle', 0): self.rho = RHO * (a[1] / 0.8) ** self.o.get('rgam', 1.0) # diagnostic: true order rate fb, fa = self._fees(0.0, 0.0, 0) self.fb, self.fa = R.clampf(fb), R.clampf(fa) return fb, fa @@ -106,9 +118,9 @@ lo = math.log(self.sp * (1.0 - self.fb)); hi = math.log(self.sp / (1.0 - self.fa)) # no-arb interval of the quotes in force m, v = self.m, self.v for _ in range(min(dt - 1, 12)): # fully silent steps - v += SIG2 + v += self.sig2 _, m, v = trunc(m, v, lo, hi) - v += SIG2 # this step's move + v += self.sig2 # this step's move # arb hypothesis: p exactly at the post-trade band edge lp_arb = math.log(s * (1.0 - self.fb)) if is_buy else math.log(s / (1.0 - self.fa)) sd = math.sqrt(v) @@ -136,11 +148,24 @@ D = max(D, 1e-9) l_ret = Z * 0.5 * H0 * jac * phi((math.log(D) - LD_FULL) / 1.2) / (D * 1.2) else: - l_ret = Z * RHO * phi((math.log(max(d, 1e-9)) - LD_M) / LD_S) / (max(d, 1e-9) * LD_S) + l_ret = Z * self.rho * phi((math.log(max(d, 1e-9)) - LD_M) / LD_S) / (max(d, 1e-9) * LD_S) pi = l_arb / (l_arb + l_ret) if (l_arb + l_ret) > 0 else 0.0 if o.get('oraclearb', 0): pi = 1.0 if arb else 0.0 is_arb = pi > 0.5 self.ncls += 1 + W = o.get('sig', 0.0) + if W > 0.0 and pi >= o.get('pinmin', 0.99): + dl = lp_arb - self.lpin + if abs(dl) < o.get('jmax', 1.0): + self.ss += dl * dl; self.tt += t - self.tpin + self.sig2 = (SIG2 * W + self.ss) / (W + self.tt) + self.lpin = lp_arb; self.tpin = t + W = o.get('rate', 0.0) + if W > 0.0: + r0 = o.get('r0', 0.3) + self.nret += 1.0 - pi + rh = (self.nret + W * r0) / (t + 1 + W) + self.rho = RHO * (rh / r0) ** o.get('rgam', 1.0) if is_arb != arb: self.nmis += 1 if o.get('soft', 0) and pi < o.get('hard', 0.995) and pi > 1.0 - o.get('hard', 0.995): m2 = pi * lp_arb + (1.0 - pi) * mt @@ -167,6 +192,7 @@ phs = 1 if self.cnt <= 1 else 2 else: phs = 0 + self.nsw += 1; self.nlab += is_arb; self.ntrue += arb; self.nok += (is_arb == arb) err = (self.m - math.log(p)) * 1e4 self.se += err * err; self.nse += 1 if not is_arb: self.se2 += err * err @@ -183,7 +209,8 @@ for s in seeds: pol = RealPolicy(T, opt) r = R.run(s, pol) - out.append((s, r['edge'], r['retail'], r['arb'], r['rv'], r['av'], pol.nmis, pol.ncls, pol.se, pol.nse)) + out.append((s, r['edge'], r['retail'], r['arb'], r['rv'], r['av'], pol.nmis, pol.ncls, pol.se, pol.nse, + pol.nsw, pol.nlab, pol.ntrue, pol.nok, r['sigma'], r['lam'], pol.sig2, pol.nret, pol.tlast)) return out @@ -192,7 +219,7 @@ res = pool.map(ev, [(path, opt, seeds[i:i + 8]) for i in range(0, len(seeds), 8)], chunksize=1) res = {r[0]: r for b in res for r in b} n = len(seeds); mean = lambda i: sum(res[s][i] for s in seeds) / n - mh, seh = stats([res[s][1] - ref['HA'][s]['edge'] for s in seeds]) + mh, seh = stats([res[s][1] - ref['HA'][s]['edge'] for s in seeds]) if all(s in ref['HA'] for s in seeds) else (float('nan'), float('nan')) # HA rows exist for 10000..12047 only mis = sum(res[s][6] for s in seeds) / max(1, sum(res[s][7] for s in seeds)) rms = math.sqrt(sum(res[s][8] for s in seeds) / max(1, sum(res[s][9] for s in seeds))) print(f'{label:24s} edge {mean(1):8.3f} retail {mean(2):8.3f} arb {mean(3):7.3f} rv {mean(4):6.0f} av {mean(5):6.0f} vs HA {mh:+.3f} se {seh:.3f} misclass {mis:.2%} p rms {rms:.1f} bps ({time.time()-t0:.0f}s)', flush=True) @@ -202,7 +229,8 @@ if __name__ == '__main__': path = sys.argv[1] n = int(os.environ.get('NSEED', '2048')) - seeds = list(range(10000, 10000 + n)) + s0 = int(os.environ.get('SEED0', '10000')) + seeds = list(range(s0, s0 + n)) rows = json.load(open(CALIB)); ref = {} for r in rows: ref.setdefault(r['kind'], {})[r['seed']] = r with Pool(14) as pool: ``` **Harness, and where it departs from the README.** The README's command, `python3 -I real.py pol_mid.json plain:soft=1`, scores only seeds 10000.. against HA, uses `Pool(14)`, and keeps no per-seed rows. For the paired numbers I used a scratch harness, `pair.py`, which calls `real.ev` (unchanged `real.py`, `{'soft': 1}`) for the baseline and `real_rs.ev` for each variant on the same seeds. It caches per-seed rows and reports mean difference, se = sd/sqrt(n), n and the 5 worst seeds. The scratch harness is not part of the deliverable. Each 1024-seed run took about 25 s on 6 cores. ## Choose-set results (seeds 60000..61023, n=1024, every setting tried) Paired difference is variant edge minus unchanged `real.py pol_mid.json plain:soft=1` edge on the same seed. "misclass" is over first-of-step own trades, the only trades the classifier decides. "lab arb" and "acc all" are over all own swaps. All settings include `soft=1`, which is omitted from the setting names below. Oracle rows are diagnostics, not policies. | setting | mean diff | se | n | worst 5 (seed diff) | misclass | lab arb | acc all | |---|---|---|---|---|---|---|---| | `sig=200` | -0.0632 | 0.0159 | 1024 | 60684 -3.123, 60777 -2.224, 60550 -1.697, 60566 -1.625, 61007 -1.507 | 16.72% | 33.25% | 89.24% | | `sig=1000` | -0.0336 | 0.0138 | 1024 | 60044 -1.651, 60014 -1.564, 61012 -1.379, 60920 -1.334, 60702 -1.290 | 16.67% | 33.27% | 89.26% | | `sig=5000` | -0.0096 | 0.0102 | 1024 | 60075 -1.060, 60105 -0.967, 60204 -0.917, 60359 -0.881, 60702 -0.869 | 16.64% | 33.29% | 89.29% | | `rate=500,r0=0.33` | +0.0110 | 0.0092 | 1024 | 60014 -1.426, 60238 -1.199, 60766 -1.011, 60523 -0.949, 60825 -0.873 | 16.58% | 33.28% | 89.33% | | `rate=2000,r0=0.33` | +0.0066 | 0.0082 | 1024 | 60523 -1.122, 60238 -1.055, 60502 -0.917, 60385 -0.899, 60681 -0.838 | 16.59% | 33.27% | 89.32% | | `rate=500,r0=0.33,rgam=0.5` | +0.0079 | 0.0077 | 1024 | 60702 -1.169, 60831 -1.031, 60523 -0.659, 61013 -0.650, 60018 -0.645 | 16.60% | 33.27% | 89.31% | | `sig=1000,rate=500,r0=0.33` | -0.0036 | 0.0134 | 1024 | 60014 -1.831, 60766 -1.589, 60018 -1.345, 60204 -1.281, 60229 -1.268 | 16.63% | 33.27% | 89.29% | | `sigoracle=1 (oracle diagnostic)` | +0.0131 | 0.0103 | 1024 | 60014 -1.379, 61012 -1.344, 60304 -1.021, 60790 -0.960, 60881 -0.941 | 16.62% | 33.28% | 89.30% | | `lamoracle=1 (oracle diagnostic)` | +0.0284 | 0.0136 | 1024 | 60677 -1.367, 60523 -1.364, 60465 -1.127, 60623 -1.104, 60014 -1.104 | 16.53% | 33.46% | 89.36% | | `lamoracle=1,rgam=2 (oracle diagnostic)` | -0.0196 | 0.0163 | 1024 | 60210 -1.879, 60121 -1.868, 61012 -1.564, 60533 -1.461, 60180 -1.427 | 16.76% | 33.55% | 89.21% | | `sigoracle=1,lamoracle=1 (oracle diagnostic)` | +0.0382 | 0.0147 | 1024 | 60670 -1.615, 60681 -1.492, 60465 -1.283, 60665 -1.280, 60493 -1.213 | 16.51% | 33.48% | 89.37% | | `sig=1000,pinmin=0.999,jmax=0.006` | -0.7858 | 0.0269 | 1024 | 60795 -5.038, 60223 -4.507, 60957 -3.973, 60763 -3.595, 60375 -3.416 | 17.90% | 31.27% | 88.47% | | `rate=100,r0=0.33` | +0.0123 | 0.0096 | 1024 | 60014 -1.512, 60188 -1.493, 60523 -1.138, 60238 -1.080, 60855 -0.983 | 16.58% | 33.28% | 89.32% | | `rate=200,r0=0.33` | +0.0156 | 0.0093 | 1024 | 60014 -1.508, 60915 -0.912, 60855 -0.885, 60523 -0.863, 60482 -0.859 | 16.58% | 33.27% | 89.32% | | `rate=1000,r0=0.33` | +0.0123 | 0.0088 | 1024 | 60523 -1.220, 60238 -1.123, 60482 -0.924, 60014 -0.865, 60770 -0.772 | 16.58% | 33.27% | 89.32% | | `rate=500,r0=0.33,rgam=1.5` | +0.0219 | 0.0123 | 1024 | 60961 -1.411, 60766 -1.110, 60014 -0.983, 60238 -0.927, 60763 -0.913 | 16.58% | 33.28% | 89.33% | | `lamoracle=1,rgam=0.5 (oracle diagnostic)` | +0.0119 | 0.0117 | 1024 | 60014 -1.157, 60092 -1.073, 60898 -1.046, 60002 -0.956, 60122 -0.900 | 16.54% | 33.37% | 89.35% | | `sig=5000,pinmin=0.999` | -0.0634 | 0.0133 | 1024 | 60130 -1.724, 60550 -1.662, 60790 -1.615, 60783 -1.569, 60578 -1.553 | 16.70% | 33.25% | 89.25% | | `sig=20000` | +0.0026 | 0.0081 | 1024 | 60014 -1.327, 60822 -0.921, 60204 -0.891, 60689 -0.873, 60702 -0.819 | 16.63% | 33.28% | 89.29% | | `sig=5000,rate=500,r0=0.33` | +0.0026 | 0.0115 | 1024 | 60335 -1.563, 60018 -1.268, 60365 -1.183, 60304 -1.121, 60124 -1.104 | 16.59% | 33.29% | 89.32% | Baseline on this set: misclass 16.64%, labeled arb 33.27%, true arb 34.47%, accuracy 89.29%. Notes on the choose set: - **The sigma estimate hurts at every prior weight.** Pin-based sigma has rms error 0.56 to 0.59 bps against the true sigma (correlation about 0.5). The fixed 9.45 has rms error 0.36 bps, because the run range 8.82..10.08 bps is narrow (uniform sd 0.36). Its mean is close to unbiased (9.50 vs 9.46 bps at W=1000). The excess variance comes from misclassified and soft pins. With `pinmin=0.999`, fewer pins means more noise: -0.063. The `jmax=0.006` setting was a design error: skipping a pin also drops its Δt, so long gaps with large real moves are removed, which biases sigma down (rms 3.6 bps). That cost -0.79 and raised misclassification to 17.9%. - **Even true sigma is worth only +0.013 (se 0.010).** True lam is worth +0.028 (se 0.014), and both together +0.038 (se 0.015). The whole idea is therefore worth at most about 0.04 of the 2.7 at stake. - **The realizable rate scaling** gives +0.007 to +0.022. The highest-mean setting, `rate=500,r0=0.33,rgam=1.5` (+0.0219 se 0.0123), was chosen for the report run. Choosing it from about 10 realizable settings on the choose set biases its choose-set number upward. The report set below is the clean test. ## Report-set result (seeds 10000..12047, n=2048, run once) Final version: `real_rs.py pol_mid.json plain:soft=1,rate=500,r0=0.33,rgam=1.5`, paired against unchanged `real.py pol_mid.json plain:soft=1`. | | mean diff | se | n | worst 5 (seed diff) | |---|---|---|---|---| | **chosen: `rate=500,r0=0.33,rgam=1.5`** | **+0.0235** | **0.0083** | **2048** | 11701 -3.308, 11699 -2.228, 11570 -1.742, 10949 -1.198, 10866 -1.164 | | diagnostic, not a policy: `sigoracle=1,lamoracle=1` | +0.0133 | 0.0105 | 2048 | 11701 -3.473, 11549 -2.657, 11062 -2.341, 11225 -1.885, 11519 -1.824 | Against HA on the same seeds: the baseline is +2.828 (se 0.041) and the chosen version is +2.852 (se 0.041). On this set the realizable version beats the "oracle". The oracle's `RHO*(lam/0.8)` is only a crude mapping from lam to the retail weight, while the empirical rate measures the routed retail-first rate itself, which also depends on our quotes and the rival. So the oracle rows are not a strict ceiling for the rate half of the idea. ## Classifier stats (report set 10000..12047) Ground truth is the replica's `arb` flag, which `replica2.run` passes to every `on_trade` callback. An arb can only be the first own trade of a step, and every later trade is labeled retail, so later trades are always classified correctly. | | own swaps | labeled arb | true arb | accuracy, all swaps | first-of-step trades | misclassified, first trades | |---|---|---|---|---|---|---| | baseline `real.py plain:soft=1` | 22,135,561 | 33.20% | 34.38% | 89.35% | 14,225,904 | 16.57% | | chosen `rate=500,r0=0.33,rgam=1.5` | 22,137,625 | 33.17% | 34.38% | 89.38% | 14,225,557 | 16.52% | Per-run quantities: the retail-first rate estimate has mean 0.331 and correlates 0.873 with the run's true lam. On the choose set, the pin-based sigma estimate (W=1000) has rms error 0.56 bps and correlation 0.51 with the true sigma. ## Verdict **Improves, but by a negligible amount: +0.0235 se 0.0083 (n=2048, about 2.8 se) on the report set.** The classifier error moves from 16.57% to 16.52%. That is about 1% of the 2.7 the README attributes to the arb/retail call. The sigma half of the idea is neutral to harmful: the best weight gives +0.003, and any weight that lets the data move sigma loses money. Even oracle per-run parameters are worth only +0.01 to +0.04. Per-run sigma and order rate are not where the 2.7 is. The 16% error is structural, and comes from overlap between arb readings and retail fill sizes near the band edge, not from run-parameter mismatch. The gain is probably not worth contract complexity: the rate half needs one counter and one `pow` per new step, and the sigma half should not be ported. ## Next 1. **The router likelihood per price hypothesis**, the third untried idea. A retail fill's size and the rival's state constrain the price jointly. Scoring each lattice price cell by the probability the router sends this exact fill to us attacks the overlap directly, which per-run parameters cannot do. 2. **Quotes against the whole lattice posterior**, the first untried idea. When pi is near 0.5, the posterior is bimodal (a pin plus a cut prior), and mean and sd misdescribe it. Choosing quotes per mode would recover part of the loss without fixing the classifier. 3. **Optionally, keep the rate scaling.** If the port has spare gas, add the rate scaling with `rgam` near 1 to 1.5 and test it again on the engine. The expected value is about +0.02, so it is low priority. ## Problems - The machine had no `python3` on PATH. I symlinked the provided CPython 3.12.14 into a scratch bin directory. Otherwise the replica runs as the README says, with `python3 -I` and no dependencies. - `real.py`'s main only scores seeds from 10000 and prints vs HA, which fails off the calibration set. This is why the scratch paired harness exists, and why `real_rs.py` adds `SEED0` and a guarded vs-HA print. - The `jmax` variant had a design bug (it drops Δt along with the skipped jump). It is reported as tried and is not used. - The chosen setting was picked on the choose set from about 10 realizable settings. Only the report-set number is unbiased. Nothing here ran on the Rust engine.