Skip to main content

simulator_api/reroute_report/
benchmark.rs

1//! The reroute benchmark: how each re-quoted route did against the one L1 landed, as win/loss
2//! counts, volume-weighted bps, and the venues both sides routed through.
3//!
4//! Population is the adapter's to select — landed reroutes whose original also landed, with both
5//! realized amounts present and the two mints distinct. [`collect`] adds only what needs the whole
6//! population to decide: a signature seen twice is an ambiguous pairing and drops wholesale.
7//! ExactOut legs are measured input-side (`amount` is output-token-denominated and outputs tie by
8//! construction, so a win is paying less major input), which leaves a non-major-input ExactOut leg
9//! unpriceable — those are skipped.
10//!
11//! Two deliberate differences from the pre-typed-plan reader, both forced by the typing. Hop
12//! amounts arrive as strings, so a float-typed string like `"1.5e3"` truncates to an integer
13//! instead of voiding the plan — the JSON number/string distinction died with the DOM parse;
14//! genuinely unparseable text still voids. And an explicit `"inAmount": null` used to void the
15//! whole plan where an absent key merely contributed zero — an `Option<String>` cannot tell the
16//! two apart, so both now contribute zero.
17
18use std::{
19    cmp::Ordering,
20    collections::{BTreeMap, HashMap, HashSet, hash_map::Entry},
21};
22
23use serde::Serialize;
24use solana_address::Address;
25
26use super::{
27    fmt::{commas_u, median},
28    prices::Prices,
29    record::{LegRecord, SwapMode},
30};
31use crate::{mints, route_plan::RoutePlan};
32
33/// Venues under this USD exposure on both sides are dust, hidden from the table.
34pub const VENUE_DUST_USD: f64 = 1e4;
35/// Legs beyond this are treated as pricing artifacts, not routing outcomes.
36pub const OUTLIER_BPS: f64 = 2000.0;
37
38#[derive(Default, Debug)]
39pub struct LoadStats {
40    pub dropped_multi_row: usize,
41}
42
43/// Row order is preserved so float accumulation stays deterministic across runs. A signature seen
44/// more than once is an ambiguous multi-swap transaction — pairing is by signature — so its rows
45/// drop wholesale, and a skipped ExactOut row never claims a signature it could make ambiguous.
46pub fn collect(rows: Vec<LegRecord>) -> (Vec<LegRecord>, LoadStats) {
47    let mut kept: Vec<LegRecord> = Vec::new();
48    let mut first_row: HashMap<String, usize> = HashMap::new();
49    let mut dropped: HashSet<usize> = HashSet::new();
50    for row in rows {
51        if row.swap_mode == SwapMode::ExactOut && !mints::is_major(&row.input_mint) {
52            continue;
53        }
54        match first_row.entry(row.original_signature.clone()) {
55            Entry::Vacant(slot) => {
56                slot.insert(kept.len());
57                kept.push(row);
58            }
59            Entry::Occupied(slot) => {
60                dropped.insert(*slot.get());
61            }
62        }
63    }
64    let stats = LoadStats {
65        dropped_multi_row: dropped.len(),
66    };
67    (
68        kept.into_iter()
69            .enumerate()
70            .filter(|(index, _)| !dropped.contains(index))
71            .map(|(_, row)| row)
72            .collect(),
73        stats,
74    )
75}
76
77/// Total `inAmount` for `mint` across the plan's hops. Amounts are strings on the wire; an integer
78/// parse is preferred and a float truncates, matching how numeric JSON amounts were read before
79/// plans were typed. A string that parses as neither voids the plan.
80fn plan_input(plan: &RoutePlan, mint: &Address) -> Option<i64> {
81    let mint = mint.to_string();
82    let mut total = 0i64;
83    for hop in plan.hops() {
84        if hop.input_mint() != Some(mint.as_str()) {
85            continue;
86        }
87        // An absent inAmount contributes zero; a present one that reads as no number at all voids
88        // the whole plan.
89        total += match hop.swap_info.in_amount.as_deref() {
90            None => 0,
91            Some(amount) => amount
92                .parse::<i64>()
93                .ok()
94                .or_else(|| amount.parse::<f64>().ok().map(|float| float as i64))?,
95        };
96    }
97    (total > 0).then_some(total)
98}
99
100/// `[(label, weight)]`; sequential hops each carry the full amount, splits weighted by percent.
101/// Unlabeled hops keep their weight under `None` so dropped flow stays visible.
102fn venue_weights(plan: &RoutePlan) -> Vec<(Option<&str>, f64)> {
103    plan.hops()
104        .iter()
105        .map(|hop| (hop.label(), hop.percent as f64 / 100.0))
106        .collect()
107}
108
109/// One measured leg: how our route compared and what it was worth.
110pub struct Measurement {
111    pub outcome: Ordering,
112    pub l1_usd: f64,
113    pub our_usd: f64,
114}
115
116/// ExactIn compares delivered output (buys valued at spend); ExactOut compares major input paid
117/// for the identical output. `None` = unpriceable or a |bps| > [`OUTLIER_BPS`] dollar-outlier.
118pub fn measure(record: &LegRecord, prices: &Prices) -> Option<Measurement> {
119    match record.swap_mode {
120        SwapMode::ExactOut => measure_exact_out(record, prices),
121        SwapMode::ExactIn => measure_exact_in(record, prices),
122    }
123}
124
125fn measure_exact_out(record: &LegRecord, prices: &Prices) -> Option<Measurement> {
126    let mint = &record.input_mint;
127    let ours = plan_input(record.route_plan.as_ref()?, mint)?;
128    let l1 = plan_input(record.original_route_plan.as_ref()?, mint)?;
129    let l1_usd = prices.usd(mint, l1 as f64)?;
130    // Same output either way, so credit the input saved: l1 + (l1 - ours).
131    let our_usd = prices.usd(mint, (2 * l1 - ours) as f64)?;
132    if l1_usd <= 0.0 || (our_usd / l1_usd - 1.0).abs() * 1e4 > OUTLIER_BPS {
133        return None;
134    }
135    Some(Measurement {
136        outcome: l1.cmp(&ours),
137        l1_usd,
138        our_usd,
139    })
140}
141
142fn measure_exact_in(record: &LegRecord, prices: &Prices) -> Option<Measurement> {
143    let ours = record.realized_output_amount.unwrap_or(0) as i64;
144    let l1 = record.original_realized_output_amount.unwrap_or(0) as i64;
145    let bps = (ours as f64 / l1 as f64 - 1.0) * 1e4;
146    let scaled = |usd: f64| usd * (1.0 + bps / 1e4);
147    // Majors first, so a leg with a priced side is valued on that side rather than through the
148    // exotic map the last two arms fall back to.
149    let (l1_usd, our_usd) = if mints::is_major(&record.output_mint) {
150        (
151            prices.usd(&record.output_mint, l1 as f64),
152            prices.usd(&record.output_mint, ours as f64),
153        )
154    } else if mints::is_major(&record.input_mint) {
155        let spend = prices.usd(&record.input_mint, record.amount as f64);
156        (spend, spend.map(scaled))
157    } else if let Some(l1_usd) = prices.usd(&record.output_mint, l1 as f64) {
158        (Some(l1_usd), prices.usd(&record.output_mint, ours as f64))
159    } else {
160        let spend = prices.usd(&record.input_mint, record.amount as f64)?;
161        (Some(spend), Some(scaled(spend)))
162    };
163    let (l1_usd, our_usd) = (l1_usd?, our_usd?);
164    if l1_usd <= 0.0 || bps.abs() > OUTLIER_BPS {
165        return None;
166    }
167    Some(Measurement {
168        outcome: ours.cmp(&l1),
169        l1_usd,
170        our_usd,
171    })
172}
173
174#[derive(Default, Clone, Serialize)]
175pub struct SideTotals {
176    pub vol: f64,
177    pub net: f64,
178}
179
180#[derive(Default, Clone, Serialize)]
181pub struct VenueTotals {
182    pub us: f64,
183    pub l1: f64,
184    pub net: f64,
185}
186
187/// Accumulated metrics for one range or a cross-range aggregate.
188#[derive(Default, Clone, Serialize)]
189pub struct RangeReport {
190    pub n: u64,
191    pub better: u64,
192    pub same: u64,
193    pub worse: u64,
194    pub vol: f64,
195    pub net: f64,
196    pub vol_better: f64,
197    pub vol_same: f64,
198    pub vol_worse: f64,
199    pub bps: Vec<f64>,
200    pub majors: SideTotals,
201    pub exotic: SideTotals,
202    pub venues: BTreeMap<String, VenueTotals>,
203}
204
205impl RangeReport {
206    pub fn from_records(records: &[LegRecord], prices: &Prices) -> Self {
207        let mut report = Self::default();
208        for record in records {
209            if let Some(measurement) = measure(record, prices) {
210                report.record(record, &measurement);
211            }
212        }
213        report
214    }
215
216    fn record(&mut self, leg: &LegRecord, measurement: &Measurement) {
217        let delta = measurement.our_usd - measurement.l1_usd;
218        self.n += 1;
219        let (count, vol) = match measurement.outcome {
220            Ordering::Greater => (&mut self.better, &mut self.vol_better),
221            Ordering::Equal => (&mut self.same, &mut self.vol_same),
222            Ordering::Less => (&mut self.worse, &mut self.vol_worse),
223        };
224        *count += 1;
225        *vol += measurement.l1_usd;
226        self.vol += measurement.l1_usd;
227        self.net += delta;
228        if measurement.l1_usd > 0.0 {
229            self.bps.push(delta / measurement.l1_usd * 1e4);
230        }
231        let side = if mints::trades_only_majors(&leg.input_mint, &leg.output_mint) {
232            &mut self.majors
233        } else {
234            &mut self.exotic
235        };
236        side.vol += measurement.l1_usd;
237        side.net += delta;
238        self.attribute_venues(leg, measurement.l1_usd, delta);
239    }
240
241    /// Split the leg's USD across its route's venues, per-leg-normalized so every leg contributes
242    /// its USD exactly once, for both our winning route and L1's landed one.
243    fn attribute_venues(&mut self, leg: &LegRecord, l1_usd: f64, delta: f64) {
244        for (ours, plan) in [(true, &leg.route_plan), (false, &leg.original_route_plan)] {
245            let Some(plan) = plan else {
246                continue;
247            };
248            let weights = venue_weights(plan);
249            let total: f64 = weights.iter().map(|(_, weight)| weight).sum();
250            let total = if total == 0.0 { 1.0 } else { total };
251            for (label, weight) in weights {
252                let label = label.unwrap_or("(unlabeled)");
253                let entry = self.venues.entry(label.to_string()).or_default();
254                let share = weight / total;
255                if ours {
256                    entry.us += l1_usd * share;
257                    entry.net += delta * share;
258                } else {
259                    entry.l1 += l1_usd * share;
260                }
261            }
262        }
263    }
264
265    pub fn merge(&mut self, other: &Self) {
266        self.n += other.n;
267        self.better += other.better;
268        self.same += other.same;
269        self.worse += other.worse;
270        self.vol += other.vol;
271        self.net += other.net;
272        self.vol_better += other.vol_better;
273        self.vol_same += other.vol_same;
274        self.vol_worse += other.vol_worse;
275        self.bps.extend_from_slice(&other.bps);
276        self.majors.vol += other.majors.vol;
277        self.majors.net += other.majors.net;
278        self.exotic.vol += other.exotic.vol;
279        self.exotic.net += other.exotic.net;
280        for (venue, totals) in &other.venues {
281            let entry = self.venues.entry(venue.clone()).or_default();
282            entry.us += totals.us;
283            entry.l1 += totals.l1;
284            entry.net += totals.net;
285        }
286    }
287
288    pub fn vw_bps(&self) -> f64 {
289        if self.vol == 0.0 {
290            0.0
291        } else {
292            self.net / self.vol * 1e4
293        }
294    }
295
296    pub fn render(&self, name: &str) -> String {
297        if self.vol == 0.0 {
298            return format!("### {name}: no measurable legs\n");
299        }
300        let mut lines = self.summary_lines(name);
301        lines.push(String::new());
302        lines.extend(self.venue_table());
303        lines.push(String::new());
304        lines.join("\n")
305    }
306
307    fn summary_lines(&self, name: &str) -> Vec<String> {
308        let mut bps = self.bps.clone();
309        let (q1_at, q3_at) = (bps.len() / 4, 3 * bps.len() / 4);
310        let med = median(&mut bps);
311        let mut lines = vec![
312            format!("### {name}"),
313            format!(
314                "vw vs L1: **{:+.2} bps** (net {}$ on ${})",
315                self.vw_bps(),
316                commas_signed(self.net),
317                commas(self.vol)
318            ),
319            format!(
320                "b/s/w: {} / {} / {}  (by volume {:.1} / {:.1} / {:.1}%)",
321                commas_u(self.better),
322                commas_u(self.same),
323                commas_u(self.worse),
324                self.vol_better / self.vol * 100.0,
325                self.vol_same / self.vol * 100.0,
326                self.vol_worse / self.vol * 100.0
327            ),
328            format!(
329                "per-leg bps: median {med:+.2}, IQR [{:+.2}, {:+.2}]",
330                bps[q1_at], bps[q3_at]
331            ),
332        ];
333        for (label, side) in [
334            ("majors<->majors", &self.majors),
335            ("exotic-involved", &self.exotic),
336        ] {
337            if side.vol > 0.0 {
338                lines.push(format!(
339                    "{label}: {:+.2} bps (net {}$ on ${})",
340                    side.net / side.vol * 1e4,
341                    commas_signed(side.net),
342                    commas(side.vol)
343                ));
344            }
345        }
346        lines
347    }
348
349    fn venue_table(&self) -> Vec<String> {
350        let mut lines = vec![
351            "| Venue | our share | L1 share | vw bps (our legs) |".to_string(),
352            "|---|---|---|---|".to_string(),
353        ];
354        let total_us: f64 = self.venues.values().map(|e| e.us).sum::<f64>().max(1.0);
355        let total_l1: f64 = self.venues.values().map(|e| e.l1).sum::<f64>().max(1.0);
356        let mut ranked: Vec<_> = self.venues.iter().collect();
357        ranked.sort_by(|a, b| b.1.us.total_cmp(&a.1.us));
358        for (venue, e) in ranked {
359            if e.us < VENUE_DUST_USD && e.l1 < VENUE_DUST_USD {
360                continue;
361            }
362            let vw = if e.us == 0.0 { 0.0 } else { e.net / e.us * 1e4 };
363            lines.push(format!(
364                "| {venue} | {:.1}% | {:.1}% | {vw:+.2} |",
365                e.us / total_us * 100.0,
366                e.l1 / total_l1 * 100.0
367            ));
368        }
369        lines
370    }
371}
372
373fn commas(x: f64) -> String {
374    commas_u(x.round().abs() as u64)
375}
376
377fn commas_signed(x: f64) -> String {
378    format!("{}{}", if x < 0.0 { "-" } else { "+" }, commas(x))
379}
380
381#[cfg(test)]
382mod tests {
383    use rstest::rstest;
384
385    use super::*;
386    use crate::{
387        reroute_report::{
388            Samples,
389            testing::{hop, plan, record_of},
390        },
391        route_plan::RouteHop,
392    };
393
394    #[rstest]
395    #[case::rounds_up("+1,235", 1_234.6)]
396    #[case::negative("-1,235", -1_234.6)]
397    fn signed_commas_carry_the_sign(#[case] expected: &str, #[case] x: f64) {
398        assert_eq!(commas_signed(x), expected);
399    }
400
401    fn hop_of(input_mint: &Address, in_amount: u64) -> RouteHop {
402        hop(
403            None,
404            "Venue",
405            &input_mint.to_string(),
406            &mints::USDT.to_string(),
407            in_amount,
408        )
409    }
410
411    /// The wire carries amounts as text, which a fixture with a `u64` cannot express.
412    fn amount_hop(in_amount: Option<&str>) -> RouteHop {
413        let mut hop = hop_of(&mints::USDC, 0);
414        hop.swap_info.in_amount = in_amount.map(str::to_string);
415        hop
416    }
417
418    #[rstest]
419    #[case::integer_amount(Some("1000"), Some(1_000))]
420    #[case::float_amount_truncates(Some("1000.9"), Some(1_000))]
421    #[case::float_string_truncates(Some("1.5e3"), Some(1_500))]
422    #[case::unparseable_voids_the_plan(Some("abc"), None)]
423    #[case::an_absent_amount_alone_sums_to_nothing(None, None)]
424    fn plan_input_reads_integer_and_float_amounts(
425        #[case] amount: Option<&str>,
426        #[case] expected: Option<i64>,
427    ) {
428        let plan = plan(vec![amount_hop(amount)]).expect("a plan");
429        assert_eq!(plan_input(&plan, &mints::USDC), expected);
430    }
431
432    #[test]
433    fn plan_input_sums_only_the_asked_mints_hops() {
434        let plan = plan(vec![
435            hop_of(&mints::USDC, 300),
436            hop_of(&Address::default(), 999),
437            hop_of(&mints::USDC, 700),
438        ])
439        .expect("a plan");
440        assert_eq!(plan_input(&plan, &mints::USDC), Some(1_000));
441
442        // Deserialization bypasses `RoutePlan::new`, so a plan off the wire can still be empty.
443        let empty: RoutePlan = serde_json::from_str("[]").expect("an empty plan");
444        assert_eq!(plan_input(&empty, &mints::USDC), None);
445    }
446
447    #[test]
448    fn an_absent_amount_contributes_zero_without_voiding() {
449        let plan = plan(vec![amount_hop(None), hop_of(&mints::USDC, 700)]).expect("a plan");
450        assert_eq!(plan_input(&plan, &mints::USDC), Some(700));
451    }
452
453    /// Fixes SOL at $100 from the stream's own swaps; the stable legs below price off decimals
454    /// alone, so nothing else needs deriving.
455    fn prices() -> Prices {
456        let mut samples = Samples::default();
457        for n in 1..=3u64 {
458            samples.push(&LegRecord {
459                original_realized_output_amount: Some(n * 100_000_000),
460                ..record_of(mints::SOL, mints::USDC, n * 1_000_000_000, None, None, None)
461            });
462        }
463        samples.prices().expect("anchored")
464    }
465
466    fn stable_record(mode: SwapMode, ours: u64, l1: u64) -> LegRecord {
467        LegRecord {
468            swap_mode: mode,
469            realized_output_amount: Some(ours),
470            original_realized_output_amount: Some(l1),
471            route_plan: plan(vec![hop_of(&mints::USDC, ours)]),
472            original_route_plan: plan(vec![hop_of(&mints::USDC, l1)]),
473            ..record_of(mints::USDC, mints::USDT, 1_000_000, None, None, None)
474        }
475    }
476
477    #[test]
478    fn exact_out_credits_the_input_saved() {
479        let m = measure(
480            &stable_record(SwapMode::ExactOut, 999_000, 1_000_000),
481            &prices(),
482        )
483        .expect("measurable");
484        assert_eq!(m.outcome, Ordering::Greater);
485        assert!((m.l1_usd - 1.0).abs() < 1e-9);
486        assert!((m.our_usd - 1.001).abs() < 1e-9);
487    }
488
489    #[rstest]
490    #[case::win(1_010_000, 1_000_000, Some(Ordering::Greater))]
491    #[case::tie(1_000_000, 1_000_000, Some(Ordering::Equal))]
492    #[case::exactly_at_the_outlier_bound_stays(1_200_000, 1_000_000, Some(Ordering::Greater))]
493    #[case::past_the_outlier_bound_drops(1_210_000, 1_000_000, None)]
494    fn exact_in_measures_major_output_with_the_outlier_gate(
495        #[case] ours: u64,
496        #[case] l1: u64,
497        #[case] expected: Option<Ordering>,
498    ) {
499        let m = measure(&stable_record(SwapMode::ExactIn, ours, l1), &prices());
500        assert_eq!(m.map(|m| m.outcome), expected);
501    }
502
503    /// Pairing is by signature, so a signature seen twice is ambiguous and drops wholesale.
504    #[test]
505    fn collect_drops_multi_row_signatures_wholesale() {
506        let mut a = stable_record(SwapMode::ExactIn, 1_000_000, 999_000);
507        a.original_signature = "sigA".to_string();
508        let mut a2 = a.clone();
509        a2.amount = 2_000_000;
510        let mut b = stable_record(SwapMode::ExactIn, 1_000_000, 999_000);
511        b.original_signature = "sigB".to_string();
512
513        let (kept, stats) = collect(vec![a, a2, b]);
514        assert_eq!(kept.len(), 1);
515        assert_eq!(kept[0].original_signature, "sigB");
516        assert_eq!(stats.dropped_multi_row, 1);
517    }
518
519    /// An ExactOut leg is measured on its input, so a non-major input has no price to measure
520    /// against — and dropping it before the signature bookkeeping keeps it from making a
521    /// measurable row ambiguous.
522    #[test]
523    fn collect_skips_exact_out_legs_it_cannot_price() {
524        let exotic = LegRecord {
525            input_mint: Address::default(),
526            ..stable_record(SwapMode::ExactOut, 1_000_000, 999_000)
527        };
528        let major = stable_record(SwapMode::ExactOut, 1_000_000, 999_000);
529
530        let (kept, stats) = collect(vec![exotic, major]);
531        assert_eq!(kept.len(), 1);
532        assert_eq!(kept[0].input_mint, mints::USDC);
533        assert_eq!(stats.dropped_multi_row, 0, "a skipped row is not a pairing");
534    }
535}