Skip to main content

simulator_api/reroute_report/
flow.rs

1//! What flowed through a venue in a reroute population, on L1 and after the re-quote.
2//!
3//! Both sides come off the same record, so the comparison needs no control run. The L1 column
4//! counts only legs among the re-quoted population, not the venue's whole L1 footprint.
5//!
6//! Nothing here does I/O.
7//!
8//! The two figures answer different questions. **Volume** is per-hop, valued by each hop's own
9//! `inAmount`, so a leg crossing the venue twice counts at both. **Share** is per-leg
10//! normalized, splitting each leg by `percent` across its route's venues, so the shares over a
11//! direction sum to 100%.
12
13use std::collections::BTreeMap;
14
15use solana_address::Address;
16
17use super::{LegRecord, Prices, Samples};
18use crate::route_plan::{RouteHop, RoutePlan};
19
20fn in_amount(hop: &RouteHop) -> Option<u128> {
21    hop.swap_info.in_amount.as_ref()?.parse().ok()
22}
23
24/// The selector named a venue the stream never carried — a wrong address or a misspelling.
25/// Reporting that as `$0` would be indistinguishable from a venue with no flow.
26#[derive(Debug, thiserror::Error)]
27pub enum RerouteReportError {
28    #[error(
29        "{asked} never appears in this stream.\nIt names {} venues; the busiest are {}.",
30        .venues.len(),
31        busiest(.venues),
32    )]
33    NothingMatched {
34        /// The selector, as the caller gave it.
35        asked: String,
36        /// Every venue the stream's quotes did name, with its hop count.
37        venues: Vec<(String, u64)>,
38    },
39}
40
41const NAMED_IN_ERROR: usize = 8;
42
43fn busiest(venues: &[(String, u64)]) -> String {
44    let mut ranked: Vec<_> = venues.iter().collect();
45    ranked.sort_unstable_by(|(a_label, a_hops), (b_label, b_hops)| {
46        b_hops.cmp(a_hops).then_with(|| a_label.cmp(b_label))
47    });
48    ranked
49        .iter()
50        .take(NAMED_IN_ERROR)
51        .map(|(label, hops)| format!("{label} ({hops})"))
52        .collect::<Vec<_>>()
53        .join(", ")
54}
55
56/// The venue under test, as the two sides can each name it.
57///
58/// Either key alone works; both is strictest. Resolving a program to its metis label is a
59/// network lookup and not done here — pass the label you already hold.
60#[derive(Clone, Default, Debug)]
61pub struct Target {
62    label: Option<String>,
63    /// Paired with its base58, encoded once rather than per hop.
64    program: Option<(Address, String)>,
65}
66
67impl Target {
68    pub fn new(label: Option<String>, program: Option<Address>) -> Self {
69        Self {
70            label,
71            program: program.map(|program| (program, program.to_string())),
72        }
73    }
74
75    pub fn label(&self) -> Option<&str> {
76        self.label.as_deref()
77    }
78
79    pub fn program(&self) -> Option<Address> {
80        self.program.as_ref().map(|(program, _)| *program)
81    }
82
83    /// The quote plan names no program, so the label is the only key it can be matched on.
84    pub fn claims_quoted(&self, hop: &RouteHop) -> bool {
85        match &self.label {
86            Some(label) => hop.label() == Some(label.as_str()),
87            None => false,
88        }
89    }
90
91    /// The program wins when given; the label fallback is what lets a label-only target measure
92    /// this side instead of reporting zero.
93    pub fn claims_fill(&self, hop: &RouteHop) -> bool {
94        if let Some((_, key)) = &self.program {
95            return hop.program.as_deref() == Some(key.as_str());
96        }
97        self.claims_quoted(hop)
98    }
99
100    fn describe(&self) -> String {
101        if let Some(label) = &self.label {
102            format!("label {label:?}")
103        } else if let Some((program, _)) = &self.program {
104            format!("program {program}")
105        } else {
106            "that venue".to_string()
107        }
108    }
109}
110
111/// What crossed the venue in one trade direction, on one side of the comparison.
112#[derive(Default, Clone, Copy, Debug)]
113pub struct Flow {
114    /// A swap crossing the venue twice counts twice.
115    pub legs: u64,
116    pub usd: f64,
117    pub without_amount: u64,
118    /// Priced at nothing rather than zero, so the dollar figure never absorbs them silently.
119    pub without_price: u64,
120    /// The numerator of [`Direction::share`], which is the interface to it.
121    share_usd: f64,
122}
123
124impl Flow {
125    fn merge(&mut self, other: Self) {
126        self.legs += other.legs;
127        self.usd += other.usd;
128        self.without_amount += other.without_amount;
129        self.without_price += other.without_price;
130        self.share_usd += other.share_usd;
131    }
132}
133
134/// One trade direction's before and after.
135#[derive(Default, Clone, Copy, Debug)]
136pub struct Direction {
137    pub l1: Flow,
138    pub requoted: Flow,
139    /// Every venue's normalized value here — the denominator of [`Direction::share`].
140    l1_all: f64,
141    requoted_all: f64,
142}
143
144impl Direction {
145    fn merge(&mut self, other: Self) {
146        self.l1.merge(other.l1);
147        self.requoted.merge(other.requoted);
148        self.l1_all += other.l1_all;
149        self.requoted_all += other.requoted_all;
150    }
151
152    /// The venue's percentage of all flow here, on each side. `None` when nothing could be
153    /// priced, which a `0.0%` would misreport.
154    pub fn share(&self) -> Option<(f64, f64)> {
155        (self.l1_all > 0.0 && self.requoted_all > 0.0).then(|| {
156            (
157                self.l1.share_usd / self.l1_all * 100.0,
158                self.requoted.share_usd / self.requoted_all * 100.0,
159            )
160        })
161    }
162
163    /// Whether the venue was on either side, which is what makes the direction worth a row.
164    pub const fn present(&self) -> bool {
165        self.l1.legs > 0 || self.requoted.legs > 0
166    }
167
168    pub const fn unpriced(&self) -> bool {
169        self.l1.without_price > 0 || self.requoted.without_price > 0
170    }
171
172    /// Nothing priceable, so the dollars are dropped: a `$0` row reads as "worth nothing".
173    pub fn fully_unpriced(&self) -> bool {
174        self.unpriced() && self.l1.usd == 0.0 && self.requoted.usd == 0.0
175    }
176
177    /// Dollars, falling back to legs so an unpriceable book still sorts above an empty one.
178    pub(super) fn rank(&self) -> (u64, u64) {
179        let usd = self.l1.usd.max(self.requoted.usd);
180        (usd as u64, self.l1.legs.max(self.requoted.legs))
181    }
182}
183
184pub(super) fn fold(directions: impl IntoIterator<Item = Direction>) -> Direction {
185    directions
186        .into_iter()
187        .fold(Direction::default(), |mut total, direction| {
188            total.merge(direction);
189            total
190        })
191}
192
193/// A trade direction, keyed by the mints it moves between as `(input, output)`.
194pub type Pair = (String, String);
195
196#[derive(Clone, Copy)]
197enum Side {
198    L1,
199    Requoted,
200}
201
202/// What a stream said about one venue.
203#[derive(Debug)]
204pub struct Report {
205    by_direction: BTreeMap<Pair, Direction>,
206    target: Target,
207    /// Absent when the stream carried no SOL<->USDC reroutes to anchor from.
208    sol_usd: Option<f64>,
209}
210
211impl Report {
212    /// Build from anything that yields records — a mapped notification stream, a DB read.
213    pub fn from_records<'a>(
214        target: Target,
215        records: impl IntoIterator<Item = &'a LegRecord>,
216    ) -> Result<Self, RerouteReportError> {
217        let mut builder = ReportBuilder::new(target);
218        for record in records {
219            builder.push(record);
220        }
221        builder.finish()
222    }
223
224    pub const fn target(&self) -> &Target {
225        &self.target
226    }
227
228    pub const fn sol_usd(&self) -> Option<f64> {
229        self.sol_usd
230    }
231
232    /// Every direction the range priced, which includes those the venue never served — filter
233    /// on [`Direction::present`] for the venue's own.
234    pub fn by_direction(&self) -> impl Iterator<Item = (&Pair, &Direction)> {
235        self.by_direction.iter()
236    }
237
238    /// The directions summed. Share stays meaningful because both numerator and denominator are
239    /// per-leg normalized.
240    pub fn total(&self) -> Direction {
241        fold(self.by_direction.values().copied())
242    }
243}
244
245/// Accumulates a [`Report`] one record at a time, holding pricing samples, one accumulator per
246/// trade direction, and the venue labels seen — never the records.
247///
248/// Every price is a median over the whole stream, so amounts accumulate raw and convert once in
249/// [`ReportBuilder::finish`]. [`Prices::usd`] is linear in the amount, which is what makes that
250/// identical to converting each hop as it arrives.
251pub struct ReportBuilder {
252    target: Target,
253    samples: Samples,
254    by_direction: Directions,
255    /// Venue labels the quotes named, for the error a selector that matches nothing raises.
256    labels: BTreeMap<String, u64>,
257}
258
259impl ReportBuilder {
260    pub fn new(target: Target) -> Self {
261        Self {
262            target,
263            samples: Samples::default(),
264            by_direction: Directions::default(),
265            labels: BTreeMap::new(),
266        }
267    }
268
269    /// Take one record's contribution. Cheap and order-independent.
270    pub fn push(&mut self, record: &LegRecord) {
271        self.samples.push(record);
272
273        // Neither column counts flow that never landed, so the two are filtered alike.
274        if record.failed || record.original_failed {
275            return;
276        }
277        let Self {
278            target,
279            by_direction,
280            labels,
281            ..
282        } = self;
283        fn hops_of(plan: &Option<RoutePlan>) -> Option<&[RouteHop]> {
284            plan.as_ref().map(RoutePlan::hops)
285        }
286        // Used twice: the census below, and the fallback when there is no fill plan.
287        let quoted = hops_of(&record.route_plan);
288        for label in quoted.iter().copied().flatten().filter_map(RouteHop::label) {
289            match labels.get_mut(label) {
290                Some(hops) => *hops += 1,
291                None => {
292                    labels.insert(label.to_string(), 1);
293                }
294            }
295        }
296
297        // The fill, so both columns compare a fill against a fill; the quote is the fallback.
298        let (requoted, from_quote) = match hops_of(&record.realized_route_plan) {
299            Some(filled) => (filled, false),
300            None => (quoted.unwrap_or_default(), true),
301        };
302        let claimed = |hop: &RouteHop| match from_quote {
303            true => target.claims_quoted(hop),
304            false => target.claims_fill(hop),
305        };
306        for hop in requoted.iter().filter(|hop| claimed(hop)) {
307            by_direction.record(Side::Requoted, hop);
308        }
309
310        let l1 = hops_of(&record.original_route_plan);
311        for hop in l1
312            .iter()
313            .copied()
314            .flatten()
315            .filter(|hop| target.claims_fill(hop))
316        {
317            by_direction.record(Side::L1, hop);
318        }
319
320        // The leg's own value, counted once whichever routes it took, so the shares are over
321        // the population rather than over the hops the venue served.
322        let leg_amount = u128::from(record.amount) as f64;
323        by_direction.accumulate_share(
324            Side::Requoted,
325            requoted,
326            record.input_mint,
327            leg_amount,
328            claimed,
329        );
330        if let Some(l1) = l1 {
331            by_direction.accumulate_share(Side::L1, l1, record.input_mint, leg_amount, |hop| {
332                target.claims_fill(hop)
333            });
334        }
335    }
336
337    /// Nothing matched on either side means the selector named something the stream never saw.
338    pub fn finish(self) -> Result<Report, RerouteReportError> {
339        let prices = self.samples.prices();
340        let report = Report {
341            sol_usd: prices.as_ref().map(Prices::sol_usd),
342            by_direction: self.by_direction.price(prices.as_ref()),
343            target: self.target,
344        };
345        let total = report.total();
346        if total.l1.legs == 0 && total.requoted.legs == 0 {
347            return Err(RerouteReportError::NothingMatched {
348                asked: report.target.describe(),
349                venues: self.labels.into_iter().collect(),
350            });
351        }
352        Ok(report)
353    }
354}
355
356/// In its own type so `record` and `accumulate_share` borrow only the map, leaving the rest of
357/// the builder free.
358#[derive(Default)]
359struct Directions(BTreeMap<Pair, DirectionAccum>);
360
361impl Directions {
362    /// The hop's notional. Its input mint is the direction's, so raw amounts sum unpriced.
363    fn record(&mut self, side: Side, hop: &RouteHop) {
364        let Some(key) = key_of(hop) else {
365            return;
366        };
367        let flow = &mut self.0.entry(key).or_default().side_mut(side).flow;
368        flow.legs += 1;
369        match in_amount(hop) {
370            Some(amount) => flow.raw += amount,
371            None => flow.without_amount += 1,
372        }
373    }
374
375    /// A leg's value split across its route's venues, filed under each hop's own direction, so
376    /// every leg counts once and the shares over a direction sum to 100%.
377    ///
378    /// Credited in the *leg's* input mint, which is not the direction's on a multi-hop route —
379    /// hence a bucket per mint.
380    fn accumulate_share(
381        &mut self,
382        side: Side,
383        hops: &[RouteHop],
384        leg_mint: Address,
385        leg_amount: f64,
386        claimed: impl Fn(&RouteHop) -> bool,
387    ) {
388        let total: u64 = hops.iter().map(|hop| hop.percent).sum();
389        if total == 0 {
390            return;
391        }
392        for hop in hops {
393            let Some(key) = key_of(hop) else {
394                continue;
395            };
396            let value = leg_amount * hop.percent as f64 / total as f64;
397            let share = &mut self.0.entry(key).or_default().side_mut(side).share;
398            credit(&mut share.all, leg_mint, value);
399            if claimed(hop) {
400                credit(&mut share.claimed, leg_mint, value);
401            }
402        }
403    }
404
405    /// A direction with nothing priceable and no venue legs carries no measurement — its bucket
406    /// was opened before its mint was known to be unpriceable — so it is dropped, not zeroed.
407    fn price(self, prices: Option<&Prices>) -> BTreeMap<Pair, Direction> {
408        self.0
409            .into_iter()
410            .map(|(pair, accum)| {
411                let direction = accum.price(&pair.0, prices);
412                (pair, direction)
413            })
414            .filter(|(_, direction)| {
415                direction.present() || direction.l1_all > 0.0 || direction.requoted_all > 0.0
416            })
417            .collect()
418    }
419}
420
421/// `None` for a hop whose pair the plan never named: keying it on a default address would pool
422/// unrelated unnamed trades into one priced bucket, so such a hop opens no direction at all.
423fn key_of(hop: &RouteHop) -> Option<Pair> {
424    Some((
425        hop.input_mint()?.to_string(),
426        hop.output_mint()?.to_string(),
427    ))
428}
429
430/// Nearly every direction sees a single leg mint, so a scan beats a map; a hub pair terminating
431/// routes out of many exotics is the exception.
432fn credit(bucket: &mut Vec<(Address, f64)>, mint: Address, value: f64) {
433    match bucket.iter_mut().find(|(held, _)| *held == mint) {
434        Some((_, total)) => *total += value,
435        None => bucket.push((mint, value)),
436    }
437}
438
439/// Raw amounts for one side of one direction, awaiting a price.
440#[derive(Default)]
441struct FlowAccum {
442    legs: u64,
443    /// In the direction's input mint, shared by every hop filed here.
444    raw: u128,
445    without_amount: u64,
446}
447
448impl FlowAccum {
449    fn price(self, mint: &str, prices: Option<&Prices>, share_usd: f64) -> Flow {
450        let usd = prices.and_then(|prices| prices.usd_str(mint, self.raw as f64));
451        Flow {
452            legs: self.legs,
453            usd: usd.unwrap_or_default(),
454            without_amount: self.without_amount,
455            // Every hop here shares one mint, so the range prices all of them or none.
456            without_price: match usd {
457                Some(_) => 0,
458                None => self.legs - self.without_amount,
459            },
460            share_usd,
461        }
462    }
463}
464
465/// Weighted leg value for one side of one direction, per the leg's own input mint.
466#[derive(Default)]
467struct ShareAccum {
468    all: Vec<(Address, f64)>,
469    claimed: Vec<(Address, f64)>,
470}
471
472impl ShareAccum {
473    /// `(all, claimed)`. An unpriceable mint contributes to neither, keeping the ratio honest.
474    fn price(&self, prices: Option<&Prices>) -> (f64, f64) {
475        let sum = |bucket: &[(Address, f64)]| {
476            bucket
477                .iter()
478                .filter_map(|(mint, raw)| prices?.usd(mint, *raw))
479                // Not `sum`: `Sum` for floats starts at `-0.0`, so an empty bucket would
480                // render as `-0.0%`.
481                .fold(0.0, |total, usd| total + usd)
482        };
483        (sum(&self.all), sum(&self.claimed))
484    }
485}
486
487/// Paired so a hop's notional and its share cannot be filed against different sides.
488#[derive(Default)]
489struct SideAccum {
490    flow: FlowAccum,
491    share: ShareAccum,
492}
493
494#[derive(Default)]
495struct DirectionAccum {
496    l1: SideAccum,
497    requoted: SideAccum,
498}
499
500impl DirectionAccum {
501    fn side_mut(&mut self, side: Side) -> &mut SideAccum {
502        match side {
503            Side::L1 => &mut self.l1,
504            Side::Requoted => &mut self.requoted,
505        }
506    }
507
508    fn price(self, mint: &str, prices: Option<&Prices>) -> Direction {
509        let (l1_all, l1_claimed) = self.l1.share.price(prices);
510        let (requoted_all, requoted_claimed) = self.requoted.share.price(prices);
511        Direction {
512            l1: self.l1.flow.price(mint, prices, l1_claimed),
513            requoted: self.requoted.flow.price(mint, prices, requoted_claimed),
514            l1_all,
515            requoted_all,
516        }
517    }
518}
519
520#[cfg(test)]
521mod tests {
522    use super::*;
523    use crate::{
524        mints,
525        reroute_report::testing::{hop, plan, record_of},
526    };
527
528    const SOL: &str = "So11111111111111111111111111111111111111112";
529    const USDC: &str = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v";
530    const VENUE_PROGRAM: &str = "BiSoNHVpsVZW2F7rx2eQ59yQwKxzU5NvBcmKshCSUypi";
531    const OTHER_PROGRAM: &str = "whirLbMiicVdio4qvUfM5KAg6Ct8VwpYzGff3uctyCc";
532
533    /// Fixes SOL at $100 from the stream's own swaps. Carries no route plan, so it prices the
534    /// range without contributing a hop to anything.
535    fn anchors() -> Vec<LegRecord> {
536        (1..=3)
537            .map(|n| LegRecord {
538                original_realized_output_amount: Some(n * 100_000_000),
539                ..record_of(mints::SOL, mints::USDC, n * 1_000_000_000, None, None, None)
540            })
541            .collect()
542    }
543
544    fn venue() -> Target {
545        Target::new(
546            Some("BisonFi".to_string()),
547            Some(VENUE_PROGRAM.parse().expect("a venue program")),
548        )
549    }
550
551    fn fill(
552        program: &str,
553        label: &str,
554        input: &str,
555        output: &str,
556        amount: u64,
557    ) -> Option<RoutePlan> {
558        plan(vec![hop(Some(program), label, input, output, amount)])
559    }
560
561    fn report_of(records: &[LegRecord]) -> Report {
562        Report::from_records(venue(), records).expect("the venue appears")
563    }
564
565    /// The anchors, plus whatever the test is actually about.
566    fn priced(extra: impl IntoIterator<Item = LegRecord>) -> Vec<LegRecord> {
567        let mut records = anchors();
568        records.extend(extra);
569        records
570    }
571
572    /// A leg the venue won: the re-quote routed it through the venue, L1 had it elsewhere.
573    fn won_leg(amount: u64) -> LegRecord {
574        let realized = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, amount);
575        let l1 = fill(OTHER_PROGRAM, "Whirlpool", SOL, USDC, amount);
576        record_of(mints::SOL, mints::USDC, amount, None, l1, realized)
577    }
578
579    /// A hop whose pair the plan never named opens no direction: keyed on a default address it
580    /// would pool unrelated unnamed trades into one priced bucket. Its named sibling still
581    /// counts alone.
582    #[test]
583    fn a_hop_with_an_unnamed_mint_opens_no_direction() {
584        let mut unnamed = hop(Some(VENUE_PROGRAM), "BisonFi", USDC, SOL, 1_000_000);
585        unnamed.swap_info.output_mint = None;
586        let l1 = plan(vec![
587            hop(Some(VENUE_PROGRAM), "BisonFi", SOL, USDC, 2_000_000_000),
588            unnamed,
589        ]);
590        let report = report_of(&priced([record_of(
591            mints::SOL,
592            mints::USDC,
593            2_000_000_000,
594            None,
595            l1,
596            None,
597        )]));
598
599        let pairs: Vec<&Pair> = report.by_direction().map(|(pair, _)| pair).collect();
600        assert_eq!(pairs, [&(SOL.to_string(), USDC.to_string())]);
601        assert_eq!(report.total().l1.legs, 1, "only the named hop counts");
602    }
603
604    /// Prices are medians over the whole stream, so no dollar figure exists until it ends. The
605    /// builder holds raw amounts and converts once — which only works because the conversion is
606    /// linear, and this is the test of that.
607    #[test]
608    fn deferring_the_conversion_matches_pricing_each_hop_as_it_arrives() {
609        let report = report_of(&priced([won_leg(2_000_000_000), won_leg(3_000_000_000)]));
610        let total = report.total();
611
612        assert_eq!(report.sol_usd(), Some(100.0));
613        assert_eq!(total.requoted.legs, 2);
614        // 2 SOL and 3 SOL at $100, whether summed raw and converted once or converted twice.
615        assert_eq!(total.requoted.usd, 500.0);
616        assert_eq!(total.l1.legs, 0, "L1 had these legs at another venue");
617    }
618
619    /// The order records arrive in is the order the session produced them, which is not a
620    /// property of the venue. Every figure must be a fold, not a sequence.
621    #[test]
622    fn the_result_does_not_depend_on_arrival_order() {
623        let forward = priced([won_leg(2_000_000_000), won_leg(3_000_000_000)]);
624        let mut backward = forward.clone();
625        backward.reverse();
626
627        let (forward, backward) = (report_of(&forward), report_of(&backward));
628        assert_eq!(forward.sol_usd(), backward.sol_usd());
629        assert_eq!(forward.total().requoted.usd, backward.total().requoted.usd);
630        assert_eq!(forward.render(None), backward.render(None));
631    }
632
633    /// Neither column counts flow that never landed, and the two must be filtered alike — a
634    /// reverted simulation is not a leg the venue won, and a failed original is not one it held.
635    #[test]
636    fn neither_side_counts_flow_that_never_landed() {
637        let records = priced([
638            won_leg(2_000_000_000),
639            LegRecord {
640                failed: true,
641                ..won_leg(9_000_000_000)
642            },
643            LegRecord {
644                original_failed: true,
645                ..won_leg(9_000_000_000)
646            },
647        ]);
648
649        let total = report_of(&records).total();
650        assert_eq!(total.requoted.legs, 1, "only the landed leg counts");
651        assert_eq!(total.requoted.usd, 200.0);
652    }
653
654    /// Volume share needs every leg counted once, so a two-hop route gives each half — where
655    /// per-hop notional credits both in full. The two answer different questions.
656    #[test]
657    fn a_share_splits_a_leg_where_notional_counts_it_twice() {
658        let two_hops = plan(vec![
659            hop(Some(VENUE_PROGRAM), "BisonFi", SOL, USDC, 1_000_000_000),
660            hop(Some(OTHER_PROGRAM), "Whirlpool", USDC, "BONK", 100_000_000),
661        ]);
662        let l1 = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, 1_000_000_000);
663        let record = record_of(mints::SOL, mints::USDC, 1_000_000_000, None, l1, two_hops);
664        let report = report_of(&priced([record]));
665
666        let total = report.total();
667        // The leg is one SOL, $100. Per-hop notional credits the venue the whole of its own hop.
668        assert_eq!(total.requoted.usd, 100.0);
669        // Share splits that $100 across the two hops on the route, and the venue holds one.
670        assert_eq!(total.requoted_all, 100.0, "the leg is counted once");
671        assert_eq!(
672            total.requoted.share_usd, 50.0,
673            "half the route was the venue's"
674        );
675        // On L1 it held the leg outright.
676        assert_eq!(total.share(), Some((100.0, 50.0)));
677
678        // Each hop is filed under its own direction, so the rows decompose the same measure.
679        let (_, first) = report
680            .by_direction()
681            .find(|((input, output), _)| input == SOL && output == USDC)
682            .expect("the venue's direction");
683        assert_eq!(first.requoted.share_usd, 50.0);
684        assert_eq!(first.requoted_all, 50.0, "that direction held half the leg");
685    }
686
687    /// The program wins when given; the label is the fallback, so naming only a label still
688    /// measures the fill columns rather than reporting a silent zero on them.
689    #[test]
690    fn a_fill_prefers_the_program_and_falls_back_to_the_label() {
691        let filled = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, 1_000);
692        let record = record_of(mints::SOL, mints::USDC, 1_000, None, filled.clone(), filled);
693        let hop = &record.original_route_plan.as_ref().expect("a plan").hops()[0];
694
695        let by_label = Target::new(Some("BisonFi".to_string()), None);
696        assert!(by_label.claims_fill(hop));
697        assert!(by_label.claims_quoted(hop));
698
699        // A program that was given and does not match is not rescued by a matching label.
700        let other = Target::new(Some("BisonFi".to_string()), Some(Address::new_unique()));
701        assert!(!other.claims_fill(hop));
702
703        let by_program = Target::new(None, Some(VENUE_PROGRAM.parse().expect("a program")));
704        assert!(by_program.claims_fill(hop));
705        // The quote names no program, so this key cannot match there.
706        assert!(!by_program.claims_quoted(hop));
707    }
708
709    /// Dollars cross mints; a raw amount does not. Summing each direction's own input mint added
710    /// lamports to stablecoin units and produced a figure that looked precise and meant nothing,
711    /// so no amount is reported in mint units anywhere — only legs and dollars.
712    #[test]
713    fn the_report_is_swaps_and_dollars_only() {
714        let sell = fill(VENUE_PROGRAM, "BisonFi", SOL, USDC, 1_000_000_000);
715        let buy = fill(VENUE_PROGRAM, "BisonFi", USDC, SOL, 100_000_000);
716        let report = report_of(&priced([
717            record_of(mints::SOL, mints::USDC, 1_000_000_000, None, None, sell),
718            record_of(mints::USDC, mints::SOL, 100_000_000, None, None, buy),
719        ]));
720
721        // Lamports in one direction, stablecoin units in the other; dollars survive the sum.
722        let json = report.to_json();
723        assert_eq!(json["total"]["l1"]["swaps"], 0);
724        assert_eq!(json["total"]["requoted"]["swaps"], 2);
725        assert_eq!(json["total"]["requoted"]["usd"], 200.0);
726
727        // And the rendered table carries the two rows, and no third.
728        let table = report.render(None);
729        let totals = table.split('→').next().expect("a totals block");
730        assert!(totals.contains("swaps") && totals.contains("volume"));
731        assert!(!totals.contains("raw in"), "a raw amount reached the table");
732        // "re-quoted" is a column heading; a *quoted fill* line is what must not appear.
733        assert!(!table.contains("quoted fill"));
734    }
735
736    /// A range with nothing to anchor on prices nothing. Reporting those legs at $0 would read
737    /// as "worth nothing" rather than "not valued".
738    #[test]
739    fn a_range_that_cannot_be_priced_reports_no_dollars() {
740        let report = report_of(&[won_leg(2_000_000_000)]);
741        let total = report.total();
742
743        assert_eq!(report.sol_usd(), None);
744        assert_eq!(total.requoted.legs, 1);
745        assert_eq!(total.requoted.without_price, 1);
746        assert!(total.fully_unpriced());
747
748        let table = report.render(None);
749        assert!(!table.contains("volume"), "a dollar row without a price");
750        assert!(table.contains("could not price"));
751    }
752
753    /// A selector that matches nothing is a wrong address or a misspelling, not a venue with no
754    /// flow. Reporting it as $0 was indistinguishable from the real thing.
755    #[test]
756    fn a_selector_that_matches_nothing_says_so() {
757        let quote = plan(vec![hop(None, "Whirlpool", SOL, USDC, 1_000)]);
758        let records = vec![record_of(mints::SOL, mints::USDC, 1_000, quote, None, None)];
759
760        let target = Target::new(Some("Nope".to_string()), None);
761        let error = Report::from_records(target, &records).expect_err("the venue never appears");
762
763        let message = error.to_string();
764        assert!(message.contains("Nope"), "names what was asked for");
765        // And what the stream does name, so the misspelling is visible.
766        assert!(message.contains("Whirlpool (1)"));
767    }
768
769    /// The subtitle is the caller's, since this module cannot know a slot range.
770    #[test]
771    fn the_subtitle_is_the_callers() {
772        let report = report_of(&[won_leg(2_000_000_000)]);
773        assert!(report.render(Some("slots 1–100")).contains("slots 1–100"));
774        assert!(!report.render(None).contains("slots"));
775    }
776}