# NP2 fair-price error diagnosis — err-b ## method Pinned Rust engine; unchanged NP2 bytecode vs Vanilla, seeds 10200–10399, 10,000 steps each via run_batch. MatchRunner draws size/rate/sigma with default_rng(s); price seed s, retail s+1 [S4][s4] [S5][s5] [S6][s6]. Read committed EVM slot 5 after every NP2 print. Error = 10,000 ln(pHat/p); observations are count-weighted and SEs cluster by seed. Best thresholds maximize arb-positive F1 over both directions; a 100/100-seed split checks stability. ## results All numerical results below are local measurements generated by the [included patch and scripts](#code), not values claimed by an external page. Source citations identify the input contracts and simulation rules. “Print” means an executed trade in **NP2’s pool**; Vanilla remains the competing pool throughout. The full requested sample contains **200 seeds, 2,000,000 steps and 2,097,219 prints**. No burn-in was removed. NP2 was reconstructed from [TWO][s1] plus [NP’s interior-routing block][s2], with full SHA-256 `51f1c1a9d4999ee460eb8bbc66880da8074b173399a4f5d71f96671482e18ac0`. Solidity 0.8.24 used the simulator compiler’s optimizer (200 runs), viaIR and Paris target [S11][s11]. The base array begins at storage slot 0 [S8][s8]; the logger reads committed slot 5 directly, with no extra EVM calls and no strategy changes. Vanilla charges 30 bps [S10][s10]. | Local baseline check, each vs Vanilla | Seeds | Mean submission edge (Y) | SE across seeds (Y) | | --- | --- | --- | --- | | CH | 0–999 | 525.5258821780834 | 2.789426459 | | NP2 | 0–999 | 526.1155722459862 | 2.789600418 | | NP2 diagnosis block | 10200–10399 | 518.5391230265337 | 6.886279984 | The [CH input][s3] hash is `6dd68f77a70900319460215374143dbd5d924198450a46d9f5f91f6b1e3d35c3`. CH matches 525.525882178 to the requested precision; NP2 matches 526.1156. The instrumented and original engines give exactly equal NP2 and Vanilla edges on every diagnosis seed. The paired check below measures instrumentation neutrality; this assignment does not submit a new fee strategy. ```json {"n":200,"mean_diff":0.0,"se":0.0,"worst":0.0} ``` ### (a) Error immediately after each print | Kind | Prints | RMS after (bps ± 1 SE) | Signed mean after (bps ± 1 SE) | RMS before (bps) | pImplied RMS (bps) | | --- | --- | --- | --- | --- | --- | | Arbitrageur, first in step | 745,240 | 23.085 ± 0.059 | -0.016 ± 0.075 | 31.623 | 0.000 | | Retail, first in step | 618,042 | 22.116 ± 0.050 | -0.028 ± 0.050 | 16.293 | 67.141 | | Retail, later in step | 733,937 | 23.383 ± 0.057 | -0.028 ± 0.067 | 22.498 | 75.198 | Before/after errors use the same true price for that step. Positive error means pHat exceeds true price. The simulator executes at most one arbitrage trade per pool before retail [S6][s6] [S9][s9]; the trace contains **zero later arbitrage prints**. Arbitrage pImplied matches true price to floating-point precision (RMS 1.42e-12 bps), yet NP2’s 0.27 first-print update leaves 23.085 bps RMS. Retail first-print updates instead raise RMS from 16.293 to 22.116 bps. The adaptive innovation gate rejects 0 arbitrage first prints, 1,780 retail first prints and 2,570 later retail prints. The normalizer blend can still run after that gate rejects the ordinary update [S1][s1] [S2][s2]. ### (b) Error at the end of silent steps A silent step has no NP2 print, even if Vanilla trades. Silence length is the number of consecutive such steps through the current step. Initialization precedes step 0; a silent step 0 has length 1. Each silent step contributes once, including lengths 3, 4 and 5 separately inside their pooled bucket. | Silence length | Silent steps | RMS (bps ± 1 SE) | Signed mean (bps ± 1 SE) | | --- | --- | --- | --- | | 1 | 348,462 | 16.102 ± 0.050 | -0.040 ± 0.045 | | 2 | 149,472 | 16.646 ± 0.071 | -0.139 ± 0.060 | | 3-5 | 118,293 | 19.070 ± 0.140 | -0.333 ± 0.104 | | 6+ | 20,491 | 35.399 ± 2.353 | -4.640 ± 1.284 | There are 636,718 silent steps; the longest observed run is 63 steps. The 6+ tail has substantially greater RMS and a negative signed mean, with materially larger seed-cluster uncertainty. pHat is unchanged on these steps: this measures stale-estimate error as true price evolves, not a callback that runs while the pool is silent. No unconditional directional drift correction is established. ### (c) Arbitrageur share of prints | Population | All prints in population | Arbitrageur prints | Arbitrageur share | Retail share | | --- | --- | --- | --- | --- | | All NP2 prints | 2,097,219 | 745,240 | 35.535% | 64.465% | | First NP2 print in each active step | 1,363,282 | 745,240 | 54.665% | 45.335% | ### (d) Best one-threshold classifiers for first prints Positive class = arbitrageur. “Best” means maximum **F1**, exhaustively over every observed cutoff, testing both ≤ and ≥ with ties included. These pooled optima are descriptive fits on all 200 seeds. Precision/recall are proportions of prints, not trade volume. Cutoffs below are rounded for display; `analyze.py` emits their full precision. Features available at the callback: `tradeRatio = min(amountY / postTradeReserveY, 0.2)`; `g` = completed silent NP2 steps **before** this first print (0 for consecutive active steps); `pImplied = postSpot*(1-feeUsed)` for an AMM buy of X, or `postSpot/(1-feeUsed)` for an AMM sale. pHat and sigmaHat are taken **before** the update. NP2 stores sigmaHat as a fractional return [S1][s1]; therefore the literal requested `|pImplied-pHat|/sigmaHat` has price units. The final row also reports the dimensionless `|pImplied/pHat-1|/sigmaHat` to remove price-level dependence. No true-price information enters a classifier. | Feature | Predict arb when | Precision | Recall | F1 | True positives | False positives | | --- | --- | --- | --- | --- | --- | --- | | tradeRatio | <= 21.6762987481 bps | 57.230% | 97.537% | 0.721344 | 726,882 | 543,233 | | Prior silent steps g | <= 0 steps | 65.309% | 88.939% | 0.753139 | 662,811 | 352,079 | | Literal absolute deviation / sigmaHat | <= 125.019695802 Y/X | 65.835% | 92.340% | 0.768669 | 688,158 | 357,121 | | Relative deviation / sigmaHat | <= 1.22201446192 dimensionless | 66.239% | 92.070% | 0.770471 | 686,141 | 349,713 | | Always classify first print as arb | All | 54.665% | 100.000% | 0.706884 | 745,240 | 618,042 | Stability check: select the threshold on seeds **10200–10299**, freeze it, and evaluate on **10300–10399**. This is a separate fit from the pooled optimum above; there is no threshold tuning on the second half. | Feature | Frozen first-half cutoff | Second-half precision | Second-half recall | Second-half F1 | | --- | --- | --- | --- | --- | | tradeRatio | <= 22.6771077878 bps | 57.229% | 97.910% | 0.722360 | | Prior silent steps g | <= 0 steps | 65.501% | 88.804% | 0.753927 | | Literal absolute deviation / sigmaHat | <= 122.202369822 Y/X | 66.374% | 91.469% | 0.769267 | | Relative deviation / sigmaHat | <= 1.21936441741 dimensionless | 66.443% | 91.960% | 0.771463 | The best normalized-deviation cutoff still admits 349,713 retail first prints. Small size alone barely improves on labeling every first print arb. Consecutive active steps (`g=0`) are more arb-heavy; a gap is not evidence that the next print must be arbitrage. F1 is a classification objective, so these thresholds do not establish an optimal estimator gain or higher AMM edge. ### (e) Per-step arbitrage loss and retail take by estimation error Bucket by `|10,000 ln(pHat_start / truePrice_thisStep)|`, measured after the new true price is drawn and **before any trade**. Means include every step in the bucket, including zero-flow steps. Arb loss is positive arbitrageur profit; retail take is signed, fee-inclusive AMM trade edge at true price [S6][s6] [S7][s7]. Reported cash-flow bps use **initial NAV = 100 X × 100 Y/X + 10,000 Y = 20,000 Y**; 1 cash-flow bp = 2 Y. Thus net = retail take − arb loss. These are not fee rates or bps per traded notional. Intervals are <5, [5,15), [15,30], >30 bps. | Absolute pre-step error (bps) | Steps | Arb loss (NAV bps/step) | Retail take (NAV bps/step) | Net (NAV bps/step) | | --- | --- | --- | --- | --- | | <5 | 304,464 | 0.000269612 | 0.027641741 | 0.027372129 | | 5-15 | 599,283 | 0.000373286 | 0.028083034 | 0.027709748 | | 15-30 | 708,021 | 0.001214720 | 0.027498851 | 0.026284130 | | >30 | 388,232 | 0.005188304 | 0.026578574 | 0.021390270 | The >30 bps bucket is 19.412% of steps but accounts for 63.340% of all arbitrage loss. Its loss per step is 19.24× the <5 bps bucket. This is an association under NP2’s current trading path, not the gain achievable by removing that error. Summed retail take minus arb loss reproduces the engine’s submission edge. Additional breakdown supporting the next tests (same/opposite is relative to the stored direction of the first pImplied innovation, exactly as NP2 defines it [S1][s1]): | Later retail subset | Prints | RMS before (bps) | RMS after (bps) | | --- | --- | --- | --- | | Second, same innovation direction | 313,697 | 23.054 | 22.819 | | Second, opposite innovation direction | 236,254 | 21.514 | 23.593 | | Third and later | 183,986 | 22.770 | 24.054 | ## code The patch and complete experiment scripts follow. All experiment files live under the scratch checkout; the two named deliverables contain the report and source citations. The binary traces are generated locally and are not embedded in this bounded report. Their aggregate digest below identifies the exact 400 trace files used here. The scripts regenerate them from the pinned public inputs. Checks passed: setup’s StarterStrategy validation; both 1,000-seed reference scores; exact original/patched edge equality for both pools on all 200 diagnosis seeds; successful callbacks and fee-slot equality for all 2,097,219 prints; print/step counts and state continuity; direct-slot pHat and sigma recurrence checks; per-step cash-flow reconciliation; zero later arbitrage prints; independent brute-force threshold checks on tied synthetic inputs. NumPy was 2.5.4. The pHat recurrence check uses floating-point arithmetic only as a cross-check: reported pHat comes directly from the EVM. Measured validation output: ```json { "n_seeds": 200, "steps": 2000000, "trace_manifest_sha256": "78dda5dcddd8476dab44e87135bd6fd48e9860205ffd4ca6d8ffd413c6e533d1", "longest_silence": 63, "max_phat_reconstruction_bps": 4.440892098500627e-12, "max_sigma_reconstruction": 1.6653345369377348e-16, "max_edge_residual_y": 4.433786671143025e-12, "arb_later": 0, "instrumentation_paired": { "n": 200, "mean_diff": 0.0, "se": 0.0, "worst": 0.0 } } ``` Trace format: per seed, `SEED.trades.bin` consists of 22 little-endian f64 values per print in `TRADE_COLUMNS` order in `analyze.py`; `SEED.steps.bin` has 9 f64 values per step in `STEP_COLUMNS` order. `buy=1` means the **AMM buys X**; `arb=1` marks the arbitrage phase. The log includes actual spot before and after execution, amountY, true price, pHat before/after, sigma before/after, old timestamp/count, fees and callback success. Slot reads do not alter strategy state. Reproduction: create the pinned checkout, save the fenced files at their indicated paths, then run these commands from its root. Save `trace.patch` before applying it. Setup runs once; obtain the original-engine baseline and diagnosis scores before rebuilding the instrumented extension. The supplied setup downloads the simulator’s toolchain/dependencies; the measurement commands themselves need no network once inputs and bytecode caches exist. ```sh git clone https://github.com/benedictbrady/amm-challenge test/scratch/amm-challenge cd test/scratch/amm-challenge git checkout 6b6921f8d463314aa7c96234e1be25a25245f704 # Save the five fenced files below under scratch/. sh scratch/setup.sh . scratch/env.sh && python scratch/inputs.py . scratch/env.sh && python scratch/run.py baseline . scratch/env.sh && python scratch/run.py check . scratch/env.sh && git apply scratch/trace.patch . scratch/env.sh && maturin develop --release --uv --manifest-path amm_sim_rs/Cargo.toml . scratch/env.sh && python scratch/run.py trace . scratch/env.sh && python scratch/analyze.py ``` | File | SHA-256 | | --- | --- | | scratch/setup.sh | 8c5c335305666ad768d7ebbeeb6467c545c69d1079b8c32fe8c93479123a161d | | scratch/inputs.py | fbe672c0f3eed51971734e4e9e33f80ced0aa3b958e53565c4768ef366fa0ae7 | | scratch/trace.patch | 0997e5a3fee5dd8b6325bde7757e34eba78ba5a84cda8ceaebfacad9abfa6d88 | | scratch/run.py | fe0deff6b564ea3f8c810ac90415415cff84c023febf9bad60b3595ff82f842e | | scratch/analyze.py | 529292b03cf190e5b95cc2251b976c2cc7746825d6633a344da8bbb2d653b959 | `scratch/setup.sh` ```sh set -eu;R=$PWD/scratch;mkdir -p $R/tmp $R/bin $R/solcx echo "export TMPDIR=$R/tmp XDG_CACHE_HOME=$R/xc UV_CACHE_DIR=$R/uvc UV_PYTHON_INSTALL_DIR=$R/uvpy RUSTUP_HOME=$R/rustup CARGO_HOME=$R/cargo SOLCX_BINARY_PATH=$R/solcx VIRTUAL_ENV=$PWD/.venv PATH=$PWD/.venv/bin:$R/bin:$R/cargo/bin:\$PATH" >$R/env.sh;. $R/env.sh command -v uv >/dev/null||{ curl -LsSf https://github.com/astral-sh/uv/releases/latest/download/uv-x86_64-unknown-linux-gnu.tar.gz|tar --no-same-owner -xz -C $R/tmp;cp $R/tmp/uv-*/uv $R/bin/;} cc --version >/dev/null 2>&1||{ curl -Ls https://micro.mamba.pm/api/micromamba/linux-64/latest -o $R/tmp/mm.tbz;python3 -c "import sys,tarfile;tarfile.open(sys.argv[1]).extract('bin/micromamba',sys.argv[2])" $R/tmp/mm.tbz $R;MAMBA_ROOT_PREFIX=$R/mamba $R/bin/micromamba create -yq -p $R/cenv -c conda-forge gcc;ln -sf $(ls $R/cenv/bin/*gcc|head -n1) $R/bin/cc;} command -v cargo >/dev/null||{ curl -sSf https://sh.rustup.rs|sh -s -- -y --profile minimal --no-modify-path;} uv venv --python 3.12 .venv;uv pip install -e . maturin maturin develop --release --uv --manifest-path amm_sim_rs/Cargo.toml amm-match validate contracts/src/StarterStrategy.sol ``` `scratch/inputs.py` ```python """Reconstruct the pinned strategy; fetched files remain in the scratch checkout.""" import hashlib import json from pathlib import Path from urllib.request import urlopen OUT = Path(__file__).resolve().parents[2] / 'downloads' OUT.mkdir(exist_ok=True) TWO = 'https://api.imd.fun/artifacts/528a6da78583c6142644a552c86670f9d25c20e39e92f4de44deae05ddc8cecd' NP = 'https://api.imd.fun/artifacts/da313379a2e69e9286668865e06b18dfcae25732c82b934b0fd9c2ef0d8c2ff5' CH = 'https://raw.githubusercontent.com/ChengHua926/amm-challenge/d34a5199c59a4c44d5336a25f1436b4ae1f21104/contracts/src/Strategy.sol' for name,url in [('two.json',TWO),('np.json',NP),('CH.sol',CH)]: (OUT/name).write_bytes(urlopen(url).read()) two=json.loads((OUT/'two.json').read_text())['code'] np=json.loads((OUT/'np.json').read_text())['code'] marker=' uint256 tradeRatio =' block=np[np.index(' // Interior retail routing'):np.index(marker)] assert two.count(marker)==1 code=two.replace(marker,block+marker,1) (OUT/'NP2.sol').write_text(code) assert hashlib.sha256(code.encode()).hexdigest()=='51f1c1a9d4999ee460eb8bbc66880da8074b173399a4f5d71f96671482e18ac0' assert hashlib.sha256((OUT/'CH.sol').read_bytes()).hexdigest()=='6dd68f77a70900319460215374143dbd5d924198450a46d9f5f91f6b1e3d35c3' ``` `scratch/trace.patch` ```diff diff --git a/amm_sim_rs/src/amm/cfmm.rs b/amm_sim_rs/src/amm/cfmm.rs index 02e6645..2cc7ee1 100644 --- a/amm_sim_rs/src/amm/cfmm.rs +++ b/amm_sim_rs/src/amm/cfmm.rs @@ -38,6 +38,9 @@ pub struct TradeResult { /// Uses Uniswap V3/V4 fee model where fees are collected separately /// (not reinvested into liquidity). pub struct CFMM { + pub trace_enabled: bool, + pub trace_arb: bool, + pub trace: Vec<[f64; 21]>, /// Strategy name pub name: String, /// EVM strategy for fee decisions @@ -57,10 +60,17 @@ pub struct CFMM { } impl CFMM { + pub fn trace_slot(&self, index: u64) -> u128 { + self.strategy.trace_slot(index) + } + /// Create a new CFMM with the given strategy and reserves. pub fn new(strategy: EVMStrategy, reserve_x: f64, reserve_y: f64) -> Self { let name = strategy.name().to_string(); Self { + trace_enabled: false, + trace_arb: false, + trace: Vec::new(), name, strategy, reserve_x, @@ -198,6 +208,7 @@ impl CFMM { /// Execute trade where AMM buys X (trader sells X for Y). pub fn execute_buy_x(&mut self, amount_x: f64, timestamp: u64) -> Option { + let spot_before = self.spot_price(); let (y_out, fee_amount) = self.quote_buy_x(amount_x); if y_out <= 0.0 { return None; @@ -219,7 +230,7 @@ impl CFMM { ); // Update fees from strategy - self.update_fees(&trade_info); + self.update_fees(&trade_info, spot_before); Some(TradeResult { trade_info, @@ -229,6 +240,7 @@ impl CFMM { /// Execute trade where AMM sells X (trader buys X with Y). pub fn execute_sell_x(&mut self, amount_x: f64, timestamp: u64) -> Option { + let spot_before = self.spot_price(); let (total_y, fee_amount) = self.quote_sell_x(amount_x); if total_y <= 0.0 { return None; @@ -250,7 +262,7 @@ impl CFMM { ); // Update fees from strategy - self.update_fees(&trade_info); + self.update_fees(&trade_info, spot_before); Some(TradeResult { trade_info, @@ -260,6 +272,7 @@ impl CFMM { /// Execute trade where trader pays Y to receive X. pub fn execute_buy_x_with_y(&mut self, amount_y: f64, timestamp: u64) -> Option { + let spot_before = self.spot_price(); let (x_out, fee_amount) = self.quote_x_for_y(amount_y); if x_out <= 0.0 { return None; @@ -281,7 +294,7 @@ impl CFMM { ); // Update fees from strategy - self.update_fees(&trade_info); + self.update_fees(&trade_info, spot_before); Some(TradeResult { trade_info, @@ -290,10 +303,34 @@ impl CFMM { } /// Update fees from strategy after a trade. - fn update_fees(&mut self, trade_info: &TradeInfo) { - if let Ok((bid_fee, ask_fee)) = self.strategy.after_swap(trade_info) { + fn update_fees(&mut self, trade_info: &TradeInfo, spot_before: f64) { + let before = if self.trace_enabled { + let fee = if trade_info.is_buy { self.current_fees.bid_fee } + else { self.current_fees.ask_fee }; + Some((fee.to_f64(), self.trace_slot(5), self.trace_slot(6), + self.trace_slot(2), self.trace_slot(10), self.trace_slot(11), + self.trace_slot(12))) + } else { None }; + let result = self.strategy.after_swap(trade_info); + let ok = result.is_ok(); + if let Ok((bid_fee, ask_fee)) = result { self.current_fees = FeeQuote::new(bid_fee.clamp_fee(), ask_fee.clamp_fee()); } + if let Some((fee, phat, sigma, ts, count, anchor, dir)) = before { + let fee_match = self.trace_slot(0) == self.current_fees.bid_fee.raw() as u128 + && self.trace_slot(1) == self.current_fees.ask_fee.raw() as u128; + self.trace.push([ + trade_info.timestamp as f64, self.trace_arb as u8 as f64, + trade_info.is_buy as u8 as f64, + trade_info.amount_x.to_f64(), trade_info.amount_y.to_f64(), + spot_before, self.spot_price(), trade_info.reserve_y.to_f64(), fee, + phat as f64 / 1e18, self.trace_slot(5) as f64 / 1e18, + sigma as f64 / 1e18, self.trace_slot(6) as f64 / 1e18, + ts as f64, count as f64, self.current_fees.bid_fee.to_f64(), + self.current_fees.ask_fee.to_f64(), ok as u8 as f64, + anchor as f64 / 1e18, dir as f64, fee_match as u8 as f64, + ]); + } // On error, keep current fees } diff --git a/amm_sim_rs/src/evm/strategy.rs b/amm_sim_rs/src/evm/strategy.rs index 1d06af1..3821255 100644 --- a/amm_sim_rs/src/evm/strategy.rs +++ b/amm_sim_rs/src/evm/strategy.rs @@ -59,6 +59,13 @@ pub struct EVMStrategy { } impl EVMStrategy { + /// Read committed storage without executing or changing the strategy. + pub fn trace_slot(&self, index: u64) -> u128 { + self.db.accounts.get(&STRATEGY_ADDRESS) + .and_then(|a| a.storage.get(&U256::from(index))) + .copied().unwrap_or(U256::ZERO).to::() + } + /// Create a new EVM strategy from compiled bytecode. pub fn new(bytecode: Vec, default_name: String) -> Result { let mut strategy = Self { diff --git a/amm_sim_rs/src/simulation/engine.rs b/amm_sim_rs/src/simulation/engine.rs index 6834c43..aee5e8b 100644 --- a/amm_sim_rs/src/simulation/engine.rs +++ b/amm_sim_rs/src/simulation/engine.rs @@ -1,6 +1,12 @@ //! Main simulation engine. use std::collections::HashMap; +use std::fs::File; +use std::io::{BufWriter, Write}; + +fn trace_row(w: &mut BufWriter, row: &[f64]) { + for v in row { w.write_all(&v.to_le_bytes()).expect("trace write"); } +} use crate::amm::CFMM; use crate::evm::EVMStrategy; @@ -118,6 +124,14 @@ impl SimulationEngine { // Store AMMs in a Vec for easier mutable access let mut amms = vec![amm_submission, amm_baseline]; + // Optional host-only instrumentation: contracts and market draws are untouched. + let mut trace = std::env::var_os("NP2_TRACE_DIR").map(|dir| { + let dir = std::path::PathBuf::from(dir); + let trades = File::create(dir.join(format!("{seed}.trades.bin"))).expect("trace create"); + let steps = File::create(dir.join(format!("{seed}.steps.bin"))).expect("trace create"); + (BufWriter::new(trades), BufWriter::new(steps)) + }); + amms[0].trace_enabled = trace.is_some(); let names = vec![submission_name.clone(), baseline_name.clone()]; // Track cumulative volumes @@ -136,10 +150,15 @@ impl SimulationEngine { for t in 0..self.config.n_steps { // 1. Generate new fair price let fair_price = price_process.step(); + let phat_start = if trace.is_some() { amms[0].trace_slot(5) as f64 / 1e18 } else { 0.0 }; + amms[0].trace_arb = true; + let mut arb_loss = 0.0; + let mut retail_take = 0.0; // 2. Arbitrageur extracts profit from each AMM for amm in amms.iter_mut() { if let Some(arb_result) = arbitrageur.execute_arb(amm, fair_price, t as u64) { + if arb_result.amm_name == submission_name { arb_loss += arb_result.profit; } *arb_volume_y.get_mut(&arb_result.amm_name).unwrap() += arb_result.amount_y; let entry = edges.entry(arb_result.amm_name).or_insert(0.0); // AMM edge is the negative of arbitrageur profit at true price @@ -148,6 +167,7 @@ impl SimulationEngine { } // 3. Retail orders arrive and get routed + amms[0].trace_arb = false; let orders = retail_trader.generate_orders(); let routed_trades = router.route_orders(&orders, &mut amms, fair_price, t as u64); for trade in routed_trades { @@ -157,11 +177,23 @@ impl SimulationEngine { } else { trade.amount_y - trade.amount_x * fair_price }; + if trade.amm_name == submission_name { retail_take += trade_edge; } let entry = edges.entry(trade.amm_name).or_insert(0.0); *entry += trade_edge; } // 4. Capture step result and accumulate fees + if let Some((trades_out, steps_out)) = trace.as_mut() { + let count = amms[0].trace.len(); + for row in amms[0].trace.drain(..) { + trace_row(trades_out, &row); + trace_row(trades_out, &[fair_price]); + } + trace_row(steps_out, &[t as f64, fair_price, phat_start, + amms[0].trace_slot(5) as f64 / 1e18, count as f64, + arb_loss, retail_take, amms[0].spot_price(), + amms[0].trace_slot(6) as f64 / 1e18]); + } let step = capture_step( t, fair_price, @@ -181,6 +213,9 @@ impl SimulationEngine { } // Calculate final PnL (reserves + accumulated fees) + if let Some((a, b)) = trace.as_mut() { + a.flush().expect("trace flush"); b.flush().expect("trace flush"); + } let final_fair_price = price_process.current_price(); let mut pnl = HashMap::new(); ``` `scratch/run.py` ```python """Compile exact inputs and run canonical seed configs. No engine logic in Python.""" import argparse import hashlib import json import os from pathlib import Path import time import numpy as np import amm_sim_rs from amm_competition.competition.config import build_config, build_base_config, BASELINE_VARIANCE from amm_competition.competition.match import MatchRunner from amm_competition.evm.compiler import SolidityCompiler ROOT = Path(__file__).resolve().parent.parent OUT = ROOT / 'scratch' / 'measure' OUT.mkdir(exist_ok=True) def configs(start, n): result = [] for seed in range(start, start+n): rng = np.random.default_rng(seed) size = rng.uniform(19, 21) rate = rng.uniform(.6, 1) sigma = rng.uniform(.000882, .001008) result.append(build_config(seed=seed, gbm_sigma=sigma, retail_arrival_rate=rate, retail_mean_size=size)) return result def bytecode(name): cache = OUT / (name + '.bin') if not cache.exists(): if name == 'Vanilla': source = ROOT / 'contracts/src/VanillaStrategy.sol' contract = 'VanillaStrategy' else: source = ROOT.parent / 'downloads' / (name + '.sol') contract = 'Strategy' code = source.read_text() compiled = SolidityCompiler().compile(code, contract) assert compiled.success, compiled.errors cache.write_bytes(compiled.bytecode) print(name, hashlib.sha256(code.encode()).hexdigest(), flush=True) return list(cache.read_bytes()) def main(): p = argparse.ArgumentParser() p.add_argument('mode', choices=['baseline', 'trace', 'check']) p.add_argument('--start', type=int, default=10200) p.add_argument('--n', type=int, default=200) p.add_argument('--workers', type=int, default=8) args = p.parse_args() runner = MatchRunner(n_simulations=10, config=build_base_config(seed=0), n_workers=args.workers, variance=BASELINE_VARIANCE) for a, b in zip(configs(0, 10), runner._build_configs()): for key in ('seed','n_steps','gbm_sigma','retail_mean_size','retail_arrival_rate', 'initial_price','initial_x','initial_y','gbm_mu','gbm_dt', 'retail_size_sigma','retail_buy_prob'): assert getattr(a,key) == getattr(b,key), key vanilla = bytecode('Vanilla') names = ['CH','NP2'] if args.mode == 'baseline' else ['NP2'] start, n = (0, 1000) if args.mode == 'baseline' else (args.start, args.n) for name in names: code = bytecode(name) records = [] if args.mode == 'trace': trace_dir = OUT / 'trace' trace_dir.mkdir(exist_ok=True) os.environ['NP2_TRACE_DIR'] = str(trace_dir) else: os.environ.pop('NP2_TRACE_DIR', None) began = time.monotonic() for s in range(start, start+n, 20): batch = amm_sim_rs.run_batch(code, vanilla, configs(s,min(20,start+n-s)),args.workers) records.extend([{'seed': r.seed, 'edge': r.edges['submission'], 'vanilla_edge': r.edges['normalizer']} for r in batch.results]) print(name, s, 'elapsed',round(time.monotonic()-began,2),flush=True) a = np.array([r['edge'] for r in records]) result = dict(name=name, start=start, n=n, mean=float(a.mean()), se=float(a.std(ddof=1)/np.sqrt(n)), records=records) (OUT / f'{args.mode}_{name}.json').write_text(json.dumps(result,indent=2)+'\n') print({k:v for k,v in result.items() if k != 'records'},flush=True) if args.mode == 'baseline': expected = 525.525882178 if name == 'CH' else 526.1156 assert abs(a.mean()-expected) < (1e-8 if name == 'CH' else .00005) if __name__ == '__main__': main() ``` `scratch/analyze.py` ```python """Read little-endian f64 traces, validate them, and compute pooled diagnostics.""" import hashlib import json from pathlib import Path import numpy as np OUT = Path(__file__).resolve().parent / 'measure' TRADE_COLUMNS = ('step arb buy amount_x amount_y spot_before spot_after reserve_y ' 'fee_used phat_before phat_after sigma_before sigma_after last_ts count_before ' 'bid_after ask_after call_ok anchor_before dir_before fees_match_slots price').split() STEP_COLUMNS = ('step price phat_start phat_end prints arb_loss retail_take spot_end sigma_end').split() def stats(x): return [len(x), float(x.sum()), float(x@x)] def pooled(rows): v = np.asarray(rows, float) n, sx, sx2 = v.sum(axis=0) if not n: return dict(n=0, mean=None, rms=None) mean, rms = sx/n, np.sqrt(sx2/n) # Delta-method SE with simulation seeds as independent clusters. se_mean = np.std(v[:,1]-mean*v[:,0], ddof=1)*np.sqrt(len(v))/n se_rms = np.std(v[:,2]-rms*rms*v[:,0], ddof=1)*np.sqrt(len(v))/(2*rms*n) return dict(n=int(n), mean=mean, rms=rms, se_mean=se_mean, se_rms=se_rms) def metrics(y, pred): tp = int(np.sum(y & pred)); fp = int(np.sum(~y & pred)) fn = int(np.sum(y & ~pred)); tn = int(np.sum(~y & ~pred)) return dict(tp=tp,fp=fp,fn=fn,tn=tn, precision=tp/(tp+fp) if tp+fp else 0., recall=tp/(tp+fn), f1=2*tp/(2*tp+fp+fn), accuracy=(tp+tn)/len(y), balanced_accuracy=(tp/(tp+fn)+tn/(tn+fp))/2) def best_threshold(x, y): """Maximize pooled F1 over all unique cutoffs, in both directions, ties inclusive.""" order = np.argsort(x, kind='stable'); sx=x[order]; sy=y[order] vals, starts, counts = np.unique(sx,return_index=True,return_counts=True) ends=starts+counts-1; ctp=np.cumsum(sy); total=int(sy.sum()) candidates=[] for direction in ['<=','>=']: if direction == '<=': selected=ends+1; tp=ctp[ends] else: selected=len(x)-starts tp=total-np.r_[0,ctp][starts] f1=2*tp/(selected+total) k=int(np.argmax(f1)) candidates.append((float(f1[k]),direction,float(vals[k]))) _,direction,threshold=max(candidates,key=lambda c:c[0]) pred=x<=threshold if direction=='<=' else x>=threshold return dict(direction=direction,threshold=threshold,**metrics(y,pred)) def main(): labels=['arb_first','retail_first','retail_later'] a={k:[] for k in labels}; a_before={k:[] for k in labels} a_implied={k:[] for k in labels}; a_gate={k:0 for k in labels} b={k:[] for k in ['1','2','3-5','6+']} e={k:[] for k in ['<5','5-15','15-30','>30']} c=[]; first_rows=[]; max_recurrence=0.; max_sigma=0.; max_edge_residual=0. manifest=hashlib.sha256(); arbs_later=0; all_steps=0 reference={r['seed']:r for r in json.loads((OUT/'check_NP2.json').read_text())['records']} traced={r['seed']:r for r in json.loads((OUT/'trace_NP2.json').read_text())['records']} paired=[]; second_diag={k:[] for k in ['same','opposite','third_plus']}; longest_silence=0 for seed in range(10200,10400): paths=[OUT/'trace'/f'{seed}.{kind}.bin' for kind in ['trades','steps']] for path in paths: manifest.update(path.name.encode()+b'\0'+hashlib.sha256(path.read_bytes()).digest()) tr=np.fromfile(paths[0],dtype='T['last_ts']) | (T['count_before']==0) assert np.array_equal(first,contract_first) assert np.array_equal(np.bincount(t,minlength=10000),S['prints']) assert np.all(T['phat_after'][:-1]==T['phat_before'][1:]) unique,first_idx=np.unique(t,return_index=True) last_idx=np.r_[first_idx[1:]-1,len(t)-1] assert np.all(T['phat_before'][first_idx]==S['phat_start'][unique]) assert np.all(T['phat_after'][last_idx]==S['phat_end'][unique]) arbs_later+=int(np.sum(arb & ~first)) p=T['price']; pre=T['phat_before']; post=T['phat_after']; sigma=T['sigma_before'] implied=np.where(buy,T['spot_after']*(1-T['fee_used']),T['spot_after']/(1-T['fee_used'])) ret=np.abs(implied/pre-1) gate=ret<=np.maximum(.03,7.2*sigma) same=(T['dir_before']!=0) & ((T['dir_before']==1)==(implied>=pre)) second=(~first)&(T['count_before']==1) alpha=np.where(first,.27,np.where(second & same,.12,.06)) reconstructed=np.where(gate,(1-alpha)*pre+alpha*implied,pre) normal=np.where(buy,T['spot_after']*(1-T['fee_used'])/.997, T['spot_after']*.997/(1-T['fee_used'])) blend=(~first)&(T['fee_used']>.003) reconstructed=np.where(blend,.9*reconstructed+.1*normal,reconstructed) max_recurrence=max(max_recurrence,float(np.max(np.abs(1e4*np.log(reconstructed/post))))) reconstructed_sigma=np.where(first,.69*sigma+.31*np.minimum(ret,1/35),sigma) max_sigma=max(max_sigma,float(np.max(np.abs(reconstructed_sigma-T['sigma_after'])))) err=1e4*np.log(post/p); before=1e4*np.log(pre/p); implied_err=1e4*np.log(implied/p) groups={'arb_first':arb&first,'retail_first':~arb&first,'retail_later':~first&~arb} for k,m in groups.items(): a[k].append(stats(err[m])); a_before[k].append(stats(before[m])) a_implied[k].append(stats(implied_err[m])); a_gate[k]+=int(np.sum(~gate[m])) for k,m in {'same':second&same,'opposite':second&~same,'third_plus':(~first)&~second}.items(): second_diag[k].append([len(err[m]),float(np.sum(before[m]**2)),float(np.sum(err[m]**2)),int(blend[m].sum())]) # Completed silent steps before a first print; initialization is before step 0. gaps=unique-np.r_[-1,unique[:-1]]-1 fi=first_idx ratio=np.minimum(T['amount_y']/T['reserve_y'],.2)*1e4 first_rows.append(np.column_stack([np.full(len(fi),seed),arb[fi],ratio[fi],gaps, (np.abs(implied-pre)/sigma)[fi],(ret/sigma)[fi]])) c.append([len(tr),int(first.sum()),int(arb.sum())]) printed=S['prints']>0; silence=~printed last=np.maximum.accumulate(np.where(printed,np.arange(10000),-1)) g=np.arange(10000)-last longest_silence=max(longest_silence,int(g.max())) silent_err=1e4*np.log(S['phat_end']/S['price']) assert np.all(S['phat_start'][silence]==S['phat_end'][silence]) for k,m in {'1':g==1,'2':g==2,'3-5':(g>=3)&(g<=5),'6+':g>=6}.items(): b[k].append(stats(silent_err[silence&m])) start_error=np.abs(1e4*np.log(S['phat_start']/S['price'])) masks={'<5':start_error<5,'5-15':(start_error>=5)&(start_error<15), '15-30':(start_error>=15)&(start_error<=30),'>30':start_error>30} for k,m in masks.items(): e[k].append([int(m.sum()),float(S['arb_loss'][m].sum()),float(S['retail_take'][m].sum())]) edge=np.where(buy,T['amount_x']*p-T['amount_y'],T['amount_y']-T['amount_x']*p) residual=abs(float(edge.sum())-traced[seed]['edge']) residual=max(residual,abs(float(np.sum(S['retail_take']-S['arb_loss']))-traced[seed]['edge'])) assert np.allclose(np.bincount(t,weights=np.where(arb,-edge,0),minlength=10000),S['arb_loss'],atol=1e-10,rtol=0) assert np.allclose(np.bincount(t,weights=np.where(~arb,edge,0),minlength=10000),S['retail_take'],atol=1e-10,rtol=0) max_edge_residual=max(max_edge_residual,residual) paired.append(traced[seed]['edge']-reference[seed]['edge']) assert traced[seed]['vanilla_edge']==reference[seed]['vanilla_edge'] all_steps+=len(st) assert arbs_later==0 and max_recurrence<1e-8 and max_sigma<1e-12 and max_edge_residual<1e-7 assert np.all(np.asarray(paired)==0) counts=np.asarray(c).sum(axis=0) F=np.concatenate(first_rows); y=F[:,1].astype(bool); train=F[:,0]<10300 d={} for j,k in enumerate(['tradeRatio_bps','silent_steps','literal_deviation','relative_sigma_deviation'],2): pooled_best=best_threshold(F[:,j],y) fitted=best_threshold(F[train,j],y[train]) pred=(F[~train,j]<=fitted['threshold'] if fitted['direction']=='<=' else F[~train,j]>=fitted['threshold']) d[k]={'pooled_best':pooled_best,'fit_10200_10299':fitted, 'test_10300_10399':metrics(y[~train],pred)} ev={} for k,rows in e.items(): v=np.asarray(rows); n,loss,take=v.sum(axis=0) ev[k]=dict(n=int(n),arb_loss_y_step=loss/n,retail_take_y_step=take/n, arb_loss_bps_step=loss/n/2,retail_take_bps_step=take/n/2, net_bps_step=(take-loss)/n/2,total_arb_loss_y=loss,total_retail_take_y=take) result=dict( a={k:dict(after=pooled(a[k]),before=pooled(a_before[k]),implied=pooled(a_implied[k]), gate_rejections=a_gate[k]) for k in labels}, b={k:pooled(rows) for k,rows in b.items()}, c=dict(prints=int(counts[0]),first_prints=int(counts[1]),arbs=int(counts[2]), share_all=counts[2]/counts[0],share_first=counts[2]/counts[1]),d=d,e=ev, second={k:dict(zip(['n','before_sq','after_sq','normal_blends'],np.asarray(v).sum(axis=0))) for k,v in second_diag.items()}, checks=dict(n_seeds=200,steps=all_steps,trace_manifest_sha256=manifest.hexdigest(), longest_silence=longest_silence, max_phat_reconstruction_bps=max_recurrence,max_sigma_reconstruction=max_sigma, max_edge_residual_y=max_edge_residual,arb_later=arbs_later, instrumentation_paired=dict(n=200,mean_diff=float(np.mean(paired)), se=float(np.std(paired,ddof=1)/np.sqrt(200)),worst=float(min(paired)))), first_only_baseline=metrics(y,np.ones(len(y),bool))) (OUT/'analysis.json').write_text(json.dumps(result,indent=2)+'\n') print(json.dumps(result,indent=2)) if __name__=='__main__': main() ``` ## blocked ```json null ``` ## next These are untested mechanisms to evaluate against NP2, not claimed improvements. Tune within the allowed development seeds; confirm a frozen candidate once on a fresh block of at least 2,000 seeds starting at 2,000,000 or later before claiming an edge gain. 1. **Use a probabilistic first-print observation model.** Combine g, relative innovation/sigmaHat and tradeRatio into an arb probability, and mix low-noise arb and high-noise retail observation updates. Test lower retail-first RMS without losing arb-first accuracy, then paired edge. The motivation is table (a)’s exact arb pImplied versus 67.141 bps retail pImplied RMS, 45.335% retail contamination among first prints, and table (d)’s limited single-feature precision; a hard arb classification with increased gain would also amplify many retail observations. 2. **Filter later retail observations within the step.** Test a robust within-step innovation accumulator (for example, clip opposing outliers before blending the normalizer-derived observation), preserving separate handling for a second innovation in the first innovation’s direction. Require improvements in opposite-second and third-plus RMS and paired edge. Their observed RMS rises 21.514→23.593 and 22.770→24.054 bps, while same-direction seconds improve 23.054→22.819; this supports testing source-aware filtering instead of one common later-print gain. 3. **Track uncertainty through silence.** Add a separate process-variance state, propagate it by elapsed steps at the next callback, and combine that uncertainty with the retail-heavy posterior after gaps. Test error following 6+ silent steps and the >30 bps loss bucket, alongside paired edge. Table (b) shows RMS reaching 35.399 bps in long silence, while table (d) identifies g=0 as the more arb-heavy group. This motivates an elapsed-time uncertainty model with observation-type conditioning; the negative tail mean alone does not justify a fixed directional correction. [s1]: https://api.imd.fun/artifacts/528a6da78583c6142644a552c86670f9d25c20e39e92f4de44deae05ddc8cecd [s2]: https://api.imd.fun/artifacts/da313379a2e69e9286668865e06b18dfcae25732c82b934b0fd9c2ef0d8c2ff5 [s3]: https://raw.githubusercontent.com/ChengHua926/amm-challenge/d34a5199c59a4c44d5336a25f1436b4ae1f21104/contracts/src/Strategy.sol [s4]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/amm_competition/competition/match.py [s5]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/amm_competition/competition/config.py [s6]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/amm_sim_rs/src/simulation/engine.rs [s7]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/amm_sim_rs/src/amm/cfmm.rs [s8]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/contracts/src/AMMStrategyBase.sol [s9]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/amm_sim_rs/src/market/arbitrageur.rs [s10]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/contracts/src/VanillaStrategy.sol [s11]: https://raw.githubusercontent.com/benedictbrady/amm-challenge/6b6921f8d463314aa7c96234e1be25a25245f704/amm_competition/evm/compiler.py