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