1use crate::chains::ChainClientFactory;
9use crate::cli::crawl::{self, Period};
10use crate::config::Config;
11use crate::error::{Result, ScopeError};
12use crate::market::{
13 BiconomyClient, BinanceClient, HealthThresholds, MarketSummary, MarketVenue, OrderBook,
14 OrderBookClient, order_book_from_analytics,
15};
16use clap::{Args, Subcommand};
17use std::time::Duration;
18
19pub const DEFAULT_EVERY_SECS: u64 = 60;
21
22pub const DEFAULT_DURATION_SECS: u64 = 3600;
24
25#[derive(Debug, Subcommand)]
27pub enum MarketCommands {
28 Summary(SummaryArgs),
35}
36
37#[derive(Debug, Args)]
42pub struct SummaryArgs {
43 #[arg(default_value = "USDC", value_name = "SYMBOL")]
45 pub pair: String,
46
47 #[arg(long, default_value = "binance", value_name = "VENUE")]
49 pub market_venue: MarketVenue,
50
51 #[arg(long, default_value = "ethereum", value_name = "CHAIN")]
53 pub chain: String,
54
55 #[arg(long, value_name = "URL")]
57 pub biconomy_api_url: Option<String>,
58
59 #[arg(long, default_value = "1.0", value_name = "TARGET")]
61 pub peg: f64,
62
63 #[arg(long, default_value = "6", value_name = "N")]
65 pub min_levels: usize,
66
67 #[arg(long, default_value = "3000", value_name = "USDT")]
69 pub min_depth: f64,
70
71 #[arg(long, default_value = "0.001", value_name = "RANGE")]
74 pub peg_range: f64,
75
76 #[arg(long, default_value = "0.2", value_name = "RATIO")]
78 pub min_bid_ask_ratio: f64,
79
80 #[arg(long, default_value = "5.0", value_name = "RATIO")]
82 pub max_bid_ask_ratio: f64,
83
84 #[arg(short, long, default_value = "text")]
86 pub format: SummaryFormat,
87
88 #[arg(long, value_name = "INTERVAL")]
91 pub every: Option<String>,
92
93 #[arg(long, value_name = "DURATION")]
96 pub duration: Option<String>,
97
98 #[arg(long, value_name = "PATH")]
100 pub report: Option<std::path::PathBuf>,
101
102 #[arg(long, value_name = "PATH")]
104 pub csv: Option<std::path::PathBuf>,
105}
106
107#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
108pub enum SummaryFormat {
109 #[default]
111 Text,
112 Json,
114}
115
116pub async fn run(
118 args: MarketCommands,
119 _config: &Config,
120 factory: &dyn ChainClientFactory,
121) -> Result<()> {
122 match args {
123 MarketCommands::Summary(summary_args) => run_summary(summary_args, factory).await,
124 }
125}
126
127fn base_symbol_from_pair(pair: &str) -> &str {
130 let p = pair.trim();
131 if let Some(i) = p.find("_USDT") {
132 return &p[..i];
133 }
134 if let Some(i) = p.find("_usdt") {
135 return &p[..i];
136 }
137 if let Some(i) = p.find("/USDT") {
138 return &p[..i];
139 }
140 if p.to_uppercase().ends_with("USDT") && p.len() > 4 {
141 return &p[..p.len() - 4];
142 }
143 p
144}
145
146fn parse_duration(s: &str) -> Result<u64> {
147 let s = s.trim();
148 if s.is_empty() {
149 return Err(ScopeError::Chain("Empty duration".to_string()));
150 }
151 let (num_str, unit) = s
152 .char_indices()
153 .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
154 .map(|(i, _)| (&s[..i], s[i..].trim()))
155 .unwrap_or((s, "s"));
156
157 let num: f64 = num_str
158 .parse()
159 .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
160
161 if num <= 0.0 {
162 return Err(ScopeError::Chain("Duration must be positive".to_string()));
163 }
164
165 let secs = match unit.to_lowercase().as_str() {
166 "s" | "sec" | "secs" | "second" | "seconds" => num,
167 "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
168 "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
169 "d" | "day" | "days" => num * 86400.0,
170 _ => {
171 return Err(ScopeError::Chain(format!(
172 "Unknown duration unit: {}",
173 unit
174 )));
175 }
176 };
177
178 Ok(secs as u64)
179}
180
181fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
183 let bid_dev = summary
184 .best_bid
185 .map(|b| (b - summary.peg_target) / summary.peg_target * 100.0);
186 let ask_dev = summary
187 .best_ask
188 .map(|a| (a - summary.peg_target) / summary.peg_target * 100.0);
189 let volume_row = summary
190 .volume_24h
191 .map(|v| format!("| Volume (24h) | {:.0} USDT | \n", v))
192 .unwrap_or_default();
193 let exec_buy = summary
194 .execution_10k_buy
195 .as_ref()
196 .map(|e| {
197 if e.fillable {
198 format!("{:.2} bps", e.slippage_bps)
199 } else {
200 "insufficient".to_string()
201 }
202 })
203 .unwrap_or_else(|| "-".to_string());
204 let exec_sell = summary
205 .execution_10k_sell
206 .as_ref()
207 .map(|e| {
208 if e.fillable {
209 format!("{:.2} bps", e.slippage_bps)
210 } else {
211 "insufficient".to_string()
212 }
213 })
214 .unwrap_or_else(|| "-".to_string());
215 let mut md = format!(
216 "# Market Health Report: {} \n\
217 **Venue:** {} \n\
218 **Generated:** {} \n\n\
219 ## Peg & Spread \n\
220 | Metric | Value | \n\
221 |--------|-------| \n\
222 | Peg Target | {:.4} | \n\
223 | Best Bid | {} | \n\
224 | Best Ask | {} | \n\
225 | Mid Price | {} | \n\
226 | Spread | {} | \n\
227 {}\
228 | 10k Buy Slippage | {} | \n\
229 | 10k Sell Slippage | {} | \n\
230 | Bid Depth | {:.0} | \n\
231 | Ask Depth | {:.0} | \n\
232 | Healthy | {} | \n\n\
233 ## Health Checks \n",
234 pair,
235 venue,
236 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
237 summary.peg_target,
238 summary
239 .best_bid
240 .map(|b| format!("{:.4} ({:+.3}%)", b, bid_dev.unwrap_or(0.0)))
241 .unwrap_or_else(|| "-".to_string()),
242 summary
243 .best_ask
244 .map(|a| format!("{:.4} ({:+.3}%)", a, ask_dev.unwrap_or(0.0)))
245 .unwrap_or_else(|| "-".to_string()),
246 summary
247 .mid_price
248 .map(|m| format!("{:.4}", m))
249 .unwrap_or_else(|| "-".to_string()),
250 summary
251 .spread
252 .map(|s| format!("{:.4}", s))
253 .unwrap_or_else(|| "-".to_string()),
254 volume_row,
255 exec_buy,
256 exec_sell,
257 summary.bid_depth,
258 summary.ask_depth,
259 if summary.healthy { "✓" } else { "✗" }
260 );
261 for check in &summary.checks {
262 let (icon, msg) = match check {
263 crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
264 crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
265 };
266 md.push_str(&format!("- {} {}\n", icon, msg));
267 }
268 md.push_str(&crate::display::report::report_footer());
269 md
270}
271
272async fn fetch_book_and_volume(
273 args: &SummaryArgs,
274 factory: &dyn ChainClientFactory,
275) -> Result<(OrderBook, Option<f64>)> {
276 let base = base_symbol_from_pair(&args.pair).to_string();
277
278 if args.market_venue.is_cex() {
279 let client: Box<dyn OrderBookClient> = match args.market_venue {
280 MarketVenue::Biconomy if args.biconomy_api_url.is_some() => {
281 Box::new(BiconomyClient::new(args.biconomy_api_url.as_ref().unwrap()))
282 }
283 _ => args
284 .market_venue
285 .create_client()
286 .ok_or_else(|| ScopeError::Chain("No CEX client for venue".to_string()))?,
287 };
288 let pair = args.market_venue.format_pair(&base);
289 let book = client.fetch_order_book(&pair).await?;
290
291 let volume = match args.market_venue {
292 MarketVenue::Binance => {
293 let binance = BinanceClient::default_url();
294 binance.fetch_24h_volume(&pair).await.ok().flatten()
295 }
296 MarketVenue::Biconomy => None,
297 _ => None,
298 };
299 Ok((book, volume))
300 } else {
301 let chain = match args.market_venue {
302 MarketVenue::Ethereum => "ethereum",
303 MarketVenue::Solana => "solana",
304 _ => &args.chain,
305 };
306 let analytics =
307 crawl::fetch_analytics_for_input(&base, chain, Period::Hour24, 10, factory).await?;
308 if analytics.dex_pairs.is_empty() {
309 return Err(ScopeError::Chain(format!(
310 "No DEX pairs found for {} on {}",
311 base, chain
312 )));
313 }
314 let best_pair = analytics
315 .dex_pairs
316 .iter()
317 .max_by(|a, b| {
318 a.liquidity_usd
319 .partial_cmp(&b.liquidity_usd)
320 .unwrap_or(std::cmp::Ordering::Equal)
321 })
322 .unwrap();
323 let book = order_book_from_analytics(chain, best_pair, &analytics.token.symbol);
324 let volume = Some(best_pair.volume_24h);
325 Ok((book, volume))
326 }
327}
328
329async fn run_summary_once(
330 args: &SummaryArgs,
331 factory: &dyn ChainClientFactory,
332 thresholds: &HealthThresholds,
333 run_num: Option<u64>,
334) -> Result<MarketSummary> {
335 if let Some(n) = run_num {
336 let ts = chrono::Utc::now().format("%H:%M:%S");
337 eprintln!(" --- Run #{} at {} ---\n", n, ts);
338 }
339
340 let (book, volume_24h) = fetch_book_and_volume(args, factory).await?;
341 let summary = MarketSummary::from_order_book(&book, args.peg, thresholds, volume_24h);
342
343 let venue_label = format!("{:?}", args.market_venue).to_lowercase();
344
345 match args.format {
346 SummaryFormat::Text => {
347 print!("{}", summary.format_text(Some(&venue_label)));
348 }
349 SummaryFormat::Json => {
350 let json = serde_json::json!({
351 "run": run_num,
352 "venue": venue_label,
353 "pair": summary.pair,
354 "peg_target": summary.peg_target,
355 "best_bid": summary.best_bid,
356 "best_ask": summary.best_ask,
357 "mid_price": summary.mid_price,
358 "spread": summary.spread,
359 "volume_24h": summary.volume_24h,
360 "execution_10k_buy": summary.execution_10k_buy.as_ref().map(|e| serde_json::json!({
361 "fillable": e.fillable,
362 "slippage_bps": e.slippage_bps
363 })),
364 "execution_10k_sell": summary.execution_10k_sell.as_ref().map(|e| serde_json::json!({
365 "fillable": e.fillable,
366 "slippage_bps": e.slippage_bps
367 })),
368 "ask_depth": summary.ask_depth,
369 "bid_depth": summary.bid_depth,
370 "ask_levels": summary.asks.len(),
371 "bid_levels": summary.bids.len(),
372 "healthy": summary.healthy,
373 "checks": summary.checks.iter().map(|c| match c {
374 crate::market::HealthCheck::Pass(m) => serde_json::json!({"status": "pass", "message": m}),
375 crate::market::HealthCheck::Fail(m) => serde_json::json!({"status": "fail", "message": m}),
376 }).collect::<Vec<_>>(),
377 });
378 println!("{}", serde_json::to_string_pretty(&json)?);
379 }
380 }
381
382 Ok(summary)
383}
384
385async fn run_summary(args: SummaryArgs, factory: &dyn ChainClientFactory) -> Result<()> {
386 let thresholds = HealthThresholds {
387 peg_target: args.peg,
388 peg_range: args.peg_range,
389 min_levels: args.min_levels,
390 min_depth: args.min_depth,
391 min_bid_ask_ratio: args.min_bid_ask_ratio,
392 max_bid_ask_ratio: args.max_bid_ask_ratio,
393 };
394
395 let repeat_mode = args.every.is_some() || args.duration.is_some();
396
397 if !repeat_mode {
398 let summary = run_summary_once(&args, factory, &thresholds, None).await?;
399 if let Some(ref report_path) = args.report {
400 let venue_label = format!("{:?}", args.market_venue).to_lowercase();
401 let md = market_summary_to_markdown(&summary, &venue_label, &args.pair);
402 std::fs::write(report_path, md)?;
403 eprintln!("\nReport saved to: {}", report_path.display());
404 }
405 return Ok(());
406 }
407
408 let every_secs = args
409 .every
410 .as_ref()
411 .map(|s| parse_duration(s))
412 .transpose()?
413 .unwrap_or(DEFAULT_EVERY_SECS);
414
415 let duration_secs = args
416 .duration
417 .as_ref()
418 .map(|s| parse_duration(s))
419 .transpose()?
420 .unwrap_or(DEFAULT_DURATION_SECS);
421
422 if every_secs == 0 {
423 return Err(ScopeError::Chain("Interval must be positive".to_string()));
424 }
425
426 let every = Duration::from_secs(every_secs);
427 let start = std::time::Instant::now();
428 let duration = Duration::from_secs(duration_secs);
429
430 eprintln!(
431 "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
432 every_secs, duration_secs
433 );
434
435 let mut run_num: u64 = 1;
436 #[allow(unused_assignments)]
437 let mut last_summary: Option<MarketSummary> = None;
438
439 if let Some(ref csv_path) = args.csv {
441 let header =
442 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy\n";
443 std::fs::write(csv_path, header)?;
444 }
445
446 loop {
447 let summary = run_summary_once(&args, factory, &thresholds, Some(run_num)).await?;
448 last_summary = Some(summary.clone());
449
450 if let Some(ref csv_path) = args.csv {
452 let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
453 let bid = summary
454 .best_bid
455 .map(|v| v.to_string())
456 .unwrap_or_else(|| "-".to_string());
457 let ask = summary
458 .best_ask
459 .map(|v| v.to_string())
460 .unwrap_or_else(|| "-".to_string());
461 let mid = summary
462 .mid_price
463 .map(|v| v.to_string())
464 .unwrap_or_else(|| "-".to_string());
465 let spread = summary
466 .spread
467 .map(|v| v.to_string())
468 .unwrap_or_else(|| "-".to_string());
469 let row = format!(
470 "{},{},{},{},{},{},{},{},{}\n",
471 ts,
472 run_num,
473 bid,
474 ask,
475 mid,
476 spread,
477 summary.bid_depth,
478 summary.ask_depth,
479 summary.healthy
480 );
481 let mut f = std::fs::OpenOptions::new().append(true).open(csv_path)?;
482 use std::io::Write;
483 f.write_all(row.as_bytes())?;
484 }
485
486 if start.elapsed() >= duration {
487 eprintln!("\nCompleted {} run(s) over {}s.", run_num, duration_secs);
488 break;
489 }
490
491 run_num += 1;
492
493 let remaining = duration.saturating_sub(start.elapsed());
494 let sleep_duration = if remaining < every { remaining } else { every };
495 tokio::time::sleep(sleep_duration).await;
496 }
497
498 if let (Some(ref report_path), Some(summary)) = (args.report, last_summary.as_ref()) {
500 let venue_label = format!("{:?}", args.market_venue).to_lowercase();
501 let md = market_summary_to_markdown(summary, &venue_label, &args.pair);
502 std::fs::write(report_path, md)?;
503 eprintln!("Report saved to: {}", report_path.display());
504 }
505 if let Some(ref csv_path) = args.csv {
506 eprintln!("Time-series CSV saved to: {}", csv_path.display());
507 }
508
509 Ok(())
510}
511
512#[cfg(test)]
513mod tests {
514 use super::*;
515 use crate::chains::DefaultClientFactory;
516
517 #[tokio::test]
518 async fn test_run_summary_with_mock_orderbook() {
519 let mut server = mockito::Server::new_async().await;
520 let orderbook_body = r#"{"asks":[["1.0001","500"],["1.0002","300"]],"bids":[["0.9999","600"],["0.9998","400"]]}"#;
521 let _mock = server
522 .mock(
523 "GET",
524 mockito::Matcher::Regex(r"^/api/v1/depth\?symbol=.*$".to_string()),
525 )
526 .with_status(200)
527 .with_header("content-type", "application/json")
528 .with_body(orderbook_body)
529 .create();
530
531 let args = SummaryArgs {
532 pair: "PUSD".to_string(),
533 market_venue: MarketVenue::Biconomy,
534 chain: "ethereum".to_string(),
535 biconomy_api_url: Some(server.url()),
536 peg: 1.0,
537 min_levels: 2,
538 min_depth: 100.0,
539 peg_range: 0.001,
540 min_bid_ask_ratio: 0.2,
541 max_bid_ask_ratio: 5.0,
542 format: SummaryFormat::Text,
543 every: None,
544 duration: None,
545 report: None,
546 csv: None,
547 };
548
549 let factory = DefaultClientFactory {
550 chains_config: Default::default(),
551 };
552 let result = run_summary(args, &factory).await;
553 assert!(result.is_ok());
554 }
555
556 #[tokio::test]
557 async fn test_run_summary_json_format() {
558 let mut server = mockito::Server::new_async().await;
559 let orderbook_body = r#"{"asks":[["1.0001","100"]],"bids":[["0.9999","100"]]}"#;
560 let _mock = server
561 .mock(
562 "GET",
563 mockito::Matcher::Regex(r"^/api/v1/depth\?symbol=.*$".to_string()),
564 )
565 .with_status(200)
566 .with_header("content-type", "application/json")
567 .with_body(orderbook_body)
568 .create();
569
570 let args = SummaryArgs {
571 pair: "PUSD".to_string(),
572 market_venue: MarketVenue::Biconomy,
573 chain: "ethereum".to_string(),
574 biconomy_api_url: Some(server.url()),
575 peg: 1.0,
576 min_levels: 1,
577 min_depth: 50.0,
578 peg_range: 0.001,
579 min_bid_ask_ratio: 0.1,
580 max_bid_ask_ratio: 10.0,
581 format: SummaryFormat::Json,
582 every: None,
583 duration: None,
584 report: None,
585 csv: None,
586 };
587
588 let factory = DefaultClientFactory {
589 chains_config: Default::default(),
590 };
591 let result = run_summary(args, &factory).await;
592 assert!(result.is_ok());
593 }
594
595 #[test]
596 fn test_parse_duration_seconds() {
597 assert_eq!(parse_duration("30s").unwrap(), 30);
598 assert_eq!(parse_duration("1").unwrap(), 1);
599 assert_eq!(parse_duration("60sec").unwrap(), 60);
600 }
601
602 #[test]
603 fn test_parse_duration_minutes() {
604 assert_eq!(parse_duration("5m").unwrap(), 300);
605 assert_eq!(parse_duration("1min").unwrap(), 60);
606 assert_eq!(parse_duration("2.5m").unwrap(), 150);
607 }
608
609 #[test]
610 fn test_parse_duration_hours() {
611 assert_eq!(parse_duration("1h").unwrap(), 3600);
612 assert_eq!(parse_duration("24h").unwrap(), 86400);
613 }
614
615 #[test]
616 fn test_parse_duration_invalid() {
617 assert!(parse_duration("").is_err());
618 assert!(parse_duration("abc").is_err());
619 assert!(parse_duration("30x").is_err());
620 }
621
622 #[test]
623 fn test_parse_duration_non_positive() {
624 assert!(parse_duration("0").is_err());
625 assert!(parse_duration("-5s").is_err());
626 }
627
628 #[test]
633 fn test_base_symbol_from_pair_underscore() {
634 assert_eq!(base_symbol_from_pair("PUSD_USDT"), "PUSD");
635 assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
636 }
637
638 #[test]
639 fn test_base_symbol_from_pair_lowercase_underscore() {
640 assert_eq!(base_symbol_from_pair("pusd_usdt"), "pusd");
641 }
642
643 #[test]
644 fn test_base_symbol_from_pair_slash() {
645 assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
646 }
647
648 #[test]
649 fn test_base_symbol_from_pair_concat() {
650 assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
651 assert_eq!(base_symbol_from_pair("PUSDUSDT"), "PUSD");
652 }
653
654 #[test]
655 fn test_base_symbol_from_pair_plain() {
656 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
657 assert_eq!(base_symbol_from_pair("ETH"), "ETH");
658 }
659
660 #[test]
661 fn test_base_symbol_from_pair_whitespace() {
662 assert_eq!(base_symbol_from_pair(" PUSD_USDT "), "PUSD");
663 }
664
665 #[test]
670 fn test_market_summary_to_markdown_basic() {
671 use crate::market::{HealthCheck, MarketSummary};
672 let summary = MarketSummary {
673 pair: "USDCUSDT".to_string(),
674 peg_target: 1.0,
675 best_bid: Some(0.9999),
676 best_ask: Some(1.0001),
677 mid_price: Some(1.0000),
678 spread: Some(0.0002),
679 volume_24h: Some(1_000_000.0),
680 bid_depth: 50_000.0,
681 ask_depth: 50_000.0,
682 bid_outliers: 0,
683 ask_outliers: 0,
684 healthy: true,
685 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
686 execution_10k_buy: None,
687 execution_10k_sell: None,
688 asks: vec![],
689 bids: vec![],
690 };
691 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
692 assert!(md.contains("Market Health Report"));
693 assert!(md.contains("USDCUSDT"));
694 assert!(md.contains("Binance"));
695 assert!(md.contains("Peg Target"));
696 assert!(md.contains("1.0000"));
697 assert!(md.contains("Healthy"));
698 }
699
700 #[test]
701 fn test_market_summary_to_markdown_no_prices() {
702 use crate::market::{HealthCheck, MarketSummary};
703 let summary = MarketSummary {
704 pair: "TESTUSDT".to_string(),
705 peg_target: 1.0,
706 best_bid: None,
707 best_ask: None,
708 mid_price: None,
709 spread: None,
710 volume_24h: None,
711 bid_depth: 0.0,
712 ask_depth: 0.0,
713 bid_outliers: 0,
714 ask_outliers: 0,
715 healthy: false,
716 checks: vec![HealthCheck::Fail("No data".to_string())],
717 execution_10k_buy: None,
718 execution_10k_sell: None,
719 asks: vec![],
720 bids: vec![],
721 };
722 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
723 assert!(md.contains("Market Health Report"));
724 assert!(md.contains("-")); }
726
727 #[test]
732 fn test_parse_duration_days() {
733 assert_eq!(parse_duration("1d").unwrap(), 86400);
734 assert_eq!(parse_duration("7d").unwrap(), 604800);
735 assert_eq!(parse_duration("1day").unwrap(), 86400);
736 assert_eq!(parse_duration("2days").unwrap(), 172800);
737 }
738
739 #[test]
740 fn test_parse_duration_long_names() {
741 assert_eq!(parse_duration("30seconds").unwrap(), 30);
742 assert_eq!(parse_duration("5minutes").unwrap(), 300);
743 assert_eq!(parse_duration("2hours").unwrap(), 7200);
744 }
745
746 #[test]
747 fn test_parse_duration_fractional() {
748 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
749 assert_eq!(parse_duration("1.5m").unwrap(), 90);
750 }
751
752 #[test]
757 fn test_summary_format_default() {
758 let fmt = SummaryFormat::default();
759 assert!(matches!(fmt, SummaryFormat::Text));
760 }
761
762 #[test]
763 fn test_summary_format_debug() {
764 let text = format!("{:?}", SummaryFormat::Text);
765 assert_eq!(text, "Text");
766 let json = format!("{:?}", SummaryFormat::Json);
767 assert_eq!(json, "Json");
768 }
769
770 #[test]
775 fn test_summary_args_debug() {
776 let args = SummaryArgs {
777 pair: "USDC".to_string(),
778 market_venue: MarketVenue::Binance,
779 chain: "ethereum".to_string(),
780 biconomy_api_url: None,
781 peg: 1.0,
782 min_levels: 6,
783 min_depth: 3000.0,
784 peg_range: 0.001,
785 min_bid_ask_ratio: 0.2,
786 max_bid_ask_ratio: 5.0,
787 format: SummaryFormat::Text,
788 every: None,
789 duration: None,
790 report: None,
791 csv: None,
792 };
793 let debug = format!("{:?}", args);
794 assert!(debug.contains("SummaryArgs"));
795 assert!(debug.contains("USDC"));
796 }
797
798 #[test]
799 fn test_default_constants() {
800 assert_eq!(DEFAULT_EVERY_SECS, 60);
801 assert_eq!(DEFAULT_DURATION_SECS, 3600);
802 }
803
804 #[test]
805 fn test_market_summary_to_markdown_with_execution_estimates() {
806 use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
807 let summary = MarketSummary {
808 pair: "TESTUSDT".to_string(),
809 peg_target: 1.0,
810 best_bid: Some(0.9999),
811 best_ask: Some(1.0001),
812 mid_price: Some(1.0000),
813 spread: Some(0.0002),
814 volume_24h: Some(1_000_000.0),
815 bid_depth: 50_000.0,
816 ask_depth: 50_000.0,
817 bid_outliers: 0,
818 ask_outliers: 0,
819 healthy: true,
820 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
821 execution_10k_buy: Some(ExecutionEstimate {
822 notional_usdt: 10_000.0,
823 side: ExecutionSide::Buy,
824 vwap: 1.0001,
825 slippage_bps: 1.5,
826 fillable: true,
827 }),
828 execution_10k_sell: Some(ExecutionEstimate {
829 notional_usdt: 10_000.0,
830 side: ExecutionSide::Sell,
831 vwap: 0.0,
832 slippage_bps: 0.0,
833 fillable: false,
834 }),
835 asks: vec![],
836 bids: vec![],
837 };
838 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
839 assert!(md.contains("Market Health Report"));
840 assert!(md.contains("TESTUSDT"));
841 assert!(md.contains("TestVenue"));
842 assert!(md.contains("1.50 bps"));
844 assert!(md.contains("insufficient"));
846 }
847
848 #[tokio::test]
849 async fn test_run_with_summary_command() {
850 let mut server = mockito::Server::new_async().await;
851 let orderbook_body = r#"{"asks":[["1.0001","500"],["1.0002","300"]],"bids":[["0.9999","600"],["0.9998","400"]]}"#;
852 let _mock = server
853 .mock(
854 "GET",
855 mockito::Matcher::Regex(r"^/api/v1/depth\?symbol=.*$".to_string()),
856 )
857 .with_status(200)
858 .with_header("content-type", "application/json")
859 .with_body(orderbook_body)
860 .create();
861
862 let args = MarketCommands::Summary(SummaryArgs {
863 pair: "PUSD".to_string(),
864 market_venue: MarketVenue::Biconomy,
865 chain: "ethereum".to_string(),
866 biconomy_api_url: Some(server.url()),
867 peg: 1.0,
868 min_levels: 2,
869 min_depth: 100.0,
870 peg_range: 0.001,
871 min_bid_ask_ratio: 0.2,
872 max_bid_ask_ratio: 5.0,
873 format: SummaryFormat::Text,
874 every: None,
875 duration: None,
876 report: None,
877 csv: None,
878 });
879
880 let factory = DefaultClientFactory {
881 chains_config: Default::default(),
882 };
883 let config = Config::default();
884 let result = run(args, &config, &factory).await;
885 assert!(result.is_ok());
886 }
887
888 #[tokio::test]
889 async fn test_run_summary_with_report_output() {
890 let mut server = mockito::Server::new_async().await;
891 let orderbook_body = r#"{"asks":[["1.0001","500"],["1.0002","300"]],"bids":[["0.9999","600"],["0.9998","400"]]}"#;
892 let _mock = server
893 .mock(
894 "GET",
895 mockito::Matcher::Regex(r"^/api/v1/depth\?symbol=.*$".to_string()),
896 )
897 .with_status(200)
898 .with_header("content-type", "application/json")
899 .with_body(orderbook_body)
900 .create();
901
902 let temp_dir = tempfile::tempdir().unwrap();
903 let report_path = temp_dir.path().join("report.md");
904
905 let args = SummaryArgs {
906 pair: "PUSD".to_string(),
907 market_venue: MarketVenue::Biconomy,
908 chain: "ethereum".to_string(),
909 biconomy_api_url: Some(server.url()),
910 peg: 1.0,
911 min_levels: 2,
912 min_depth: 100.0,
913 peg_range: 0.001,
914 min_bid_ask_ratio: 0.2,
915 max_bid_ask_ratio: 5.0,
916 format: SummaryFormat::Text,
917 every: None,
918 duration: None,
919 report: Some(report_path.clone()),
920 csv: None,
921 };
922
923 let factory = DefaultClientFactory {
924 chains_config: Default::default(),
925 };
926 let result = run_summary(args, &factory).await;
927 assert!(result.is_ok());
928 assert!(report_path.exists());
930 let content = std::fs::read_to_string(&report_path).unwrap();
931 assert!(content.contains("Market Health Report"));
932 }
933
934 #[tokio::test]
935 async fn test_run_summary_json_format_with_mock() {
936 let mut server = mockito::Server::new_async().await;
937 let orderbook_body = r#"{"asks":[["1.0001","500"]],"bids":[["0.9999","600"]]}"#;
938 let _mock = server
939 .mock(
940 "GET",
941 mockito::Matcher::Regex(r"^/api/v1/depth\?symbol=.*$".to_string()),
942 )
943 .with_status(200)
944 .with_header("content-type", "application/json")
945 .with_body(orderbook_body)
946 .create();
947
948 let args = SummaryArgs {
949 pair: "PUSD".to_string(),
950 market_venue: MarketVenue::Biconomy,
951 chain: "ethereum".to_string(),
952 biconomy_api_url: Some(server.url()),
953 peg: 1.0,
954 min_levels: 1,
955 min_depth: 50.0,
956 peg_range: 0.001,
957 min_bid_ask_ratio: 0.1,
958 max_bid_ask_ratio: 10.0,
959 format: SummaryFormat::Json,
960 every: None,
961 duration: None,
962 report: None,
963 csv: None,
964 };
965
966 let factory = DefaultClientFactory {
967 chains_config: Default::default(),
968 };
969 let result = run_summary(args, &factory).await;
971 assert!(result.is_ok());
972 }
973}