diff --git a/Cargo.lock b/Cargo.lock index 925a217..13b51cc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,49 +347,6 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" -[[package]] -name = "axum" -version = "0.8.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" -dependencies = [ - "axum-core", - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "serde_core", - "sync_wrapper", - "tower", - "tower-layer", - "tower-service", -] - -[[package]] -name = "axum-core" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" -dependencies = [ - "bytes", - "futures-core", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "sync_wrapper", - "tower-layer", - "tower-service", -] - [[package]] name = "aya" version = "0.13.1" @@ -1741,12 +1698,6 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" -[[package]] -name = "httpdate" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" - [[package]] name = "hyper" version = "1.8.1" @@ -1761,7 +1712,6 @@ dependencies = [ "http", "http-body", "httparse", - "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -1787,19 +1737,6 @@ dependencies = [ "webpki-roots 1.0.6", ] -[[package]] -name = "hyper-timeout" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" -dependencies = [ - "hyper", - "hyper-util", - "pin-project-lite", - "tokio", - "tower-service", -] - [[package]] name = "hyper-tls" version = "0.6.0" @@ -2202,12 +2139,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "memchr" version = "2.8.0" @@ -2603,26 +2534,6 @@ dependencies = [ "num", ] -[[package]] -name = "pin-project" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" -dependencies = [ - "pin-project-internal", -] - -[[package]] -name = "pin-project-internal" -version = "1.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3444,8 +3355,9 @@ checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] name = "simulator-api" -version = "0.17.0" -source = "git+https://github.com/nitro-svm/nitro-stream?rev=d157f1b9#d157f1b943bf541093585a0b39419aa624cb0353" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72535c2e6202b0cd93d5dc9cb1c76fb479f3962d9c73e327be1e7faba65c2dcb" dependencies = [ "base64 0.22.1", "bon", @@ -3464,15 +3376,15 @@ dependencies = [ [[package]] name = "simulator-client" -version = "0.17.0" -source = "git+https://github.com/nitro-svm/nitro-stream?rev=d157f1b9#d157f1b943bf541093585a0b39419aa624cb0353" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bdcafc8c420bc4ca973d38e784c6d85729ac239f65c9fae30eadee7af948398" dependencies = [ "base64 0.22.1", "bincode 2.0.1", "bon", "chrono", "futures", - "http", "rand 0.10.2", "reqwest", "serde", @@ -3486,15 +3398,11 @@ dependencies = [ "solana-rpc-client-api", "solana-sdk-ids", "solana-transaction-status", - "spl-associated-token-account-interface", - "spl-token-2022-interface", "thiserror 2.0.18", "tokio", "tokio-retry", "tokio-tungstenite 0.24.0", "tokio-util", - "tonic", - "tower", "tracing", ] @@ -5742,37 +5650,6 @@ dependencies = [ "winnow", ] -[[package]] -name = "tonic" -version = "0.14.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" -dependencies = [ - "async-trait", - "axum", - "base64 0.22.1", - "bytes", - "flate2", - "h2", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-timeout", - "hyper-util", - "percent-encoding", - "pin-project", - "socket2", - "sync_wrapper", - "tokio", - "tokio-stream", - "tower", - "tower-layer", - "tower-service", - "tracing", - "zstd", -] - [[package]] name = "tower" version = "0.5.3" @@ -5781,15 +5658,11 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", - "indexmap 2.14.0", "pin-project-lite", - "slab", "sync_wrapper", "tokio", - "tokio-util", "tower-layer", "tower-service", - "tracing", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 2036168..e9cbec5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,8 +14,8 @@ rustls = { version = "0.23", default-features = false, features = ["ring"] } serde_json = "1" futures = "0.3" serde = { version = "1", features = ["derive"] } -simulator-api = { git = "https://github.com/nitro-svm/nitro-stream", rev = "d157f1b9" } -simulator-client = { git = "https://github.com/nitro-svm/nitro-stream", rev = "d157f1b9" } +simulator-api = "0.18.0" +simulator-client = "0.18.0" solana-account = "4.1.0" solana-account-decoder = "4.1.0" solana-address = "2.6.0" diff --git a/src/bin/counterfactual_flow/README.md b/src/bin/counterfactual_flow/README.md index abca453..3e691b6 100644 --- a/src/bin/counterfactual_flow/README.md +++ b/src/bin/counterfactual_flow/README.md @@ -89,17 +89,17 @@ per-hop `ammKey`s in any baseline run's `routePlan`. ## What we measured The 10,000-slot range above, against **BisonFi** (`8FnX3xo2yYw3EUE6w3nQA4GfXGS9wpK6oj3veJpbFzLo`), -a SOL/USDC prop AMM. The pair carries **14,513** re-quotable swap legs in that window — every -SOL↔USDC swap a supported router placed, not just the ones this venue won. +a SOL/USDC prop AMM. The pair carries **13,615** re-quotable swap legs in that window — every +SOL↔USDC swap an admitted router placed, not just the ones this venue won. -**Fresh, the venue won 1,516 of them. Ten slots slower, it won 2.** +**Fresh, the venue won 1,341 of them. Ten slots slower, it won none.** | | legs won | |---|---| -| the venue as it really ran | 1,516 | -| the same venue, 4s slower | 2 | +| the venue as it really ran | 1,341 | +| the same venue, 4s slower | 0 | -Ten slots of staleness costs this venue **99.9%** of the flow it captures. That is one run per +Ten slots of staleness costs this venue **all** of the flow it captures. That is one run per side; the router is not deterministic, so see [below](#reading-the-output-and-what-not-to-claim) for how many runs a smaller difference would need. @@ -110,26 +110,31 @@ books: | | legs | |---|---| -| SOL/USDC legs re-quoted | 14,513 | -| **…that routed through the venue on chain** | **2,595** | -| re-quoted fresh → router picks the venue | 1,516 | -| re-quoted 10 slots stale → router picks the venue | 2 | +| SOL/USDC legs re-quoted | 13,615 | +| **…that routed through the venue on chain** | **2,595** † | +| re-quoted fresh → router picks the venue | 1,341 | +| re-quoted 10 slots stale → router picks the venue | 0 | + +† The on-chain figure is carried over from an earlier measurement. The run JSONL records only the +route Metis chose, never the one the original took, so this row cannot be re-derived from the +output of the runs above. Two things to read here, and the first one bounds what any of this can say. **This only sees aggregator flow.** Swaps are detected by decoding router instructions — Jupiter, -OKX, Titan and DFlow. A taker hitting your pool directly, through an unsupported aggregator, or via -private flow never enters the sample at all, so the totals here are a slice of your book, not your -book. +OKX, Titan and DFlow — and of those, only the venues the run admits are re-quoted, which is Jupiter +alone unless `--reroute-venues` names more. A taker hitting your pool directly, through an +unsupported aggregator, or via private flow never enters the sample at all, so the totals here are a +slice of your book, not your book. -**The baseline is close to history, but it is not a replay of it.** The venue held 2,595 legs on -chain and the fresh re-quote gives it 1,516 — 58%. It is not the same 58%, either: it keeps 660 of -the legs it really won and takes 856 it did not, because the re-quote runs against a different -router than the one that originally placed each swap, with its own market coverage and quote timing. -Read the baseline as *"what this router would do"*, not *"what happened"*. +**The baseline is close to history, but it is not a replay of it.** The fresh re-quote gives the +venue 1,341 legs against the 2,595 it held on chain — and they are not the same legs, because the +re-quote runs against a different router than the one that originally placed each swap, with its own +market coverage and quote timing. Read the baseline as *"what this router would do"*, not *"what +happened"*. **Which is why the comparison is baseline-vs-modified, never modified-vs-L1.** Both arms run through -the same router with the same disagreements, so those cancel; what remains is your change. 1,516 → 2 +the same router with the same disagreements, so those cancel; what remains is your change. 1,341 → 0 is not a haircut, it is elimination. ## What it means for a maker @@ -175,7 +180,7 @@ Rows 1–5 are exact: every way of splitting ten runs into two disjoint arms of are estimated by resampling, since two disjoint arms of six would need twelve runs. So a single run is not a precise measurement of *how much* flow moved, even though the example above -separates so far — 1,516 against 2 — that one run per side already carries the direction. If you ever +separates so far — 1,341 against 0 — that one run per side already carries the direction. If you ever see a minor difference between the baseline and the override, run both sides several times to take run-to-run variance out of the answer. diff --git a/src/bin/counterfactual_flow/main.rs b/src/bin/counterfactual_flow/main.rs index bc39e29..437fd75 100644 --- a/src/bin/counterfactual_flow/main.rs +++ b/src/bin/counterfactual_flow/main.rs @@ -32,7 +32,7 @@ use anyhow::{Context, Result, anyhow, bail, ensure}; use clap::{Args, Parser, Subcommand}; use serde::{Deserialize, Serialize}; use simulator_api::{ - AccountData, AccountModifications, MintPair, RerouteFilter, RerouteStatsReport, + AccountData, AccountModifications, MintPair, RerouteFilter, RerouteStatsReport, RerouteVenues, }; use simulator_client::{ AccountDiffNotification, Continue, CreateSession, ManagedBacktestSession, ManagedEvent, @@ -173,6 +173,20 @@ struct RunArgs { #[arg(long, value_parser = parse_pair)] filter_pair: Vec, + /// Leave swaps whose L1 transaction failed out of the run entirely. They are tracked as + /// their own population rather than mixed into the funnel, but excluding them keeps the + /// rows to flow that actually filled. + #[arg(long, default_value_t = false)] + skip_l1_failures: bool, + + /// Also re-quote arbitrage cycles (same input and output mint), stitched leg by leg. + #[arg(long, default_value_t = false)] + circular_arbs: bool, + + /// Venues whose swaps to re-quote, comma-separated (server default: jupiter alone). + #[arg(long)] + reroute_venues: Option, + /// Reroute notifications JSONL output. #[arg(long, default_value = "reroute-out.jsonl")] out: PathBuf, @@ -252,6 +266,9 @@ struct RunConfig { filter: Option, venue_label: Option, jsonl_out: Option, + detect_failed_l1_swaps: bool, + circular_arbs: bool, + reroute_venues: Option, } /// One line of the `capture` JSONL. @@ -260,6 +277,10 @@ struct RunConfig { struct CaptureRow { slot: u64, account: AccountData, + /// The transaction that produced this state, when the diff named one. Replaying it at + /// a shifted slot is the alternative to posting the bytes. + #[serde(default, skip_serializing_if = "Option::is_none")] + signature: Option, } /// One line of the `run` reroute-notification JSONL. @@ -349,7 +370,9 @@ async fn capture(conn: ConnectionArgs, args: CaptureArgs) -> Result<()> { let mut session = ManagedBacktestSession::start(conn.websocket_url(), conn.api_key.clone(), create).await?; - let rows = Arc::new(Mutex::new(BTreeMap::::new())); + let rows = Arc::new(Mutex::new( + BTreeMap::)>::new(), + )); let conversion_error = Arc::new(Mutex::new(None::)); let first_pre_taken = Arc::new(AtomicBool::new(false)); let start_slot = args.range.start_slot; @@ -363,13 +386,14 @@ async fn capture(conn: ConnectionArgs, args: CaptureArgs) -> Result<()> { let error_sink = error_sink.clone(); let first_pre_taken = first_pre_taken.clone(); async move { + let signature = diff.signature.clone(); let insert = |slot, converted: Result, initial| match converted { Ok(account) => { let mut rows = sink.lock().unwrap(); if initial { - rows.entry(slot).or_insert(account); + rows.entry(slot).or_insert((account, None)); } else { - rows.insert(slot, account); + rows.insert(slot, (account, signature.clone())); } } Err(error) => { @@ -417,11 +441,15 @@ async fn capture(conn: ConnectionArgs, args: CaptureArgs) -> Result<()> { ); let captured = rows.len(); let mut out = io::BufWriter::new(fs::File::create(&args.out)?); - for (slot, account) in rows { + for (slot, (account, signature)) in rows { writeln!( out, "{}", - serde_json::to_string(&CaptureRow { slot, account })? + serde_json::to_string(&CaptureRow { + slot, + account, + signature + })? )?; } out.flush()?; @@ -482,7 +510,7 @@ fn build_shift_actions( .collect() } -fn load_capture(path: &Path) -> Result> { +fn load_capture_rows(path: &Path) -> Result> { let input = io::BufReader::new( fs::File::open(path).with_context(|| format!("opening capture {}", path.display()))?, ); @@ -490,13 +518,19 @@ fn load_capture(path: &Path) -> Result> { .lines() .enumerate() .map(|(index, line)| { - let row: CaptureRow = serde_json::from_str(&line?) - .with_context(|| format!("parsing {} line {}", path.display(), index + 1))?; - Ok((row.slot, row.account)) + serde_json::from_str(&line?) + .with_context(|| format!("parsing {} line {}", path.display(), index + 1)) }) .collect() } +fn load_capture(path: &Path) -> Result> { + Ok(load_capture_rows(path)? + .into_iter() + .map(|row| (row.slot, row.account)) + .collect()) +} + fn parse_pair(value: &str) -> Result { let (base, quote) = value .split_once(',') @@ -545,6 +579,9 @@ async fn run(conn: ConnectionArgs, args: RunArgs) -> Result { filter: filter_from(&args), venue_label: venue_label_of(&args).await?, jsonl_out: Some(args.out.clone()), + detect_failed_l1_swaps: !args.skip_l1_failures, + circular_arbs: args.circular_arbs, + reroute_venues: args.reroute_venues.clone(), }; let output = run_once(conn, config).await?; report_run("run", &output); @@ -560,7 +597,10 @@ async fn run_once(conn: ConnectionArgs, config: RunConfig) -> Result .start_slot(config.range.start_slot) .slot_count(config.range.slot_count) .reroute_order_flow(true) - .maybe_reroute_filter(config.filter) + .detect_failed_l1_swaps(config.detect_failed_l1_swaps) + .reroute_circular_arbs(config.circular_arbs) + .maybe_reroute_venues(config.reroute_venues.clone()) + .maybe_reroute_filter(config.filter.clone()) .replay_account_state(!config.range.no_replay) .disconnect_timeout_secs(900u16) .capacity_wait_timeout_secs(900u16) @@ -679,9 +719,8 @@ async fn run_once(conn: ConnectionArgs, config: RunConfig) -> Result fn report_run(label: &str, output: &RunOutput) { if let Some(stats) = &output.funnel { eprintln!( - "[{label}] reroute: {} detected | {} filtered -> {} rerouted -> {} simulated -> {} succeeded | {} requote-fail", + "[{label}] reroute: {} detected -> {} rerouted -> {} simulated -> {} succeeded | {} requote-fail", stats.swaps_detected, - stats.swaps_filtered, stats.swaps_rerouted, stats.swaps_simulated, stats.swaps_succeeded, @@ -716,6 +755,9 @@ async fn compare(conn: ConnectionArgs, args: CompareArgs) -> Result<()> { filter: filter_from(&args.run), venue_label: venue_label.clone(), jsonl_out: None, + detect_failed_l1_swaps: !args.run.skip_l1_failures, + circular_arbs: args.run.circular_arbs, + reroute_venues: args.run.reroute_venues.clone(), }; eprintln!("[baseline] running unmodified..."); @@ -958,6 +1000,9 @@ mod tests { lead, program_id: None, filter_pair: Vec::new(), + skip_l1_failures: false, + circular_arbs: false, + reroute_venues: None, out: PathBuf::from("out.jsonl"), }; assert_eq!(args(Some(10), None).shift(), Some(-10));