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