1use std::collections::BTreeMap;
7
8use serde::Serialize;
9
10use super::{LegRecord, Prices, SwapMode, fmt::commas_u};
11
12pub fn bucket(outcome: &str, category: Option<&str>) -> Option<String> {
17 match outcome {
18 "filled" => None,
19 "reverted" => Some(format!("reverted_{}", category.unwrap_or_default())),
20 other => Some(other.to_string()),
21 }
22}
23
24#[derive(Default, Clone, Serialize)]
25pub struct TemplateProbes {
26 pub outcomes: BTreeMap<String, u64>,
29 pub scored_vs_historical: u64,
31 pub bps_vs_historical_total: i64,
33 pub pairs_vs_same_state: u64,
35 pub bps_vs_same_state_total: f64,
38 pub notional_usd: f64,
41 pub weighted_bps_usd: f64,
42}
43
44impl TemplateProbes {
45 fn probes(&self) -> u64 {
46 self.outcomes.values().sum()
47 }
48
49 fn filled(&self) -> u64 {
50 self.outcomes.get("filled").copied().unwrap_or(0)
51 }
52
53 fn mean(total: f64, count: u64, unit: &str) -> String {
54 match count {
55 0 => "—".to_string(),
56 n => format!("{:+.2} ({} {unit})", total / n as f64, commas_u(n)),
57 }
58 }
59
60 fn row(&self, template: &str) -> String {
61 let probes = self.probes();
62 let filled = self.filled();
63 let pct = filled as f64 / probes as f64 * 100.0;
64 let vs_historical = Self::mean(
65 self.bps_vs_historical_total as f64,
66 self.scored_vs_historical,
67 "scored",
68 );
69 let vs_same_state = Self::mean(
70 self.bps_vs_same_state_total,
71 self.pairs_vs_same_state,
72 "pairs",
73 );
74 let per_dollar = match self.notional_usd {
75 notional if notional > 0.0 => format!(
76 "{:+.2} (${})",
77 self.weighted_bps_usd / notional,
78 commas_u(notional as u64)
79 ),
80 _ => "—".to_string(),
81 };
82 format!(
83 "| {template} | {} | {} ({pct:.1}%) | {vs_historical} | {per_dollar} | {vs_same_state} |",
84 commas_u(probes),
85 commas_u(filled),
86 )
87 }
88
89 fn outcomes_line(&self, template: &str) -> String {
90 let parts: Vec<String> = self
91 .outcomes
92 .iter()
93 .map(|(outcome, n)| format!("{outcome} {}", commas_u(*n)))
94 .collect();
95 format!("{template} outcomes: {}", parts.join(", "))
96 }
97}
98
99#[derive(Default, Clone, Serialize)]
100pub struct ProbeReport {
101 pub by_template: BTreeMap<String, TemplateProbes>,
102}
103
104impl ProbeReport {
105 pub fn from_records(records: &[LegRecord], prices: Option<&Prices>) -> Self {
106 let mut report = Self::default();
107 for record in records {
108 let template = record
109 .template
110 .clone()
111 .unwrap_or_else(|| "(unnamed)".to_string());
112 let entry = report.by_template.entry(template).or_default();
113 let outcome = record
114 .error_category
115 .clone()
116 .unwrap_or_else(|| "filled".to_string());
117 *entry.outcomes.entry(outcome).or_default() += 1;
118 if let Some(bps) = record.output_bps {
119 entry.scored_vs_historical += 1;
120 entry.bps_vs_historical_total += bps;
121 let priced_mint = match record.swap_mode {
122 SwapMode::ExactIn => &record.input_mint,
123 SwapMode::ExactOut => &record.output_mint,
124 };
125 if let Some(usd) = prices.and_then(|p| p.usd(priced_mint, record.amount as f64)) {
126 entry.notional_usd += usd;
127 entry.weighted_bps_usd += usd * bps as f64;
128 }
129 }
130 if let (Some(new), Some(original)) =
131 (record.new_output_amount, record.quoted_output_amount)
132 && original > 0
133 {
134 entry.pairs_vs_same_state += 1;
135 let change = (new as f64 / original as f64 - 1.0) * 1e4;
136 entry.bps_vs_same_state_total += match record.swap_mode {
137 SwapMode::ExactIn => change,
138 SwapMode::ExactOut => -change,
139 };
140 }
141 }
142 report
143 }
144
145 pub fn merge(&mut self, other: &Self) {
146 for (template, probes) in &other.by_template {
147 let entry = self.by_template.entry(template.clone()).or_default();
148 entry.notional_usd += probes.notional_usd;
149 entry.weighted_bps_usd += probes.weighted_bps_usd;
150 for (outcome, n) in &probes.outcomes {
151 *entry.outcomes.entry(outcome.clone()).or_default() += n;
152 }
153 entry.scored_vs_historical += probes.scored_vs_historical;
154 entry.bps_vs_historical_total += probes.bps_vs_historical_total;
155 entry.pairs_vs_same_state += probes.pairs_vs_same_state;
156 entry.bps_vs_same_state_total += probes.bps_vs_same_state_total;
157 }
158 }
159
160 pub fn is_empty(&self) -> bool {
161 self.by_template.is_empty()
162 }
163
164 pub fn render(&self, name: &str) -> String {
165 let mut lines = vec![
166 format!("### {name}: direct-fill probes"),
167 "| Template | probes | filled | per hop (bps) | per dollar (bps) | vs same-state (bps) |"
168 .to_string(),
169 "|---|---|---|---|---|---|".to_string(),
170 ];
171 for (template, probes) in &self.by_template {
172 lines.push(probes.row(template));
173 }
174 for (template, probes) in &self.by_template {
175 lines.push(probes.outcomes_line(template));
176 }
177 lines.push(String::new());
178 lines.join("\n")
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use rstest::rstest;
185
186 use super::*;
187 use crate::{
188 mints,
189 reroute_report::{Samples, testing::record_of},
190 };
191
192 fn prices() -> Prices {
194 let mut samples = Samples::default();
195 for n in 1..=3u64 {
196 samples.push(&LegRecord {
197 original_realized_output_amount: Some(n * 100_000_000),
198 ..record_of(mints::SOL, mints::USDC, n * 1_000_000_000, None, None, None)
199 });
200 }
201 samples.prices().expect("anchored")
202 }
203
204 #[test]
207 fn the_per_dollar_mean_weighs_hops_by_notional() {
208 let hop = |lamports: u64, bps: i64| LegRecord {
209 template: Some("Tempest".to_string()),
210 output_bps: Some(bps),
211 ..record_of(mints::SOL, mints::USDC, lamports, None, None, None)
212 };
213 let records = [
215 hop(10_000_000, -100),
216 hop(10_000_000, -100),
217 hop(1_000_000_000, 10),
218 ];
219 let template = |prices: Option<&Prices>| {
220 ProbeReport::from_records(&records, prices).by_template["Tempest"].clone()
221 };
222
223 let weighted = template(Some(&prices()));
224 let per_hop: i64 = (-100 - 100 + 10) / 3;
225 let per_dollar = (0.01 * -100.0 + 0.01 * -100.0 + 1.0 * 10.0) / 1.02;
226 assert_eq!(weighted.bps_vs_historical_total / 3, per_hop, "per hop");
227 assert!(
228 (weighted.weighted_bps_usd / weighted.notional_usd - per_dollar).abs() < 0.01,
229 "per dollar follows the large hop, not the count"
230 );
231 assert_eq!(template(None).notional_usd, 0.0);
233 }
234
235 fn probe(template: &str) -> LegRecord {
236 LegRecord {
237 template: Some(template.to_string()),
238 ..record_of(mints::SOL, mints::USDC, 1_000_000, None, None, None)
239 }
240 }
241
242 #[rstest]
243 #[case::success_falls_back_to_filled(None, "filled")]
244 #[case::reverted_keeps_its_category(Some("reverted_slippage".to_string()), "reverted_slippage")]
245 #[case::unattributed_keeps_its_category(Some("unattributed".to_string()), "unattributed")]
246 fn from_records_buckets_outcomes_by_error_category_else_filled(
247 #[case] error_category: Option<String>,
248 #[case] expected: &str,
249 ) {
250 let record = LegRecord {
251 error_category,
252 ..probe("Tempest")
253 };
254 let report = ProbeReport::from_records(&[record], None);
255 assert_eq!(
256 report.by_template["Tempest"].outcomes.get(expected),
257 Some(&1)
258 );
259 }
260
261 #[test]
262 fn scored_vs_historical_counts_only_rows_carrying_output_bps() {
263 let scored = LegRecord {
264 output_bps: Some(12),
265 ..probe("Tempest")
266 };
267 let unscored = probe("Tempest");
268
269 let report = ProbeReport::from_records(&[scored, unscored], None);
270 let template = &report.by_template["Tempest"];
271 assert_eq!(template.scored_vs_historical, 1);
272 assert_eq!(template.bps_vs_historical_total, 12);
273 }
274
275 #[rstest]
276 #[case::both_present_and_positive(Some(1_010_000), Some(1_000_000), 1)]
277 #[case::new_amount_missing(None, Some(1_000_000), 0)]
278 #[case::original_amount_missing(Some(1_010_000), None, 0)]
279 #[case::original_amount_zero(Some(1_010_000), Some(0), 0)]
280 fn same_state_pairs_require_both_amounts_and_a_positive_original(
281 #[case] new_output_amount: Option<u64>,
282 #[case] quoted_output_amount: Option<u64>,
283 #[case] expected_pairs: u64,
284 ) {
285 let record = LegRecord {
286 new_output_amount,
287 quoted_output_amount,
288 ..probe("Tempest")
289 };
290 let report = ProbeReport::from_records(&[record], None);
291 assert_eq!(
292 report.by_template["Tempest"].pairs_vs_same_state,
293 expected_pairs
294 );
295 }
296
297 #[test]
298 fn same_state_bps_is_the_relative_change_from_quoted_to_new() {
299 let record = LegRecord {
300 new_output_amount: Some(1_010_000),
301 quoted_output_amount: Some(1_000_000),
302 ..probe("Tempest")
303 };
304 let report = ProbeReport::from_records(&[record], None);
305 let template = &report.by_template["Tempest"];
306 assert_eq!(template.pairs_vs_same_state, 1);
307 assert!((template.bps_vs_same_state_total - 100.0).abs() < 1e-9);
308 }
309
310 #[test]
311 fn merge_carries_every_field() {
312 let a = LegRecord {
313 output_bps: Some(10),
314 new_output_amount: Some(1_010_000),
315 quoted_output_amount: Some(1_000_000),
316 ..probe("Tempest")
317 };
318 let b = LegRecord {
319 error_category: Some("reverted_slippage".to_string()),
320 output_bps: Some(20),
321 new_output_amount: Some(1_020_000),
322 quoted_output_amount: Some(1_000_000),
323 ..probe("Tempest")
324 };
325
326 let mut report = ProbeReport::from_records(&[a], None);
327 report.merge(&ProbeReport::from_records(&[b], None));
328
329 let template = &report.by_template["Tempest"];
330 assert_eq!(template.outcomes.get("filled"), Some(&1));
331 assert_eq!(template.outcomes.get("reverted_slippage"), Some(&1));
332 assert_eq!(template.scored_vs_historical, 2);
333 assert_eq!(template.bps_vs_historical_total, 30);
334 assert_eq!(template.pairs_vs_same_state, 2);
335 assert!((template.bps_vs_same_state_total - 300.0).abs() < 1e-9);
336 }
337
338 #[test]
339 fn a_record_with_no_template_lands_under_unnamed() {
340 let record = record_of(mints::SOL, mints::USDC, 1_000_000, None, None, None);
341 let report = ProbeReport::from_records(&[record], None);
342 assert!(report.by_template.contains_key("(unnamed)"));
343 }
344
345 #[test]
346 fn render_contains_the_template_row_and_outcomes_line() {
347 let filled_a = LegRecord {
348 output_bps: Some(100),
349 new_output_amount: Some(1_010_000),
350 quoted_output_amount: Some(1_000_000),
351 ..probe("Tempest")
352 };
353 let filled_b = LegRecord {
354 output_bps: Some(200),
355 new_output_amount: Some(1_020_000),
356 quoted_output_amount: Some(1_000_000),
357 ..probe("Tempest")
358 };
359 let reverted = LegRecord {
360 error_category: Some("reverted_slippage".to_string()),
361 ..probe("Tempest")
362 };
363
364 let report = ProbeReport::from_records(&[filled_a, filled_b, reverted], None);
365 let rendered = report.render("R1");
366
367 assert!(rendered.contains("### R1: direct-fill probes"));
368 assert!(rendered.contains("| Template | probes | filled |"));
369 assert!(
370 rendered.contains(
371 "| Tempest | 3 | 2 (66.7%) | +150.00 (2 scored) | — | +150.00 (2 pairs) |"
372 )
373 );
374 assert!(rendered.contains("Tempest outcomes: filled 2, reverted_slippage 1"));
375 }
376
377 #[test]
378 fn render_shows_an_em_dash_when_a_mean_has_no_observations() {
379 let reverted = LegRecord {
380 error_category: Some("reverted_slippage".to_string()),
381 ..probe("Vertigo")
382 };
383
384 let report = ProbeReport::from_records(&[reverted], None);
385 let rendered = report.render("R2");
386
387 assert!(rendered.contains("| Vertigo | 1 | 0 (0.0%) | — | — |"));
388 assert!(rendered.contains("Vertigo outcomes: reverted_slippage 1"));
389 }
390
391 #[test]
392 fn is_empty_reflects_an_empty_template_map() {
393 assert!(ProbeReport::default().is_empty());
394 let report = ProbeReport::from_records(&[probe("Tempest")], None);
395 assert!(!report.is_empty());
396 }
397
398 #[test]
401 fn an_exact_out_notional_is_priced_in_the_output_mint() {
402 let record = LegRecord {
403 swap_mode: SwapMode::ExactOut,
404 output_bps: Some(10),
405 ..probe("Tempest")
406 };
407 let report = ProbeReport::from_records(&[record], Some(&prices()));
408 let template = &report.by_template["Tempest"];
409 assert!(
410 (template.notional_usd - 1.0).abs() < 1e-9,
411 "{}",
412 template.notional_usd
413 );
414 }
415
416 #[rstest]
417 #[case::cheaper(900, 1_000.0)]
418 #[case::dearer(1_100, -1_000.0)]
419 fn an_exact_out_comparison_scores_input_saved(#[case] charged: u64, #[case] expected: f64) {
420 let record = LegRecord {
421 swap_mode: SwapMode::ExactOut,
422 new_output_amount: Some(charged),
423 quoted_output_amount: Some(1_000),
424 ..probe("t")
425 };
426 let report = ProbeReport::from_records(&[record], None);
427 assert!((report.by_template["t"].bps_vs_same_state_total - expected).abs() < 1e-9);
428 }
429}