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