1use crate::cli::crawl::{self, Period};
9use clap::{Args, Subcommand};
10use scope::chains::ChainClientFactory;
11use scope::config::Config;
12
13use super::health_args::HealthArgs;
14use scope::display::terminal as t;
15use scope::error::{Result, ScopeError};
16use scope::market::{
17 HealthThresholds, MarketSummary, OrderBook, VenueRegistry, order_book_from_analytics,
18};
19use std::time::Duration;
20
21pub const DEFAULT_EVERY_SECS: u64 = 60;
23
24pub const DEFAULT_DURATION_SECS: u64 = 3600;
26
27#[derive(Debug, Subcommand)]
29#[allow(clippy::large_enum_variant)]
30pub enum MarketCommands {
31 Summary(SummaryArgs),
38
39 Ohlc(OhlcArgs),
41
42 Trades(TradesArgs),
44}
45
46#[derive(Debug, Args)]
51#[command(
52 after_help = "\x1b[1mExamples:\x1b[0m
53 scope market summary DAI --venue binance
54 scope market summary @dai-token --venue binance \x1b[2m# address book shortcut\x1b[0m
55 scope market summary USDC --venue binance --format json
56 scope market summary DAI --venue binance --every 30s --duration 1h
57 scope market summary DAI --venue binance --report health.md --csv peg.csv",
58 after_long_help = "\x1b[1mExamples:\x1b[0m
59
60 \x1b[1m$ scope market summary DAI --venue binance\x1b[0m
61
62 +-- DAI/USDT (binance) ----------------------------+
63 | |
64 |-- Metrics |
65 | Best Bid 0.9999 (-0.010%) |
66 | Best Ask 1.0001 (+0.010%) |
67 | Mid Price 1.0000 (+0.000%) |
68 | Spread 0.0002 (0.020%) |
69 | Volume (24h) 125000 USDT |
70 | |
71 |-- Health Checks |
72 | + No sells below peg |
73 | + Bid/Ask ratio: 0.93x |
74 | + Bid levels: 12 >= 10 minimum |
75 | + Bid depth: 42000 USDT >= 3000 USDT minimum |
76 | |
77 | HEALTHY |
78 +-----------------------------------------------------+
79
80 \x1b[1m$ scope market summary DAI --venue binance --every 30s --duration 1h\x1b[0m
81
82 Monitoring DAI/USDT (binance) every 30s for 1h...
83 [2026-02-16 10:00:00] Mid=1.0000 Spread=0.020% Depth=42K/45K HEALTHY
84 [2026-02-16 10:00:30] Mid=1.0000 Spread=0.020% Depth=42K/44K HEALTHY
85 [2026-02-16 10:01:00] Mid=0.9999 Spread=0.030% Depth=41K/44K HEALTHY
86 ..."
87)]
88pub struct SummaryArgs {
89 #[arg(default_value = "USDC", value_name = "SYMBOL")]
91 pub pair: String,
92
93 #[arg(long, default_value = "binance", value_name = "VENUE")]
96 pub venue: String,
97
98 #[arg(long, default_value = "ethereum", value_name = "CHAIN")]
100 pub chain: String,
101
102 #[command(flatten)]
104 pub health: HealthArgs,
105
106 #[arg(short, long, default_value = "text")]
108 pub format: SummaryFormat,
109
110 #[arg(long, value_name = "INTERVAL")]
113 pub every: Option<String>,
114
115 #[arg(long, value_name = "DURATION")]
118 pub duration: Option<String>,
119
120 #[arg(long, value_name = "PATH")]
122 pub report: Option<std::path::PathBuf>,
123
124 #[arg(long, value_name = "PATH")]
126 pub csv: Option<std::path::PathBuf>,
127}
128
129#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
130pub enum SummaryFormat {
131 #[default]
133 Text,
134 Json,
136}
137
138#[derive(Debug, Args)]
140#[command(
141 after_help = "\x1b[1mExamples:\x1b[0m
142 scope market ohlc BTC
143 scope market ohlc DAI --venue binance --interval 1d
144 scope market ohlc ETH --venue mexc --limit 50 --format json",
145 after_long_help = "\x1b[1mExamples:\x1b[0m
146
147 \x1b[1m$ scope market ohlc BTC --limit 5\x1b[0m
148
149 OHLC -- BTCUSDT (binance) interval=1h limit=5
150 --------------------------------------------------------
151 Open Time Open High Low Close Volume
152 --------------------------------------------------------
153 2026-02-16 09:00 97250.120000 97380.540000 97210.980000 97345.670000 1234.56
154 2026-02-16 08:00 97100.890000 97260.120000 97080.340000 97250.120000 1456.78
155 2026-02-16 07:00 96950.230000 97120.890000 96920.560000 97100.890000 1678.90
156 ...
157
158 5 candles returned
159
160 \x1b[1m$ scope market ohlc BTC --format json --limit 2\x1b[0m
161
162 [
163 {
164 \"open_time\": 1739696400000,
165 \"open\": 97250.12,
166 \"high\": 97380.54,
167 \"low\": 97210.98,
168 \"close\": 97345.67,
169 \"volume\": 1234.56,
170 \"close_time\": null
171 },
172 ...
173 ]"
174)]
175pub struct OhlcArgs {
176 #[arg(default_value = "USDC", value_name = "SYMBOL")]
178 pub pair: String,
179
180 #[arg(long, default_value = "binance", value_name = "VENUE")]
182 pub venue: String,
183
184 #[arg(long, default_value = "1h", value_name = "INTERVAL")]
186 pub interval: String,
187
188 #[arg(long, default_value = "100", value_name = "LIMIT")]
190 pub limit: u32,
191
192 #[arg(long, default_value = "text")]
194 pub format: OhlcFormat,
195}
196
197#[derive(Debug, Args)]
199#[command(after_help = "\x1b[1mExamples:\x1b[0m
200 scope market trades BTC
201 scope market trades DAI --venue binance --limit 20
202 scope market trades ETH --venue okx --format json")]
203pub struct TradesArgs {
204 #[arg(default_value = "USDC", value_name = "SYMBOL")]
206 pub pair: String,
207
208 #[arg(long, default_value = "binance", value_name = "VENUE")]
210 pub venue: String,
211
212 #[arg(long, default_value = "50", value_name = "LIMIT")]
214 pub limit: u32,
215
216 #[arg(long, default_value = "text")]
218 pub format: OhlcFormat,
219}
220
221#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
223pub enum OhlcFormat {
224 #[default]
226 Text,
227 Json,
229}
230
231pub async fn run(
233 args: MarketCommands,
234 config: &Config,
235 factory: &dyn ChainClientFactory,
236) -> Result<()> {
237 match args {
238 MarketCommands::Summary(summary_args) => run_summary(summary_args, config, factory).await,
239 MarketCommands::Ohlc(ohlc_args) => run_ohlc(ohlc_args).await,
240 MarketCommands::Trades(trades_args) => run_trades(trades_args).await,
241 }
242}
243
244fn normalize_pair_input(pair: &str) -> &str {
252 let p = pair.trim();
253 if let Some((lhs, rhs)) = p.split_once('=')
254 && !rhs.is_empty()
255 && lhs
256 .chars()
257 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
258 {
259 return rhs;
260 }
261 p
262}
263
264fn parse_pair_components(pair: &str) -> (&str, Option<&str>) {
271 let p = normalize_pair_input(pair);
272
273 for sep in ['_', '/', '-'] {
274 if let Some((base, quote)) = p.split_once(sep)
275 && !base.is_empty()
276 && !quote.is_empty()
277 {
278 return (base, Some(quote));
279 }
280 }
281
282 const COMMON_QUOTES: [&str; 7] = ["USDT", "USDC", "USD", "BTC", "ETH", "EUR", "TRY"];
283 for quote in COMMON_QUOTES {
284 if p.len() > quote.len() && p.to_uppercase().ends_with(quote) {
285 let base_len = p.len() - quote.len();
286 return (&p[..base_len], Some(quote));
287 }
288 }
289
290 (p, None)
291}
292
293#[cfg(test)]
295fn base_symbol_from_pair(pair: &str) -> &str {
296 parse_pair_components(pair).0
297}
298
299fn parse_duration(s: &str) -> Result<u64> {
300 let s = s.trim();
301 if s.is_empty() {
302 return Err(ScopeError::Chain("Empty duration".to_string()));
303 }
304 let (num_str, unit) = s
305 .char_indices()
306 .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
307 .map(|(i, _)| (&s[..i], s[i..].trim()))
308 .unwrap_or((s, "s"));
309
310 let num: f64 = num_str
311 .parse()
312 .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
313
314 if num <= 0.0 {
315 return Err(ScopeError::Chain("Duration must be positive".to_string()));
316 }
317
318 let secs = match unit.to_lowercase().as_str() {
319 "s" | "sec" | "secs" | "second" | "seconds" => num,
320 "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
321 "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
322 "d" | "day" | "days" => num * 86400.0,
323 _ => {
324 return Err(ScopeError::Chain(format!(
325 "Unknown duration unit: {}",
326 unit
327 )));
328 }
329 };
330
331 Ok(secs as u64)
332}
333
334fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
336 let bid_dev = summary
337 .best_bid
338 .map(|b| (b - summary.peg_target) / summary.peg_target * 100.0);
339 let ask_dev = summary
340 .best_ask
341 .map(|a| (a - summary.peg_target) / summary.peg_target * 100.0);
342 let volume_row = summary
343 .volume_24h
344 .map(|v| format!("| Volume (24h) | {:.0} USDT | \n", v))
345 .unwrap_or_default();
346 let exec_buy = summary
347 .execution_10k_buy
348 .as_ref()
349 .map(|e| {
350 if e.fillable {
351 format!("{:.2} bps", e.slippage_bps)
352 } else {
353 "insufficient".to_string()
354 }
355 })
356 .unwrap_or_else(|| "-".to_string());
357 let exec_sell = summary
358 .execution_10k_sell
359 .as_ref()
360 .map(|e| {
361 if e.fillable {
362 format!("{:.2} bps", e.slippage_bps)
363 } else {
364 "insufficient".to_string()
365 }
366 })
367 .unwrap_or_else(|| "-".to_string());
368 let mut md = format!(
369 "# Market Health Report: {} \n\
370 **Venue:** {} \n\
371 **Generated:** {} \n\n\
372 ## Peg & Spread \n\
373 | Metric | Value | \n\
374 |--------|-------| \n\
375 | Peg Target | {:.4} | \n\
376 | Best Bid | {} | \n\
377 | Best Ask | {} | \n\
378 | Mid Price | {} | \n\
379 | Spread | {} | \n\
380 {}\
381 | 10k Buy Slippage | {} | \n\
382 | 10k Sell Slippage | {} | \n\
383 | Bid Depth | {:.0} | \n\
384 | Ask Depth | {:.0} | \n\
385 | Healthy | {} | \n\n\
386 ## Health Checks \n",
387 pair,
388 venue,
389 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
390 summary.peg_target,
391 summary
392 .best_bid
393 .map(|b| format!("{:.4} ({:+.3}%)", b, bid_dev.unwrap_or(0.0)))
394 .unwrap_or_else(|| "-".to_string()),
395 summary
396 .best_ask
397 .map(|a| format!("{:.4} ({:+.3}%)", a, ask_dev.unwrap_or(0.0)))
398 .unwrap_or_else(|| "-".to_string()),
399 summary
400 .mid_price
401 .map(|m| format!("{:.4}", m))
402 .unwrap_or_else(|| "-".to_string()),
403 summary
404 .spread
405 .map(|s| format!("{:.4}", s))
406 .unwrap_or_else(|| "-".to_string()),
407 volume_row,
408 exec_buy,
409 exec_sell,
410 summary.bid_depth,
411 summary.ask_depth,
412 if summary.healthy { "✓" } else { "✗" }
413 );
414 for check in &summary.checks {
415 let (icon, msg) = match check {
416 scope::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
417 scope::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
418 };
419 md.push_str(&format!("- {} {}\n", icon, msg));
420 }
421 md.push_str(&scope::display::report::report_footer());
422 md
423}
424
425fn is_dex_venue(venue: &str) -> bool {
427 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
428}
429
430fn dex_venue_to_chain(venue: &str) -> &str {
432 match venue.to_lowercase().as_str() {
433 "ethereum" | "eth" => "ethereum",
434 "solana" => "solana",
435 _ => "ethereum",
436 }
437}
438
439async fn fetch_book_and_volume(
440 args: &SummaryArgs,
441 factory: &dyn ChainClientFactory,
442) -> Result<(OrderBook, Option<f64>)> {
443 let (base, quote) = parse_pair_components(&args.pair);
444 let base = base.to_string();
445
446 if is_dex_venue(&args.venue) {
447 let chain = dex_venue_to_chain(&args.venue);
449 let analytics =
450 crawl::fetch_analytics_for_input(&base, chain, Period::Hour24, 10, factory, None)
451 .await?;
452 if analytics.dex_pairs.is_empty() {
453 return Err(ScopeError::Chain(format!(
454 "No DEX pairs found for {} on {}",
455 base, chain
456 )));
457 }
458 let best_pair = analytics
459 .dex_pairs
460 .iter()
461 .max_by(|a, b| {
462 a.liquidity_usd
463 .partial_cmp(&b.liquidity_usd)
464 .unwrap_or(std::cmp::Ordering::Equal)
465 })
466 .ok_or_else(|| ScopeError::Chain("No DEX pairs after filter".to_string()))?;
467 let book = order_book_from_analytics(chain, best_pair, &analytics.token.symbol);
468 let volume = Some(best_pair.volume_24h);
469 Ok((book, volume))
470 } else {
471 let registry = VenueRegistry::load()?;
473 let exchange = registry.create_exchange_client(&args.venue)?;
474 let pair = match quote {
475 Some(q) => exchange.format_pair_with_quote(&base, q),
476 None => exchange.format_pair(&base),
477 };
478 let book = exchange.fetch_order_book(&pair).await?;
479
480 let volume = if exchange.has_ticker() {
482 exchange
483 .fetch_ticker(&pair)
484 .await
485 .ok()
486 .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
487 } else {
488 None
489 };
490 Ok((book, volume))
491 }
492}
493
494async fn run_summary_once(
495 args: &SummaryArgs,
496 factory: &dyn ChainClientFactory,
497 thresholds: &HealthThresholds,
498 run_num: Option<u64>,
499) -> Result<MarketSummary> {
500 if let Some(n) = run_num {
501 let ts = chrono::Utc::now().format("%H:%M:%S");
502 eprintln!(" --- Run #{} at {} ---\n", n, ts);
503 }
504
505 let (book, volume_24h) = fetch_book_and_volume(args, factory).await?;
506 let summary = MarketSummary::from_order_book(&book, thresholds, volume_24h);
507
508 let venue_label = args.venue.clone();
509
510 match args.format {
511 SummaryFormat::Text => {
512 print!("{}", summary.format_text(Some(&venue_label)));
513 }
514 SummaryFormat::Json => {
515 let json = serde_json::json!({
516 "run": run_num,
517 "venue": venue_label,
518 "pair": summary.pair,
519 "peg_target": summary.peg_target,
520 "best_bid": summary.best_bid,
521 "best_ask": summary.best_ask,
522 "mid_price": summary.mid_price,
523 "spread": summary.spread,
524 "volume_24h": summary.volume_24h,
525 "execution_10k_buy": summary.execution_10k_buy.as_ref().map(|e| serde_json::json!({
526 "fillable": e.fillable,
527 "slippage_bps": e.slippage_bps
528 })),
529 "execution_10k_sell": summary.execution_10k_sell.as_ref().map(|e| serde_json::json!({
530 "fillable": e.fillable,
531 "slippage_bps": e.slippage_bps
532 })),
533 "ask_depth": summary.ask_depth,
534 "bid_depth": summary.bid_depth,
535 "ask_levels": summary.asks.len(),
536 "bid_levels": summary.bids.len(),
537 "healthy": summary.healthy,
538 "checks": summary.checks.iter().map(|c| match c {
539 scope::market::HealthCheck::Pass(m) => serde_json::json!({"status": "pass", "message": m}),
540 scope::market::HealthCheck::Fail(m) => serde_json::json!({"status": "fail", "message": m}),
541 }).collect::<Vec<_>>(),
542 });
543 println!("{}", serde_json::to_string_pretty(&json)?);
544 }
545 }
546
547 Ok(summary)
548}
549
550async fn run_summary(
551 args: SummaryArgs,
552 config: &Config,
553 factory: &dyn ChainClientFactory,
554) -> Result<()> {
555 let thresholds = args.health.resolve(config);
556
557 let repeat_mode = args.every.is_some() || args.duration.is_some();
558
559 if !repeat_mode {
560 let summary = run_summary_once(&args, factory, &thresholds, None).await?;
561 if let Some(ref report_path) = args.report {
562 let venue_label = args.venue.clone();
563 let md = market_summary_to_markdown(&summary, &venue_label, &args.pair);
564 std::fs::write(report_path, md)?;
565 eprintln!("\nReport saved to: {}", report_path.display());
566 }
567 return Ok(());
568 }
569
570 let every_secs = args
571 .every
572 .as_ref()
573 .map(|s| parse_duration(s))
574 .transpose()?
575 .unwrap_or(DEFAULT_EVERY_SECS);
576
577 let duration_secs = args
578 .duration
579 .as_ref()
580 .map(|s| parse_duration(s))
581 .transpose()?
582 .unwrap_or(DEFAULT_DURATION_SECS);
583
584 if every_secs == 0 {
585 return Err(ScopeError::Chain("Interval must be positive".to_string()));
586 }
587
588 let every = Duration::from_secs(every_secs);
589 let start = std::time::Instant::now();
590 let duration = Duration::from_secs(duration_secs);
591
592 eprintln!(
593 "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
594 every_secs, duration_secs
595 );
596
597 let mut run_num: u64 = 1;
598 #[allow(unused_assignments)]
599 let mut last_summary: Option<MarketSummary> = None;
600
601 if let Some(ref csv_path) = args.csv {
603 let header =
604 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy\n";
605 std::fs::write(csv_path, header)?;
606 }
607
608 loop {
609 let summary = run_summary_once(&args, factory, &thresholds, Some(run_num)).await?;
610 last_summary = Some(summary.clone());
611
612 if let Some(ref csv_path) = args.csv {
614 let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
615 let bid = summary
616 .best_bid
617 .map(|v| v.to_string())
618 .unwrap_or_else(|| "-".to_string());
619 let ask = summary
620 .best_ask
621 .map(|v| v.to_string())
622 .unwrap_or_else(|| "-".to_string());
623 let mid = summary
624 .mid_price
625 .map(|v| v.to_string())
626 .unwrap_or_else(|| "-".to_string());
627 let spread = summary
628 .spread
629 .map(|v| v.to_string())
630 .unwrap_or_else(|| "-".to_string());
631 let row = format!(
632 "{},{},{},{},{},{},{},{},{}\n",
633 ts,
634 run_num,
635 bid,
636 ask,
637 mid,
638 spread,
639 summary.bid_depth,
640 summary.ask_depth,
641 summary.healthy
642 );
643 let mut f = std::fs::OpenOptions::new().append(true).open(csv_path)?;
644 use std::io::Write;
645 f.write_all(row.as_bytes())?;
646 }
647
648 if start.elapsed() >= duration {
649 eprintln!("\nCompleted {} run(s) over {}s.", run_num, duration_secs);
650 break;
651 }
652
653 run_num += 1;
654
655 let remaining = duration.saturating_sub(start.elapsed());
656 let sleep_duration = if remaining < every { remaining } else { every };
657 tokio::time::sleep(sleep_duration).await;
658 }
659
660 if let (Some(ref report_path), Some(summary)) = (args.report, last_summary.as_ref()) {
662 let venue_label = args.venue.clone();
663 let md = market_summary_to_markdown(summary, &venue_label, &args.pair);
664 std::fs::write(report_path, md)?;
665 eprintln!("Report saved to: {}", report_path.display());
666 }
667 if let Some(ref csv_path) = args.csv {
668 eprintln!("Time-series CSV saved to: {}", csv_path.display());
669 }
670
671 Ok(())
672}
673
674async fn run_ohlc(args: OhlcArgs) -> Result<()> {
680 let registry = VenueRegistry::load()?;
681 let descriptor = registry.get(&args.venue).ok_or_else(|| {
682 ScopeError::NotFound(format!(
683 "Venue '{}' not found. Use `scope venues list` to see available venues.",
684 args.venue
685 ))
686 })?;
687
688 let client = scope::market::ExchangeClient::from_descriptor(descriptor);
689 let (base, quote) = parse_pair_components(&args.pair);
690 let pair = match quote {
691 Some(q) => client.format_pair_with_quote(base, q),
692 None => client.format_pair(base),
693 };
694
695 let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
696
697 match args.format {
698 OhlcFormat::Json => {
699 let json_candles: Vec<serde_json::Value> = candles
700 .iter()
701 .map(|c| {
702 serde_json::json!({
703 "open_time": c.open_time,
704 "open": c.open,
705 "high": c.high,
706 "low": c.low,
707 "close": c.close,
708 "volume": c.volume,
709 "close_time": c.close_time,
710 })
711 })
712 .collect();
713 let json_str = serde_json::to_string_pretty(&json_candles)
714 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
715 println!("{json_str}");
716 }
717 OhlcFormat::Text => {
718 println!(
719 "{}",
720 t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
721 );
722 println!("{}", t::kv_row("Interval", &args.interval));
723 println!("{}", t::kv_row("Limit", &args.limit.to_string()));
724
725 let cols = [
726 t::Col {
727 label: "Open Time",
728 width: 19,
729 align: '>',
730 },
731 t::Col {
732 label: "Open",
733 width: 12,
734 align: '>',
735 },
736 t::Col {
737 label: "High",
738 width: 12,
739 align: '>',
740 },
741 t::Col {
742 label: "Low",
743 width: 12,
744 align: '>',
745 },
746 t::Col {
747 label: "Close",
748 width: 12,
749 align: '>',
750 },
751 t::Col {
752 label: "Volume",
753 width: 14,
754 align: '>',
755 },
756 ];
757 println!("{}", t::table_header(&cols));
758
759 for c in &candles {
760 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
761 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
762 .unwrap_or_else(|| format!("{}", c.open_time));
763 let open_str = format!("{:.6}", c.open);
764 let high_str = format!("{:.6}", c.high);
765 let low_str = format!("{:.6}", c.low);
766 let close_str = format!("{:.6}", c.close);
767 let volume_str = format!("{:.2}", c.volume);
768 let values = [
769 dt.as_str(),
770 open_str.as_str(),
771 high_str.as_str(),
772 low_str.as_str(),
773 close_str.as_str(),
774 volume_str.as_str(),
775 ];
776 println!("{}", t::table_row(&cols, &values));
777 }
778
779 println!(
780 "{}",
781 t::info_row(&format!("{} candles returned", candles.len()))
782 );
783 println!("{}", t::section_footer());
784 }
785 }
786 Ok(())
787}
788
789async fn run_trades(args: TradesArgs) -> Result<()> {
795 let registry = VenueRegistry::load()?;
796 let descriptor = registry.get(&args.venue).ok_or_else(|| {
797 ScopeError::NotFound(format!(
798 "Venue '{}' not found. Use `scope venues list` to see available venues.",
799 args.venue
800 ))
801 })?;
802
803 let client = scope::market::ExchangeClient::from_descriptor(descriptor);
804 let (base, quote) = parse_pair_components(&args.pair);
805 let pair = match quote {
806 Some(q) => client.format_pair_with_quote(base, q),
807 None => client.format_pair(base),
808 };
809
810 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
811
812 match args.format {
813 OhlcFormat::Json => {
814 let json_trades: Vec<serde_json::Value> = trades
815 .iter()
816 .map(|t| {
817 serde_json::json!({
818 "price": t.price,
819 "quantity": t.quantity,
820 "quote_quantity": t.quote_quantity,
821 "timestamp_ms": t.timestamp_ms,
822 "side": format!("{:?}", t.side),
823 })
824 })
825 .collect();
826 let json_str = serde_json::to_string_pretty(&json_trades)
827 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
828 println!("{json_str}");
829 }
830 OhlcFormat::Text => {
831 println!(
832 "{}",
833 t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
834 );
835
836 let cols = [
837 t::Col {
838 label: "Time",
839 width: 10,
840 align: '>',
841 },
842 t::Col {
843 label: "Side",
844 width: 5,
845 align: '>',
846 },
847 t::Col {
848 label: "Price",
849 width: 12,
850 align: '>',
851 },
852 t::Col {
853 label: "Qty",
854 width: 12,
855 align: '>',
856 },
857 ];
858 println!("{}", t::table_header(&cols));
859
860 for t in &trades {
861 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
862 .map(|d| d.format("%H:%M:%S").to_string())
863 .unwrap_or_else(|| "?".to_string());
864 let side_str = match t.side {
865 scope::market::TradeSide::Buy => "BUY",
866 scope::market::TradeSide::Sell => "SELL",
867 };
868 let price_str = format!("{:.6}", t.price);
869 let qty_str = format!("{:.2}", t.quantity);
870 let values = [
871 time.as_str(),
872 side_str,
873 price_str.as_str(),
874 qty_str.as_str(),
875 ];
876 println!("{}", t::table_row(&cols, &values));
877 }
878
879 println!(
880 "{}",
881 t::info_row(&format!("{} trades returned", trades.len()))
882 );
883 println!("{}", t::section_footer());
884 }
885 }
886 Ok(())
887}
888
889#[cfg(test)]
890mod tests {
891 use super::*;
892 use scope::chains::DefaultClientFactory;
893
894 #[allow(dead_code)]
898 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
899 let venue_id = format!("test_mock_{}", std::process::id());
900 let yaml = format!(
901 r#"
902id: {venue_id}
903name: Test Mock Venue
904base_url: {server_url}
905timeout_secs: 5
906symbol:
907 template: "{{base}}_{{quote}}"
908 default_quote: USDT
909capabilities:
910 order_book:
911 path: /api/v1/depth
912 params:
913 symbol: "{{pair}}"
914 response:
915 asks_key: asks
916 bids_key: bids
917 level_format: positional
918 ticker:
919 path: /api/v1/ticker
920 params:
921 symbol: "{{pair}}"
922 response:
923 last_price: last
924 volume_24h: vol
925"#
926 );
927 let dir = tempfile::tempdir().unwrap();
928 let file_path = dir.path().join(format!("{}.yaml", venue_id));
929 std::fs::write(&file_path, yaml).unwrap();
930 (venue_id, dir)
933 }
934
935 #[tokio::test]
936 async fn test_run_summary_with_mock_orderbook() {
937 let args = SummaryArgs {
941 pair: "USDC".to_string(),
942 venue: "eth".to_string(),
943 chain: "ethereum".to_string(),
944 health: HealthArgs {
945 peg_target: Some(1.0),
946 peg_range: Some(0.01),
947 min_levels: Some(1),
948 min_depth: Some(50.0),
949 min_bid_ask_ratio: Some(0.1),
950 max_bid_ask_ratio: Some(10.0),
951 ..Default::default()
952 },
953 format: SummaryFormat::Text,
954 every: None,
955 duration: None,
956 report: None,
957 csv: None,
958 };
959
960 let http: std::sync::Arc<dyn scope::http::HttpClient> =
961 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
962 let factory = DefaultClientFactory {
963 chains_config: Default::default(),
964 http,
965 };
966 let _result = run_summary(args, &Config::default(), &factory).await;
968 }
970
971 #[tokio::test]
972 async fn test_run_summary_json_format() {
973 let args = SummaryArgs {
974 pair: "USDC".to_string(),
975 venue: "eth".to_string(),
976 chain: "ethereum".to_string(),
977 health: HealthArgs {
978 peg_target: Some(1.0),
979 peg_range: Some(0.01),
980 min_levels: Some(1),
981 min_depth: Some(50.0),
982 min_bid_ask_ratio: Some(0.1),
983 max_bid_ask_ratio: Some(10.0),
984 ..Default::default()
985 },
986 format: SummaryFormat::Json,
987 every: None,
988 duration: None,
989 report: None,
990 csv: None,
991 };
992
993 let http: std::sync::Arc<dyn scope::http::HttpClient> =
994 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
995 let factory = DefaultClientFactory {
996 chains_config: Default::default(),
997 http,
998 };
999 let _result = run_summary(args, &Config::default(), &factory).await;
1000 }
1001
1002 #[test]
1003 fn test_parse_duration_seconds() {
1004 assert_eq!(parse_duration("30s").unwrap(), 30);
1005 assert_eq!(parse_duration("1").unwrap(), 1);
1006 assert_eq!(parse_duration("60sec").unwrap(), 60);
1007 }
1008
1009 #[test]
1010 fn test_parse_duration_minutes() {
1011 assert_eq!(parse_duration("5m").unwrap(), 300);
1012 assert_eq!(parse_duration("1min").unwrap(), 60);
1013 assert_eq!(parse_duration("2.5m").unwrap(), 150);
1014 }
1015
1016 #[test]
1017 fn test_parse_duration_hours() {
1018 assert_eq!(parse_duration("1h").unwrap(), 3600);
1019 assert_eq!(parse_duration("24h").unwrap(), 86400);
1020 }
1021
1022 #[test]
1023 fn test_parse_duration_invalid() {
1024 assert!(parse_duration("").is_err());
1025 assert!(parse_duration("abc").is_err());
1026 assert!(parse_duration("30x").is_err());
1027 }
1028
1029 #[test]
1030 fn test_parse_duration_unknown_unit_error_message() {
1031 let result = parse_duration("30z");
1032 assert!(result.is_err());
1033 let err = result.unwrap_err();
1034 assert!(err.to_string().contains("Unknown duration unit"));
1035 assert!(err.to_string().contains("z"));
1036 }
1037
1038 #[test]
1039 fn test_parse_duration_invalid_number_error() {
1040 let result = parse_duration("abc30s");
1041 assert!(result.is_err());
1042 }
1043
1044 #[test]
1045 fn test_parse_duration_non_positive() {
1046 assert!(parse_duration("0").is_err());
1047 assert!(parse_duration("-5s").is_err());
1048 }
1049
1050 #[test]
1055 fn test_base_symbol_from_pair_underscore() {
1056 assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1057 assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
1058 }
1059
1060 #[test]
1061 fn test_base_symbol_from_pair_lowercase_underscore() {
1062 assert_eq!(base_symbol_from_pair("dai_usdt"), "dai");
1063 }
1064
1065 #[test]
1066 fn test_base_symbol_from_pair_slash() {
1067 assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
1068 }
1069
1070 #[test]
1071 fn test_base_symbol_from_pair_concat() {
1072 assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
1073 assert_eq!(base_symbol_from_pair("DAIUSDT"), "DAI");
1074 }
1075
1076 #[test]
1077 fn test_base_symbol_from_pair_plain() {
1078 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1079 assert_eq!(base_symbol_from_pair("ETH"), "ETH");
1080 }
1081
1082 #[test]
1083 fn test_base_symbol_from_pair_whitespace() {
1084 assert_eq!(base_symbol_from_pair(" DAI_USDT "), "DAI");
1085 }
1086
1087 #[test]
1088 fn test_normalize_pair_input_key_value() {
1089 assert_eq!(normalize_pair_input("pair=USDT_PUSD"), "USDT_PUSD");
1090 assert_eq!(normalize_pair_input("pair_symbol=USDT_PUSD"), "USDT_PUSD");
1091 assert_eq!(normalize_pair_input("USDT_PUSD"), "USDT_PUSD");
1092 }
1093
1094 #[test]
1095 fn test_parse_pair_components_delimited() {
1096 assert_eq!(parse_pair_components("USDT_PUSD"), ("USDT", Some("PUSD")));
1097 assert_eq!(parse_pair_components("ETH/USDC"), ("ETH", Some("USDC")));
1098 assert_eq!(parse_pair_components("BTC-USDT"), ("BTC", Some("USDT")));
1099 }
1100
1101 #[test]
1102 fn test_parse_pair_components_concat_common_quote() {
1103 assert_eq!(parse_pair_components("BTCUSDT"), ("BTC", Some("USDT")));
1104 assert_eq!(parse_pair_components("ETHUSD"), ("ETH", Some("USD")));
1105 assert_eq!(parse_pair_components("ARB"), ("ARB", None));
1106 }
1107
1108 #[test]
1113 fn test_market_summary_to_markdown_basic() {
1114 use scope::market::{HealthCheck, MarketSummary};
1115 let summary = MarketSummary {
1116 pair: "USDCUSDT".to_string(),
1117 peg_target: 1.0,
1118 best_bid: Some(0.9999),
1119 best_ask: Some(1.0001),
1120 mid_price: Some(1.0000),
1121 spread: Some(0.0002),
1122 volume_24h: Some(1_000_000.0),
1123 bid_depth: 50_000.0,
1124 ask_depth: 50_000.0,
1125 bid_outliers: 0,
1126 ask_outliers: 0,
1127 healthy: true,
1128 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1129 execution_10k_buy: None,
1130 execution_10k_sell: None,
1131 asks: vec![],
1132 bids: vec![],
1133 };
1134 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1135 assert!(md.contains("Market Health Report"));
1136 assert!(md.contains("USDCUSDT"));
1137 assert!(md.contains("Binance"));
1138 assert!(md.contains("Peg Target"));
1139 assert!(md.contains("1.0000"));
1140 assert!(md.contains("Healthy"));
1141 }
1142
1143 #[test]
1144 fn test_market_summary_to_markdown_no_prices() {
1145 use scope::market::{HealthCheck, MarketSummary};
1146 let summary = MarketSummary {
1147 pair: "TESTUSDT".to_string(),
1148 peg_target: 1.0,
1149 best_bid: None,
1150 best_ask: None,
1151 mid_price: None,
1152 spread: None,
1153 volume_24h: None,
1154 bid_depth: 0.0,
1155 ask_depth: 0.0,
1156 bid_outliers: 0,
1157 ask_outliers: 0,
1158 healthy: false,
1159 checks: vec![HealthCheck::Fail("No data".to_string())],
1160 execution_10k_buy: None,
1161 execution_10k_sell: None,
1162 asks: vec![],
1163 bids: vec![],
1164 };
1165 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1166 assert!(md.contains("Market Health Report"));
1167 assert!(md.contains("-")); }
1169
1170 #[test]
1175 fn test_parse_duration_days() {
1176 assert_eq!(parse_duration("1d").unwrap(), 86400);
1177 assert_eq!(parse_duration("7d").unwrap(), 604800);
1178 assert_eq!(parse_duration("1day").unwrap(), 86400);
1179 assert_eq!(parse_duration("2days").unwrap(), 172800);
1180 }
1181
1182 #[test]
1183 fn test_parse_duration_long_names() {
1184 assert_eq!(parse_duration("30seconds").unwrap(), 30);
1185 assert_eq!(parse_duration("5minutes").unwrap(), 300);
1186 assert_eq!(parse_duration("2hours").unwrap(), 7200);
1187 }
1188
1189 #[test]
1190 fn test_parse_duration_fractional() {
1191 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1192 assert_eq!(parse_duration("1.5m").unwrap(), 90);
1193 }
1194
1195 #[test]
1200 fn test_summary_format_default() {
1201 let fmt = SummaryFormat::default();
1202 assert!(matches!(fmt, SummaryFormat::Text));
1203 }
1204
1205 #[test]
1206 fn test_summary_format_debug() {
1207 let text = format!("{:?}", SummaryFormat::Text);
1208 assert_eq!(text, "Text");
1209 let json = format!("{:?}", SummaryFormat::Json);
1210 assert_eq!(json, "Json");
1211 }
1212
1213 #[test]
1218 fn test_ohlc_args_deserialization() {
1219 use crate::cli::{Cli, Commands};
1220 use clap::Parser;
1221 let cli = Cli::try_parse_from([
1222 "scope",
1223 "market",
1224 "ohlc",
1225 "USDC",
1226 "--venue",
1227 "binance",
1228 "--interval",
1229 "1h",
1230 "--limit",
1231 "50",
1232 ])
1233 .unwrap();
1234 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1235 assert_eq!(args.pair, "USDC");
1236 assert_eq!(args.venue, "binance");
1237 assert_eq!(args.interval, "1h");
1238 assert_eq!(args.limit, 50);
1239 } else {
1240 panic!("Expected Market Ohlc command");
1241 }
1242 }
1243
1244 #[test]
1245 fn test_trades_args_deserialization() {
1246 use crate::cli::{Cli, Commands};
1247 use clap::Parser;
1248 let cli = Cli::try_parse_from([
1249 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1250 ])
1251 .unwrap();
1252 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1253 assert_eq!(args.pair, "BTC");
1254 assert_eq!(args.venue, "mexc");
1255 assert_eq!(args.limit, 100);
1256 } else {
1257 panic!("Expected Market Trades command");
1258 }
1259 }
1260
1261 #[test]
1262 fn test_base_symbol_from_pair_various_inputs() {
1263 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1265 assert_eq!(base_symbol_from_pair("BTCUSDT"), "BTC");
1266 assert_eq!(base_symbol_from_pair("ETH/USDT"), "ETH");
1267 assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1268 assert_eq!(base_symbol_from_pair("X"), "X"); assert_eq!(base_symbol_from_pair(""), "");
1270 }
1271
1272 #[test]
1273 fn test_summary_args_debug() {
1274 let args = SummaryArgs {
1275 pair: "USDC".to_string(),
1276 venue: "binance".to_string(),
1277 chain: "ethereum".to_string(),
1278 health: HealthArgs {
1279 peg_target: Some(1.0),
1280 peg_range: Some(0.001),
1281 min_levels: Some(6),
1282 min_depth: Some(3000.0),
1283 min_bid_ask_ratio: Some(0.2),
1284 max_bid_ask_ratio: Some(5.0),
1285 ..Default::default()
1286 },
1287 format: SummaryFormat::Text,
1288 every: None,
1289 duration: None,
1290 report: None,
1291 csv: None,
1292 };
1293 let debug = format!("{:?}", args);
1294 assert!(debug.contains("SummaryArgs"));
1295 assert!(debug.contains("USDC"));
1296 }
1297
1298 #[test]
1299 fn test_default_constants() {
1300 assert_eq!(DEFAULT_EVERY_SECS, 60);
1301 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1302 }
1303
1304 #[test]
1305 fn test_market_summary_to_markdown_with_execution_estimates() {
1306 use scope::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1307 let summary = MarketSummary {
1308 pair: "TESTUSDT".to_string(),
1309 peg_target: 1.0,
1310 best_bid: Some(0.9999),
1311 best_ask: Some(1.0001),
1312 mid_price: Some(1.0000),
1313 spread: Some(0.0002),
1314 volume_24h: Some(1_000_000.0),
1315 bid_depth: 50_000.0,
1316 ask_depth: 50_000.0,
1317 bid_outliers: 0,
1318 ask_outliers: 0,
1319 healthy: true,
1320 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1321 execution_10k_buy: Some(ExecutionEstimate {
1322 notional_usdt: 10_000.0,
1323 side: ExecutionSide::Buy,
1324 vwap: 1.0001,
1325 slippage_bps: 1.5,
1326 fillable: true,
1327 }),
1328 execution_10k_sell: Some(ExecutionEstimate {
1329 notional_usdt: 10_000.0,
1330 side: ExecutionSide::Sell,
1331 vwap: 0.0,
1332 slippage_bps: 0.0,
1333 fillable: false,
1334 }),
1335 asks: vec![],
1336 bids: vec![],
1337 };
1338 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1339 assert!(md.contains("Market Health Report"));
1340 assert!(md.contains("TESTUSDT"));
1341 assert!(md.contains("TestVenue"));
1342 assert!(md.contains("1.50 bps"));
1344 assert!(md.contains("insufficient"));
1346 }
1347
1348 #[tokio::test]
1349 async fn test_run_with_summary_command() {
1350 let args = MarketCommands::Summary(SummaryArgs {
1352 pair: "USDC".to_string(),
1353 venue: "eth".to_string(),
1354 chain: "ethereum".to_string(),
1355 health: HealthArgs {
1356 peg_target: Some(1.0),
1357 peg_range: Some(0.01),
1358 min_levels: Some(1),
1359 min_depth: Some(50.0),
1360 min_bid_ask_ratio: Some(0.1),
1361 max_bid_ask_ratio: Some(10.0),
1362 ..Default::default()
1363 },
1364 format: SummaryFormat::Text,
1365 every: None,
1366 duration: None,
1367 report: None,
1368 csv: None,
1369 });
1370
1371 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1372 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1373 let factory = DefaultClientFactory {
1374 chains_config: Default::default(),
1375 http,
1376 };
1377 let config = Config::default();
1378 let _result = run(args, &config, &factory).await;
1379 }
1381
1382 #[test]
1383 fn test_is_dex_venue() {
1384 assert!(is_dex_venue("eth"));
1385 assert!(is_dex_venue("ethereum"));
1386 assert!(is_dex_venue("Ethereum"));
1387 assert!(is_dex_venue("solana"));
1388 assert!(is_dex_venue("Solana"));
1389 assert!(!is_dex_venue("binance"));
1390 assert!(!is_dex_venue("okx"));
1391 assert!(!is_dex_venue("mexc"));
1392 }
1393
1394 #[test]
1395 fn test_dex_venue_to_chain() {
1396 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
1397 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
1398 assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
1399 assert_eq!(dex_venue_to_chain("solana"), "solana");
1400 }
1401
1402 #[test]
1403 fn test_venue_registry_loaded_in_cex_path() {
1404 let registry = VenueRegistry::load().unwrap();
1406 assert!(registry.contains("binance"));
1407 let client = registry.create_exchange_client("binance");
1408 assert!(client.is_ok());
1409 }
1410
1411 #[test]
1412 fn test_venue_registry_error_for_unknown() {
1413 let registry = VenueRegistry::load().unwrap();
1414 let result = registry.create_exchange_client("kracken");
1415 assert!(result.is_err());
1416 let err = result.unwrap_err().to_string();
1417 assert!(err.contains("Unknown venue"));
1418 assert!(err.contains("Did you mean")); }
1420
1421 #[tokio::test]
1422 async fn test_run_summary_json_format_with_mock() {
1423 let args = SummaryArgs {
1424 pair: "USDC".to_string(),
1425 venue: "eth".to_string(),
1426 chain: "ethereum".to_string(),
1427 health: HealthArgs {
1428 peg_target: Some(1.0),
1429 peg_range: Some(0.01),
1430 min_levels: Some(1),
1431 min_depth: Some(50.0),
1432 min_bid_ask_ratio: Some(0.1),
1433 max_bid_ask_ratio: Some(10.0),
1434 ..Default::default()
1435 },
1436 format: SummaryFormat::Json,
1437 every: None,
1438 duration: None,
1439 report: None,
1440 csv: None,
1441 };
1442
1443 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1444 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1445 let factory = DefaultClientFactory {
1446 chains_config: Default::default(),
1447 http,
1448 };
1449 let _result = run_summary(args, &Config::default(), &factory).await;
1450 }
1451
1452 #[test]
1457 fn test_ohlc_format_default() {
1458 let fmt: OhlcFormat = Default::default();
1459 assert_eq!(fmt, OhlcFormat::Text);
1460 }
1461
1462 #[test]
1463 fn test_ohlc_format_display() {
1464 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1466 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1467 }
1468
1469 #[test]
1470 fn test_ohlc_args_default_values() {
1471 let args = OhlcArgs {
1473 pair: "BTC".to_string(),
1474 venue: "binance".to_string(),
1475 interval: "1h".to_string(),
1476 limit: 100,
1477 format: OhlcFormat::Text,
1478 };
1479 assert_eq!(args.pair, "BTC");
1480 assert_eq!(args.venue, "binance");
1481 assert_eq!(args.interval, "1h");
1482 assert_eq!(args.limit, 100);
1483 }
1484
1485 #[test]
1486 fn test_trades_args_construction() {
1487 let args = TradesArgs {
1488 pair: "ETH".to_string(),
1489 venue: "okx".to_string(),
1490 limit: 50,
1491 format: OhlcFormat::Json,
1492 };
1493 assert_eq!(args.pair, "ETH");
1494 assert_eq!(args.venue, "okx");
1495 assert_eq!(args.limit, 50);
1496 }
1497
1498 #[tokio::test]
1499 async fn test_run_ohlc_unknown_venue() {
1500 let args = OhlcArgs {
1501 pair: "BTC".to_string(),
1502 venue: "nonexistent_venue".to_string(),
1503 interval: "1h".to_string(),
1504 limit: 10,
1505 format: OhlcFormat::Text,
1506 };
1507 let result = run_ohlc(args).await;
1508 assert!(result.is_err());
1509 let err = result.unwrap_err().to_string();
1510 assert!(
1511 err.contains("not found"),
1512 "expected 'not found' error, got: {}",
1513 err
1514 );
1515 }
1516
1517 #[tokio::test]
1518 async fn test_run_trades_unknown_venue() {
1519 let args = TradesArgs {
1520 pair: "BTC".to_string(),
1521 venue: "nonexistent_venue".to_string(),
1522 limit: 10,
1523 format: OhlcFormat::Text,
1524 };
1525 let result = run_trades(args).await;
1526 assert!(result.is_err());
1527 let err = result.unwrap_err().to_string();
1528 assert!(
1529 err.contains("not found"),
1530 "expected 'not found' error, got: {}",
1531 err
1532 );
1533 }
1534
1535 #[tokio::test]
1536 async fn test_run_dispatches_ohlc() {
1537 let cmd = MarketCommands::Ohlc(OhlcArgs {
1538 pair: "BTC".to_string(),
1539 venue: "nonexistent_test_venue".to_string(),
1540 interval: "1h".to_string(),
1541 limit: 5,
1542 format: OhlcFormat::Text,
1543 });
1544 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1545 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1546 let factory = DefaultClientFactory {
1547 chains_config: Default::default(),
1548 http,
1549 };
1550 let config = Config::default();
1551 let result = run(cmd, &config, &factory).await;
1552 assert!(result.is_err());
1554 }
1555
1556 #[tokio::test]
1557 async fn test_run_dispatches_trades() {
1558 let cmd = MarketCommands::Trades(TradesArgs {
1559 pair: "ETH".to_string(),
1560 venue: "nonexistent_test_venue".to_string(),
1561 limit: 5,
1562 format: OhlcFormat::Json,
1563 });
1564 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1565 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1566 let factory = DefaultClientFactory {
1567 chains_config: Default::default(),
1568 http,
1569 };
1570 let config = Config::default();
1571 let result = run(cmd, &config, &factory).await;
1572 assert!(result.is_err());
1573 }
1574
1575 #[tokio::test]
1576 async fn test_run_ohlc_text_format_with_real_venue() {
1577 let args = OhlcArgs {
1580 pair: "BTC".to_string(),
1581 venue: "binance".to_string(),
1582 interval: "1h".to_string(),
1583 limit: 3,
1584 format: OhlcFormat::Text,
1585 };
1586 let _result = run_ohlc(args).await;
1587 }
1589
1590 #[tokio::test]
1591 async fn test_run_ohlc_json_format_with_real_venue() {
1592 let args = OhlcArgs {
1593 pair: "ETH".to_string(),
1594 venue: "binance".to_string(),
1595 interval: "15m".to_string(),
1596 limit: 2,
1597 format: OhlcFormat::Json,
1598 };
1599 let _result = run_ohlc(args).await;
1600 }
1601
1602 #[tokio::test]
1603 async fn test_run_trades_text_format_with_real_venue() {
1604 let args = TradesArgs {
1605 pair: "BTC".to_string(),
1606 venue: "binance".to_string(),
1607 limit: 5,
1608 format: OhlcFormat::Text,
1609 };
1610 let _result = run_trades(args).await;
1611 }
1612
1613 #[tokio::test]
1614 async fn test_run_trades_json_format_with_real_venue() {
1615 let args = TradesArgs {
1616 pair: "ETH".to_string(),
1617 venue: "binance".to_string(),
1618 limit: 3,
1619 format: OhlcFormat::Json,
1620 };
1621 let _result = run_trades(args).await;
1622 }
1623
1624 #[tokio::test]
1625 async fn test_run_ohlc_multiple_venues() {
1626 for venue in &["mexc", "okx", "bybit"] {
1628 let args = OhlcArgs {
1629 pair: "BTC".to_string(),
1630 venue: venue.to_string(),
1631 interval: "1h".to_string(),
1632 limit: 2,
1633 format: OhlcFormat::Json,
1634 };
1635 let _result = run_ohlc(args).await;
1636 }
1637 }
1638
1639 #[tokio::test]
1640 async fn test_run_trades_multiple_venues() {
1641 for venue in &["mexc", "okx", "bybit"] {
1642 let args = TradesArgs {
1643 pair: "BTC".to_string(),
1644 venue: venue.to_string(),
1645 limit: 3,
1646 format: OhlcFormat::Text,
1647 };
1648 let _result = run_trades(args).await;
1649 }
1650 }
1651
1652 #[test]
1657 fn test_parse_duration_whitespace_empty() {
1658 assert!(parse_duration("").is_err());
1659 assert!(parse_duration(" ").is_err());
1660 }
1661
1662 #[test]
1663 fn test_parse_duration_sec_secs_second_seconds() {
1664 assert_eq!(parse_duration("1sec").unwrap(), 1);
1665 assert_eq!(parse_duration("2secs").unwrap(), 2);
1666 assert_eq!(parse_duration("1second").unwrap(), 1);
1667 assert_eq!(parse_duration("3seconds").unwrap(), 3);
1668 }
1669
1670 #[test]
1671 fn test_parse_duration_minute_minutes() {
1672 assert_eq!(parse_duration("1minute").unwrap(), 60);
1673 assert_eq!(parse_duration("2minutes").unwrap(), 120);
1674 }
1675
1676 #[test]
1677 fn test_parse_duration_hr_hrs_hour_hours() {
1678 assert_eq!(parse_duration("1hr").unwrap(), 3600);
1679 assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1680 assert_eq!(parse_duration("1hour").unwrap(), 3600);
1681 assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1682 }
1683
1684 #[test]
1685 fn test_parse_duration_number_only_defaults_to_seconds() {
1686 assert_eq!(parse_duration("1.5").unwrap(), 1);
1687 assert_eq!(parse_duration("42").unwrap(), 42);
1688 }
1689
1690 #[test]
1691 fn test_parse_duration_trimmed_input() {
1692 assert_eq!(parse_duration(" 30s ").unwrap(), 30);
1693 assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1694 }
1695
1696 #[test]
1697 fn test_parse_duration_invalid_number_format() {
1698 assert!(parse_duration("1.2.3s").is_err());
1699 assert!(parse_duration("abc").is_err());
1700 }
1701
1702 #[test]
1707 fn test_dex_venue_to_chain_unknown_returns_ethereum() {
1708 assert_eq!(dex_venue_to_chain("binance"), "ethereum");
1709 assert_eq!(dex_venue_to_chain("kraken"), "ethereum");
1710 assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
1711 }
1712
1713 #[test]
1718 fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1719 use scope::market::{HealthCheck, MarketSummary};
1720 let summary = MarketSummary {
1721 pair: "TESTUSDT".to_string(),
1722 peg_target: 1.0,
1723 best_bid: Some(0.9999),
1724 best_ask: Some(1.0001),
1725 mid_price: Some(1.0000),
1726 spread: Some(0.0002),
1727 volume_24h: Some(500_000.0),
1728 bid_depth: 40_000.0,
1729 ask_depth: 45_000.0,
1730 bid_outliers: 0,
1731 ask_outliers: 0,
1732 healthy: false,
1733 checks: vec![
1734 HealthCheck::Pass("Spread within range".to_string()),
1735 HealthCheck::Fail("Bid depth below minimum".to_string()),
1736 ],
1737 execution_10k_buy: None,
1738 execution_10k_sell: None,
1739 asks: vec![],
1740 bids: vec![],
1741 };
1742 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1743 assert!(md.contains("✓ Spread within range"));
1744 assert!(md.contains("✗ Bid depth below minimum"));
1745 assert!(md.contains("✗")); }
1747
1748 #[test]
1749 fn test_market_summary_to_markdown_empty_checks() {
1750 use scope::market::MarketSummary;
1751 let summary = MarketSummary {
1752 pair: "X".to_string(),
1753 peg_target: 1.0,
1754 best_bid: Some(1.0),
1755 best_ask: Some(1.0),
1756 mid_price: Some(1.0),
1757 spread: Some(0.0),
1758 volume_24h: None,
1759 bid_depth: 100.0,
1760 ask_depth: 100.0,
1761 bid_outliers: 0,
1762 ask_outliers: 0,
1763 healthy: true,
1764 checks: vec![],
1765 execution_10k_buy: None,
1766 execution_10k_sell: None,
1767 asks: vec![],
1768 bids: vec![],
1769 };
1770 let md = market_summary_to_markdown(&summary, "Venue", "X");
1771 assert!(md.contains("Market Health Report"));
1772 assert!(md.contains("Health Checks"));
1773 }
1774
1775 #[test]
1780 fn test_ohlc_format_partial_eq() {
1781 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1782 assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1783 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1784 }
1785
1786 #[test]
1787 fn test_summary_format_clone_copy() {
1788 let text = SummaryFormat::Text;
1789 let cloned = text;
1790 assert!(matches!(cloned, SummaryFormat::Text));
1791 assert!(matches!(text, SummaryFormat::Text));
1792 }
1793
1794 #[tokio::test]
1799 async fn test_run_summary_interval_zero_error() {
1800 let args = SummaryArgs {
1801 pair: "USDC".to_string(),
1802 venue: "eth".to_string(),
1803 chain: "ethereum".to_string(),
1804 health: HealthArgs {
1805 peg_target: Some(1.0),
1806 peg_range: Some(0.01),
1807 min_levels: Some(1),
1808 min_depth: Some(50.0),
1809 min_bid_ask_ratio: Some(0.1),
1810 max_bid_ask_ratio: Some(10.0),
1811 ..Default::default()
1812 },
1813 format: SummaryFormat::Text,
1814 every: Some("0.1s".to_string()),
1815 duration: Some("1m".to_string()),
1816 report: None,
1817 csv: None,
1818 };
1819 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1820 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1821 let factory = DefaultClientFactory {
1822 chains_config: Default::default(),
1823 http,
1824 };
1825 let result = run_summary(args, &Config::default(), &factory).await;
1826 assert!(result.is_err());
1827 let err = result.unwrap_err().to_string();
1828 assert!(
1829 err.contains("Interval must be positive") || err.contains("positive"),
1830 "expected interval error, got: {}",
1831 err
1832 );
1833 }
1834
1835 #[test]
1840 fn test_market_commands_debug() {
1841 let cmd = MarketCommands::Summary(SummaryArgs {
1842 pair: "USDC".to_string(),
1843 venue: "binance".to_string(),
1844 chain: "ethereum".to_string(),
1845 health: HealthArgs {
1846 peg_target: Some(1.0),
1847 peg_range: Some(0.001),
1848 min_levels: Some(6),
1849 min_depth: Some(3000.0),
1850 min_bid_ask_ratio: Some(0.2),
1851 max_bid_ask_ratio: Some(5.0),
1852 ..Default::default()
1853 },
1854 format: SummaryFormat::Text,
1855 every: None,
1856 duration: None,
1857 report: None,
1858 csv: None,
1859 });
1860 let debug = format!("{:?}", cmd);
1861 assert!(debug.contains("Summary"));
1862
1863 let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
1864 pair: "BTC".to_string(),
1865 venue: "binance".to_string(),
1866 interval: "1h".to_string(),
1867 limit: 100,
1868 format: OhlcFormat::Text,
1869 });
1870 assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
1871
1872 let trades_cmd = MarketCommands::Trades(TradesArgs {
1873 pair: "ETH".to_string(),
1874 venue: "binance".to_string(),
1875 limit: 50,
1876 format: OhlcFormat::Json,
1877 });
1878 assert!(format!("{:?}", trades_cmd).contains("Trades"));
1879 }
1880
1881 #[test]
1882 fn test_summary_args_with_report_csv_options() {
1883 let args = SummaryArgs {
1884 pair: "DAI".to_string(),
1885 venue: "binance".to_string(),
1886 chain: "ethereum".to_string(),
1887 health: HealthArgs {
1888 peg_target: Some(1.0),
1889 peg_range: Some(0.001),
1890 min_levels: Some(6),
1891 min_depth: Some(3000.0),
1892 min_bid_ask_ratio: Some(0.2),
1893 max_bid_ask_ratio: Some(5.0),
1894 ..Default::default()
1895 },
1896 format: SummaryFormat::Json,
1897 every: Some("30s".to_string()),
1898 duration: Some("1h".to_string()),
1899 report: Some(std::path::PathBuf::from("/tmp/report.md")),
1900 csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
1901 };
1902 assert_eq!(args.pair, "DAI");
1903 assert_eq!(args.venue, "binance");
1904 assert!(args.every.is_some());
1905 assert!(args.duration.is_some());
1906 assert!(args.report.is_some());
1907 assert!(args.csv.is_some());
1908 }
1909
1910 #[test]
1911 fn test_base_symbol_from_pair_4char_usdt() {
1912 assert_eq!(base_symbol_from_pair("USDT"), "USDT");
1914 }
1915
1916 #[test]
1917 fn test_market_summary_to_markdown_unhealthy() {
1918 use scope::market::{HealthCheck, MarketSummary};
1919 let summary = MarketSummary {
1920 pair: "X".to_string(),
1921 peg_target: 1.0,
1922 best_bid: Some(0.99),
1923 best_ask: Some(1.01),
1924 mid_price: Some(1.0),
1925 spread: Some(0.02),
1926 volume_24h: None,
1927 bid_depth: 100.0,
1928 ask_depth: 100.0,
1929 bid_outliers: 0,
1930 ask_outliers: 0,
1931 healthy: false,
1932 checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
1933 execution_10k_buy: None,
1934 execution_10k_sell: None,
1935 asks: vec![],
1936 bids: vec![],
1937 };
1938 let md = market_summary_to_markdown(&summary, "Test", "X");
1939 assert!(md.contains("✗"));
1940 assert!(md.contains("Peg deviation too high"));
1941 }
1942
1943 #[test]
1944 fn test_ohlc_format_eq() {
1945 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1946 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1947 }
1948
1949 #[test]
1950 fn test_trades_args_default_venue() {
1951 let args = TradesArgs {
1952 pair: "USDC".to_string(),
1953 venue: "binance".to_string(),
1954 limit: 50,
1955 format: OhlcFormat::Text,
1956 };
1957 assert_eq!(args.pair, "USDC");
1958 assert_eq!(args.venue, "binance");
1959 }
1960
1961 const USDC_ADDR: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
1966
1967 fn dex_factory(pairs: &[(f64, f64)]) -> scope::chains::mocks::MockClientFactory {
1969 let mut factory = scope::chains::mocks::MockClientFactory::new();
1970 let data = factory.mock_dex.token_data.as_mut().unwrap();
1971 data.symbol = "USDC".to_string();
1972 data.pairs = pairs
1973 .iter()
1974 .enumerate()
1975 .map(|(i, &(liquidity_usd, volume_24h))| scope::chains::DexPair {
1976 dex_name: format!("DEX {i}"),
1977 pair_address: format!("0xpair{i}"),
1978 base_token: "USDC".to_string(),
1979 quote_token: "USDT".to_string(),
1980 price_usd: 1.0,
1981 volume_24h,
1982 liquidity_usd,
1983 price_change_24h: 0.0,
1984 buys_24h: 0,
1985 sells_24h: 0,
1986 buys_6h: 0,
1987 sells_6h: 0,
1988 buys_1h: 0,
1989 sells_1h: 0,
1990 pair_created_at: None,
1991 url: None,
1992 })
1993 .collect();
1994 factory
1995 }
1996
1997 fn dex_args(format: SummaryFormat) -> SummaryArgs {
1998 SummaryArgs {
1999 pair: USDC_ADDR.to_string(),
2000 venue: "eth".to_string(),
2001 chain: "ethereum".to_string(),
2002 health: HealthArgs::default(),
2003 format,
2004 every: None,
2005 duration: None,
2006 report: None,
2007 csv: None,
2008 }
2009 }
2010
2011 #[tokio::test]
2012 async fn test_dex_book_uses_most_liquid_pair() {
2013 let factory = dex_factory(&[(1_000.0, 10.0), (50_000.0, 777.0), (2_000.0, 20.0)]);
2015 let (book, volume) = fetch_book_and_volume(&dex_args(SummaryFormat::Text), &factory)
2016 .await
2017 .unwrap();
2018 assert_eq!(volume, Some(777.0));
2019 let depth: f64 = book.bids.iter().chain(&book.asks).map(|l| l.value()).sum();
2020 assert!((depth - 50_000.0).abs() < 1.0, "depth {depth}");
2021 }
2022
2023 #[tokio::test]
2024 async fn test_dex_no_pairs_is_error() {
2025 let factory = dex_factory(&[]);
2026 let err = fetch_book_and_volume(&dex_args(SummaryFormat::Text), &factory)
2027 .await
2028 .unwrap_err();
2029 assert!(err.to_string().contains("No DEX pairs found"), "{err}");
2030 }
2031
2032 #[tokio::test]
2033 async fn test_run_summary_once_json_reflects_resolved_thresholds() {
2034 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2035 let mut args = dex_args(SummaryFormat::Json);
2036 args.health = HealthArgs {
2038 min_levels: Some(1),
2039 min_top10_depth: Some(1.0),
2040 ..Default::default()
2041 };
2042 let thresholds = args.health.resolve(&Config::default());
2043 let summary = run_summary_once(&args, &factory, &thresholds, Some(1))
2044 .await
2045 .unwrap();
2046 assert!(summary.healthy, "{:?}", summary.checks);
2047
2048 let strict = HealthThresholds::default();
2049 let summary = run_summary_once(&args, &factory, &strict, None)
2050 .await
2051 .unwrap();
2052 assert!(!summary.healthy);
2053 }
2054
2055 #[tokio::test]
2056 async fn test_run_summary_one_shot_writes_report() {
2057 let dir = tempfile::tempdir().unwrap();
2058 let report = dir.path().join("report.md");
2059 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2060 let mut args = dex_args(SummaryFormat::Text);
2061 args.report = Some(report.clone());
2062 run_summary(args, &Config::default(), &factory)
2063 .await
2064 .unwrap();
2065 let md = std::fs::read_to_string(&report).unwrap();
2066 assert!(md.contains("Market Health Report"), "{md}");
2067 assert!(md.contains(USDC_ADDR), "{md}");
2068 }
2069
2070 #[tokio::test]
2071 async fn test_run_summary_repeat_mode_writes_csv_rows_and_report() {
2072 let dir = tempfile::tempdir().unwrap();
2073 let csv = dir.path().join("series.csv");
2074 let report = dir.path().join("final.md");
2075 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2076 let mut args = dex_args(SummaryFormat::Json);
2077 args.every = Some("1s".to_string());
2078 args.duration = Some("1s".to_string());
2079 args.csv = Some(csv.clone());
2080 args.report = Some(report.clone());
2081 run_summary(args, &Config::default(), &factory)
2082 .await
2083 .unwrap();
2084
2085 let body = std::fs::read_to_string(&csv).unwrap();
2086 let mut lines = body.lines();
2087 assert_eq!(
2088 lines.next(),
2089 Some("timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy")
2090 );
2091 let rows: Vec<_> = lines.collect();
2093 assert_eq!(rows.len(), 2, "{body}");
2094 assert!(rows[0].split(',').nth(1) == Some("1"));
2095 assert!(rows[1].split(',').nth(1) == Some("2"));
2096 assert!(report.exists());
2097 }
2098}