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 base_symbol_from_pair(pair: &str) -> &str {
265 let p = pair.trim();
266 if let Some(i) = p.find("_USDT") {
267 return &p[..i];
268 }
269 if let Some(i) = p.find("_usdt") {
270 return &p[..i];
271 }
272 if let Some(i) = p.find("/USDT") {
273 return &p[..i];
274 }
275 if p.to_uppercase().ends_with("USDT") && p.len() > 4 {
276 return &p[..p.len() - 4];
277 }
278 p
279}
280
281fn parse_duration(s: &str) -> Result<u64> {
282 let s = s.trim();
283 if s.is_empty() {
284 return Err(ScopeError::Chain("Empty duration".to_string()));
285 }
286 let (num_str, unit) = s
287 .char_indices()
288 .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
289 .map(|(i, _)| (&s[..i], s[i..].trim()))
290 .unwrap_or((s, "s"));
291
292 let num: f64 = num_str
293 .parse()
294 .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
295
296 if num <= 0.0 {
297 return Err(ScopeError::Chain("Duration must be positive".to_string()));
298 }
299
300 let secs = match unit.to_lowercase().as_str() {
301 "s" | "sec" | "secs" | "second" | "seconds" => num,
302 "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
303 "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
304 "d" | "day" | "days" => num * 86400.0,
305 _ => {
306 return Err(ScopeError::Chain(format!(
307 "Unknown duration unit: {}",
308 unit
309 )));
310 }
311 };
312
313 Ok(secs as u64)
314}
315
316fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
318 let bid_dev = summary
319 .best_bid
320 .map(|b| (b - summary.peg_target) / summary.peg_target * 100.0);
321 let ask_dev = summary
322 .best_ask
323 .map(|a| (a - summary.peg_target) / summary.peg_target * 100.0);
324 let volume_row = summary
325 .volume_24h
326 .map(|v| format!("| Volume (24h) | {:.0} USDT | \n", v))
327 .unwrap_or_default();
328 let exec_buy = summary
329 .execution_10k_buy
330 .as_ref()
331 .map(|e| {
332 if e.fillable {
333 format!("{:.2} bps", e.slippage_bps)
334 } else {
335 "insufficient".to_string()
336 }
337 })
338 .unwrap_or_else(|| "-".to_string());
339 let exec_sell = summary
340 .execution_10k_sell
341 .as_ref()
342 .map(|e| {
343 if e.fillable {
344 format!("{:.2} bps", e.slippage_bps)
345 } else {
346 "insufficient".to_string()
347 }
348 })
349 .unwrap_or_else(|| "-".to_string());
350 let mut md = format!(
351 "# Market Health Report: {} \n\
352 **Venue:** {} \n\
353 **Generated:** {} \n\n\
354 ## Peg & Spread \n\
355 | Metric | Value | \n\
356 |--------|-------| \n\
357 | Peg Target | {:.4} | \n\
358 | Best Bid | {} | \n\
359 | Best Ask | {} | \n\
360 | Mid Price | {} | \n\
361 | Spread | {} | \n\
362 {}\
363 | 10k Buy Slippage | {} | \n\
364 | 10k Sell Slippage | {} | \n\
365 | Bid Depth | {:.0} | \n\
366 | Ask Depth | {:.0} | \n\
367 | Healthy | {} | \n\n\
368 ## Health Checks \n",
369 pair,
370 venue,
371 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
372 summary.peg_target,
373 summary
374 .best_bid
375 .map(|b| format!("{:.4} ({:+.3}%)", b, bid_dev.unwrap_or(0.0)))
376 .unwrap_or_else(|| "-".to_string()),
377 summary
378 .best_ask
379 .map(|a| format!("{:.4} ({:+.3}%)", a, ask_dev.unwrap_or(0.0)))
380 .unwrap_or_else(|| "-".to_string()),
381 summary
382 .mid_price
383 .map(|m| format!("{:.4}", m))
384 .unwrap_or_else(|| "-".to_string()),
385 summary
386 .spread
387 .map(|s| format!("{:.4}", s))
388 .unwrap_or_else(|| "-".to_string()),
389 volume_row,
390 exec_buy,
391 exec_sell,
392 summary.bid_depth,
393 summary.ask_depth,
394 if summary.healthy { "✓" } else { "✗" }
395 );
396 for check in &summary.checks {
397 let (icon, msg) = match check {
398 crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
399 crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
400 };
401 md.push_str(&format!("- {} {}\n", icon, msg));
402 }
403 md.push_str(&crate::display::report::report_footer());
404 md
405}
406
407fn is_dex_venue(venue: &str) -> bool {
409 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
410}
411
412fn dex_venue_to_chain(venue: &str) -> &str {
414 match venue.to_lowercase().as_str() {
415 "ethereum" | "eth" => "ethereum",
416 "solana" => "solana",
417 _ => "ethereum",
418 }
419}
420
421async fn fetch_book_and_volume(
422 args: &SummaryArgs,
423 factory: &dyn ChainClientFactory,
424) -> Result<(OrderBook, Option<f64>)> {
425 let base = base_symbol_from_pair(&args.pair).to_string();
426
427 if is_dex_venue(&args.venue) {
428 let chain = dex_venue_to_chain(&args.venue);
430 let analytics =
431 crawl::fetch_analytics_for_input(&base, chain, Period::Hour24, 10, factory, None)
432 .await?;
433 if analytics.dex_pairs.is_empty() {
434 return Err(ScopeError::Chain(format!(
435 "No DEX pairs found for {} on {}",
436 base, chain
437 )));
438 }
439 let best_pair = analytics
440 .dex_pairs
441 .iter()
442 .max_by(|a, b| {
443 a.liquidity_usd
444 .partial_cmp(&b.liquidity_usd)
445 .unwrap_or(std::cmp::Ordering::Equal)
446 })
447 .ok_or_else(|| ScopeError::Chain("No DEX pairs after filter".to_string()))?;
448 let book = order_book_from_analytics(chain, best_pair, &analytics.token.symbol);
449 let volume = Some(best_pair.volume_24h);
450 Ok((book, volume))
451 } else {
452 let registry = VenueRegistry::load()?;
454 let exchange = registry.create_exchange_client(&args.venue)?;
455 let pair = exchange.format_pair(&base);
456 let book = exchange.fetch_order_book(&pair).await?;
457
458 let volume = if exchange.has_ticker() {
460 exchange
461 .fetch_ticker(&pair)
462 .await
463 .ok()
464 .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
465 } else {
466 None
467 };
468 Ok((book, volume))
469 }
470}
471
472async fn run_summary_once(
473 args: &SummaryArgs,
474 factory: &dyn ChainClientFactory,
475 thresholds: &HealthThresholds,
476 run_num: Option<u64>,
477) -> Result<MarketSummary> {
478 if let Some(n) = run_num {
479 let ts = chrono::Utc::now().format("%H:%M:%S");
480 eprintln!(" --- Run #{} at {} ---\n", n, ts);
481 }
482
483 let (book, volume_24h) = fetch_book_and_volume(args, factory).await?;
484 let summary = MarketSummary::from_order_book(&book, args.peg, thresholds, volume_24h);
485
486 let venue_label = args.venue.clone();
487
488 match args.format {
489 SummaryFormat::Text => {
490 print!("{}", summary.format_text(Some(&venue_label)));
491 }
492 SummaryFormat::Json => {
493 let json = serde_json::json!({
494 "run": run_num,
495 "venue": venue_label,
496 "pair": summary.pair,
497 "peg_target": summary.peg_target,
498 "best_bid": summary.best_bid,
499 "best_ask": summary.best_ask,
500 "mid_price": summary.mid_price,
501 "spread": summary.spread,
502 "volume_24h": summary.volume_24h,
503 "execution_10k_buy": summary.execution_10k_buy.as_ref().map(|e| serde_json::json!({
504 "fillable": e.fillable,
505 "slippage_bps": e.slippage_bps
506 })),
507 "execution_10k_sell": summary.execution_10k_sell.as_ref().map(|e| serde_json::json!({
508 "fillable": e.fillable,
509 "slippage_bps": e.slippage_bps
510 })),
511 "ask_depth": summary.ask_depth,
512 "bid_depth": summary.bid_depth,
513 "ask_levels": summary.asks.len(),
514 "bid_levels": summary.bids.len(),
515 "healthy": summary.healthy,
516 "checks": summary.checks.iter().map(|c| match c {
517 crate::market::HealthCheck::Pass(m) => serde_json::json!({"status": "pass", "message": m}),
518 crate::market::HealthCheck::Fail(m) => serde_json::json!({"status": "fail", "message": m}),
519 }).collect::<Vec<_>>(),
520 });
521 println!("{}", serde_json::to_string_pretty(&json)?);
522 }
523 }
524
525 Ok(summary)
526}
527
528async fn run_summary(args: SummaryArgs, factory: &dyn ChainClientFactory) -> Result<()> {
529 let thresholds = HealthThresholds {
530 peg_target: args.peg,
531 peg_range: args.peg_range,
532 min_levels: args.min_levels,
533 min_depth: args.min_depth,
534 min_bid_ask_ratio: args.min_bid_ask_ratio,
535 max_bid_ask_ratio: args.max_bid_ask_ratio,
536 };
537
538 let repeat_mode = args.every.is_some() || args.duration.is_some();
539
540 if !repeat_mode {
541 let summary = run_summary_once(&args, factory, &thresholds, None).await?;
542 if let Some(ref report_path) = args.report {
543 let venue_label = args.venue.clone();
544 let md = market_summary_to_markdown(&summary, &venue_label, &args.pair);
545 std::fs::write(report_path, md)?;
546 eprintln!("\nReport saved to: {}", report_path.display());
547 }
548 return Ok(());
549 }
550
551 let every_secs = args
552 .every
553 .as_ref()
554 .map(|s| parse_duration(s))
555 .transpose()?
556 .unwrap_or(DEFAULT_EVERY_SECS);
557
558 let duration_secs = args
559 .duration
560 .as_ref()
561 .map(|s| parse_duration(s))
562 .transpose()?
563 .unwrap_or(DEFAULT_DURATION_SECS);
564
565 if every_secs == 0 {
566 return Err(ScopeError::Chain("Interval must be positive".to_string()));
567 }
568
569 let every = Duration::from_secs(every_secs);
570 let start = std::time::Instant::now();
571 let duration = Duration::from_secs(duration_secs);
572
573 eprintln!(
574 "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
575 every_secs, duration_secs
576 );
577
578 let mut run_num: u64 = 1;
579 #[allow(unused_assignments)]
580 let mut last_summary: Option<MarketSummary> = None;
581
582 if let Some(ref csv_path) = args.csv {
584 let header =
585 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy\n";
586 std::fs::write(csv_path, header)?;
587 }
588
589 loop {
590 let summary = run_summary_once(&args, factory, &thresholds, Some(run_num)).await?;
591 last_summary = Some(summary.clone());
592
593 if let Some(ref csv_path) = args.csv {
595 let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
596 let bid = summary
597 .best_bid
598 .map(|v| v.to_string())
599 .unwrap_or_else(|| "-".to_string());
600 let ask = summary
601 .best_ask
602 .map(|v| v.to_string())
603 .unwrap_or_else(|| "-".to_string());
604 let mid = summary
605 .mid_price
606 .map(|v| v.to_string())
607 .unwrap_or_else(|| "-".to_string());
608 let spread = summary
609 .spread
610 .map(|v| v.to_string())
611 .unwrap_or_else(|| "-".to_string());
612 let row = format!(
613 "{},{},{},{},{},{},{},{},{}\n",
614 ts,
615 run_num,
616 bid,
617 ask,
618 mid,
619 spread,
620 summary.bid_depth,
621 summary.ask_depth,
622 summary.healthy
623 );
624 let mut f = std::fs::OpenOptions::new().append(true).open(csv_path)?;
625 use std::io::Write;
626 f.write_all(row.as_bytes())?;
627 }
628
629 if start.elapsed() >= duration {
630 eprintln!("\nCompleted {} run(s) over {}s.", run_num, duration_secs);
631 break;
632 }
633
634 run_num += 1;
635
636 let remaining = duration.saturating_sub(start.elapsed());
637 let sleep_duration = if remaining < every { remaining } else { every };
638 tokio::time::sleep(sleep_duration).await;
639 }
640
641 if let (Some(ref report_path), Some(summary)) = (args.report, last_summary.as_ref()) {
643 let venue_label = args.venue.clone();
644 let md = market_summary_to_markdown(summary, &venue_label, &args.pair);
645 std::fs::write(report_path, md)?;
646 eprintln!("Report saved to: {}", report_path.display());
647 }
648 if let Some(ref csv_path) = args.csv {
649 eprintln!("Time-series CSV saved to: {}", csv_path.display());
650 }
651
652 Ok(())
653}
654
655async fn run_ohlc(args: OhlcArgs) -> Result<()> {
661 let registry = VenueRegistry::load()?;
662 let descriptor = registry.get(&args.venue).ok_or_else(|| {
663 ScopeError::NotFound(format!(
664 "Venue '{}' not found. Use `scope venues list` to see available venues.",
665 args.venue
666 ))
667 })?;
668
669 let client = crate::market::ExchangeClient::from_descriptor(descriptor);
670 let pair = client.format_pair(base_symbol_from_pair(&args.pair));
671
672 let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
673
674 match args.format {
675 OhlcFormat::Json => {
676 let json_candles: Vec<serde_json::Value> = candles
677 .iter()
678 .map(|c| {
679 serde_json::json!({
680 "open_time": c.open_time,
681 "open": c.open,
682 "high": c.high,
683 "low": c.low,
684 "close": c.close,
685 "volume": c.volume,
686 "close_time": c.close_time,
687 })
688 })
689 .collect();
690 let json_str = serde_json::to_string_pretty(&json_candles)
691 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
692 println!("{json_str}");
693 }
694 OhlcFormat::Text => {
695 println!(
696 "{}",
697 t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
698 );
699 println!("{}", t::kv_row("Interval", &args.interval));
700 println!("{}", t::kv_row("Limit", &args.limit.to_string()));
701
702 let cols = [
703 t::Col {
704 label: "Open Time",
705 width: 19,
706 align: '>',
707 },
708 t::Col {
709 label: "Open",
710 width: 12,
711 align: '>',
712 },
713 t::Col {
714 label: "High",
715 width: 12,
716 align: '>',
717 },
718 t::Col {
719 label: "Low",
720 width: 12,
721 align: '>',
722 },
723 t::Col {
724 label: "Close",
725 width: 12,
726 align: '>',
727 },
728 t::Col {
729 label: "Volume",
730 width: 14,
731 align: '>',
732 },
733 ];
734 println!("{}", t::table_header(&cols));
735
736 for c in &candles {
737 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
738 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
739 .unwrap_or_else(|| format!("{}", c.open_time));
740 let open_str = format!("{:.6}", c.open);
741 let high_str = format!("{:.6}", c.high);
742 let low_str = format!("{:.6}", c.low);
743 let close_str = format!("{:.6}", c.close);
744 let volume_str = format!("{:.2}", c.volume);
745 let values = [
746 dt.as_str(),
747 open_str.as_str(),
748 high_str.as_str(),
749 low_str.as_str(),
750 close_str.as_str(),
751 volume_str.as_str(),
752 ];
753 println!("{}", t::table_row(&cols, &values));
754 }
755
756 println!(
757 "{}",
758 t::info_row(&format!("{} candles returned", candles.len()))
759 );
760 println!("{}", t::section_footer());
761 }
762 }
763 Ok(())
764}
765
766async fn run_trades(args: TradesArgs) -> Result<()> {
772 let registry = VenueRegistry::load()?;
773 let descriptor = registry.get(&args.venue).ok_or_else(|| {
774 ScopeError::NotFound(format!(
775 "Venue '{}' not found. Use `scope venues list` to see available venues.",
776 args.venue
777 ))
778 })?;
779
780 let client = crate::market::ExchangeClient::from_descriptor(descriptor);
781 let pair = client.format_pair(base_symbol_from_pair(&args.pair));
782
783 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
784
785 match args.format {
786 OhlcFormat::Json => {
787 let json_trades: Vec<serde_json::Value> = trades
788 .iter()
789 .map(|t| {
790 serde_json::json!({
791 "price": t.price,
792 "quantity": t.quantity,
793 "quote_quantity": t.quote_quantity,
794 "timestamp_ms": t.timestamp_ms,
795 "side": format!("{:?}", t.side),
796 })
797 })
798 .collect();
799 let json_str = serde_json::to_string_pretty(&json_trades)
800 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
801 println!("{json_str}");
802 }
803 OhlcFormat::Text => {
804 println!(
805 "{}",
806 t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
807 );
808
809 let cols = [
810 t::Col {
811 label: "Time",
812 width: 10,
813 align: '>',
814 },
815 t::Col {
816 label: "Side",
817 width: 5,
818 align: '>',
819 },
820 t::Col {
821 label: "Price",
822 width: 12,
823 align: '>',
824 },
825 t::Col {
826 label: "Qty",
827 width: 12,
828 align: '>',
829 },
830 ];
831 println!("{}", t::table_header(&cols));
832
833 for t in &trades {
834 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
835 .map(|d| d.format("%H:%M:%S").to_string())
836 .unwrap_or_else(|| "?".to_string());
837 let side_str = match t.side {
838 crate::market::TradeSide::Buy => "BUY",
839 crate::market::TradeSide::Sell => "SELL",
840 };
841 let price_str = format!("{:.6}", t.price);
842 let qty_str = format!("{:.2}", t.quantity);
843 let values = [
844 time.as_str(),
845 side_str,
846 price_str.as_str(),
847 qty_str.as_str(),
848 ];
849 println!("{}", t::table_row(&cols, &values));
850 }
851
852 println!(
853 "{}",
854 t::info_row(&format!("{} trades returned", trades.len()))
855 );
856 println!("{}", t::section_footer());
857 }
858 }
859 Ok(())
860}
861
862#[cfg(test)]
863mod tests {
864 use super::*;
865 use crate::chains::DefaultClientFactory;
866
867 #[allow(dead_code)]
871 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
872 let venue_id = format!("test_mock_{}", std::process::id());
873 let yaml = format!(
874 r#"
875id: {venue_id}
876name: Test Mock Venue
877base_url: {server_url}
878timeout_secs: 5
879symbol:
880 template: "{{base}}_{{quote}}"
881 default_quote: USDT
882capabilities:
883 order_book:
884 path: /api/v1/depth
885 params:
886 symbol: "{{pair}}"
887 response:
888 asks_key: asks
889 bids_key: bids
890 level_format: positional
891 ticker:
892 path: /api/v1/ticker
893 params:
894 symbol: "{{pair}}"
895 response:
896 last_price: last
897 volume_24h: vol
898"#
899 );
900 let dir = tempfile::tempdir().unwrap();
901 let file_path = dir.path().join(format!("{}.yaml", venue_id));
902 std::fs::write(&file_path, yaml).unwrap();
903 (venue_id, dir)
906 }
907
908 #[tokio::test]
909 async fn test_run_summary_with_mock_orderbook() {
910 let args = SummaryArgs {
914 pair: "USDC".to_string(),
915 venue: "eth".to_string(),
916 chain: "ethereum".to_string(),
917 peg: 1.0,
918 min_levels: 1,
919 min_depth: 50.0,
920 peg_range: 0.01,
921 min_bid_ask_ratio: 0.1,
922 max_bid_ask_ratio: 10.0,
923 format: SummaryFormat::Text,
924 every: None,
925 duration: None,
926 report: None,
927 csv: None,
928 };
929
930 let http: std::sync::Arc<dyn crate::http::HttpClient> =
931 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
932 let factory = DefaultClientFactory {
933 chains_config: Default::default(),
934 http,
935 };
936 let _result = run_summary(args, &factory).await;
938 }
940
941 #[tokio::test]
942 async fn test_run_summary_json_format() {
943 let args = SummaryArgs {
944 pair: "USDC".to_string(),
945 venue: "eth".to_string(),
946 chain: "ethereum".to_string(),
947 peg: 1.0,
948 min_levels: 1,
949 min_depth: 50.0,
950 peg_range: 0.01,
951 min_bid_ask_ratio: 0.1,
952 max_bid_ask_ratio: 10.0,
953 format: SummaryFormat::Json,
954 every: None,
955 duration: None,
956 report: None,
957 csv: None,
958 };
959
960 let http: std::sync::Arc<dyn crate::http::HttpClient> =
961 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
962 let factory = DefaultClientFactory {
963 chains_config: Default::default(),
964 http,
965 };
966 let _result = run_summary(args, &factory).await;
967 }
968
969 #[test]
970 fn test_parse_duration_seconds() {
971 assert_eq!(parse_duration("30s").unwrap(), 30);
972 assert_eq!(parse_duration("1").unwrap(), 1);
973 assert_eq!(parse_duration("60sec").unwrap(), 60);
974 }
975
976 #[test]
977 fn test_parse_duration_minutes() {
978 assert_eq!(parse_duration("5m").unwrap(), 300);
979 assert_eq!(parse_duration("1min").unwrap(), 60);
980 assert_eq!(parse_duration("2.5m").unwrap(), 150);
981 }
982
983 #[test]
984 fn test_parse_duration_hours() {
985 assert_eq!(parse_duration("1h").unwrap(), 3600);
986 assert_eq!(parse_duration("24h").unwrap(), 86400);
987 }
988
989 #[test]
990 fn test_parse_duration_invalid() {
991 assert!(parse_duration("").is_err());
992 assert!(parse_duration("abc").is_err());
993 assert!(parse_duration("30x").is_err());
994 }
995
996 #[test]
997 fn test_parse_duration_unknown_unit_error_message() {
998 let result = parse_duration("30z");
999 assert!(result.is_err());
1000 let err = result.unwrap_err();
1001 assert!(err.to_string().contains("Unknown duration unit"));
1002 assert!(err.to_string().contains("z"));
1003 }
1004
1005 #[test]
1006 fn test_parse_duration_invalid_number_error() {
1007 let result = parse_duration("abc30s");
1008 assert!(result.is_err());
1009 }
1010
1011 #[test]
1012 fn test_parse_duration_non_positive() {
1013 assert!(parse_duration("0").is_err());
1014 assert!(parse_duration("-5s").is_err());
1015 }
1016
1017 #[test]
1022 fn test_base_symbol_from_pair_underscore() {
1023 assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1024 assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
1025 }
1026
1027 #[test]
1028 fn test_base_symbol_from_pair_lowercase_underscore() {
1029 assert_eq!(base_symbol_from_pair("dai_usdt"), "dai");
1030 }
1031
1032 #[test]
1033 fn test_base_symbol_from_pair_slash() {
1034 assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
1035 }
1036
1037 #[test]
1038 fn test_base_symbol_from_pair_concat() {
1039 assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
1040 assert_eq!(base_symbol_from_pair("DAIUSDT"), "DAI");
1041 }
1042
1043 #[test]
1044 fn test_base_symbol_from_pair_plain() {
1045 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1046 assert_eq!(base_symbol_from_pair("ETH"), "ETH");
1047 }
1048
1049 #[test]
1050 fn test_base_symbol_from_pair_whitespace() {
1051 assert_eq!(base_symbol_from_pair(" DAI_USDT "), "DAI");
1052 }
1053
1054 #[test]
1059 fn test_market_summary_to_markdown_basic() {
1060 use crate::market::{HealthCheck, MarketSummary};
1061 let summary = MarketSummary {
1062 pair: "USDCUSDT".to_string(),
1063 peg_target: 1.0,
1064 best_bid: Some(0.9999),
1065 best_ask: Some(1.0001),
1066 mid_price: Some(1.0000),
1067 spread: Some(0.0002),
1068 volume_24h: Some(1_000_000.0),
1069 bid_depth: 50_000.0,
1070 ask_depth: 50_000.0,
1071 bid_outliers: 0,
1072 ask_outliers: 0,
1073 healthy: true,
1074 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1075 execution_10k_buy: None,
1076 execution_10k_sell: None,
1077 asks: vec![],
1078 bids: vec![],
1079 };
1080 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1081 assert!(md.contains("Market Health Report"));
1082 assert!(md.contains("USDCUSDT"));
1083 assert!(md.contains("Binance"));
1084 assert!(md.contains("Peg Target"));
1085 assert!(md.contains("1.0000"));
1086 assert!(md.contains("Healthy"));
1087 }
1088
1089 #[test]
1090 fn test_market_summary_to_markdown_no_prices() {
1091 use crate::market::{HealthCheck, MarketSummary};
1092 let summary = MarketSummary {
1093 pair: "TESTUSDT".to_string(),
1094 peg_target: 1.0,
1095 best_bid: None,
1096 best_ask: None,
1097 mid_price: None,
1098 spread: None,
1099 volume_24h: None,
1100 bid_depth: 0.0,
1101 ask_depth: 0.0,
1102 bid_outliers: 0,
1103 ask_outliers: 0,
1104 healthy: false,
1105 checks: vec![HealthCheck::Fail("No data".to_string())],
1106 execution_10k_buy: None,
1107 execution_10k_sell: None,
1108 asks: vec![],
1109 bids: vec![],
1110 };
1111 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1112 assert!(md.contains("Market Health Report"));
1113 assert!(md.contains("-")); }
1115
1116 #[test]
1121 fn test_parse_duration_days() {
1122 assert_eq!(parse_duration("1d").unwrap(), 86400);
1123 assert_eq!(parse_duration("7d").unwrap(), 604800);
1124 assert_eq!(parse_duration("1day").unwrap(), 86400);
1125 assert_eq!(parse_duration("2days").unwrap(), 172800);
1126 }
1127
1128 #[test]
1129 fn test_parse_duration_long_names() {
1130 assert_eq!(parse_duration("30seconds").unwrap(), 30);
1131 assert_eq!(parse_duration("5minutes").unwrap(), 300);
1132 assert_eq!(parse_duration("2hours").unwrap(), 7200);
1133 }
1134
1135 #[test]
1136 fn test_parse_duration_fractional() {
1137 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1138 assert_eq!(parse_duration("1.5m").unwrap(), 90);
1139 }
1140
1141 #[test]
1146 fn test_summary_format_default() {
1147 let fmt = SummaryFormat::default();
1148 assert!(matches!(fmt, SummaryFormat::Text));
1149 }
1150
1151 #[test]
1152 fn test_summary_format_debug() {
1153 let text = format!("{:?}", SummaryFormat::Text);
1154 assert_eq!(text, "Text");
1155 let json = format!("{:?}", SummaryFormat::Json);
1156 assert_eq!(json, "Json");
1157 }
1158
1159 #[test]
1164 fn test_ohlc_args_deserialization() {
1165 use crate::cli::{Cli, Commands};
1166 use clap::Parser;
1167 let cli = Cli::try_parse_from([
1168 "scope",
1169 "market",
1170 "ohlc",
1171 "USDC",
1172 "--venue",
1173 "binance",
1174 "--interval",
1175 "1h",
1176 "--limit",
1177 "50",
1178 ])
1179 .unwrap();
1180 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1181 assert_eq!(args.pair, "USDC");
1182 assert_eq!(args.venue, "binance");
1183 assert_eq!(args.interval, "1h");
1184 assert_eq!(args.limit, 50);
1185 } else {
1186 panic!("Expected Market Ohlc command");
1187 }
1188 }
1189
1190 #[test]
1191 fn test_trades_args_deserialization() {
1192 use crate::cli::{Cli, Commands};
1193 use clap::Parser;
1194 let cli = Cli::try_parse_from([
1195 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1196 ])
1197 .unwrap();
1198 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1199 assert_eq!(args.pair, "BTC");
1200 assert_eq!(args.venue, "mexc");
1201 assert_eq!(args.limit, 100);
1202 } else {
1203 panic!("Expected Market Trades command");
1204 }
1205 }
1206
1207 #[test]
1208 fn test_base_symbol_from_pair_various_inputs() {
1209 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1211 assert_eq!(base_symbol_from_pair("BTCUSDT"), "BTC");
1212 assert_eq!(base_symbol_from_pair("ETH/USDT"), "ETH");
1213 assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1214 assert_eq!(base_symbol_from_pair("X"), "X"); assert_eq!(base_symbol_from_pair(""), "");
1216 }
1217
1218 #[test]
1219 fn test_summary_args_debug() {
1220 let args = SummaryArgs {
1221 pair: "USDC".to_string(),
1222 venue: "binance".to_string(),
1223 chain: "ethereum".to_string(),
1224 peg: 1.0,
1225 min_levels: 6,
1226 min_depth: 3000.0,
1227 peg_range: 0.001,
1228 min_bid_ask_ratio: 0.2,
1229 max_bid_ask_ratio: 5.0,
1230 format: SummaryFormat::Text,
1231 every: None,
1232 duration: None,
1233 report: None,
1234 csv: None,
1235 };
1236 let debug = format!("{:?}", args);
1237 assert!(debug.contains("SummaryArgs"));
1238 assert!(debug.contains("USDC"));
1239 }
1240
1241 #[test]
1242 fn test_default_constants() {
1243 assert_eq!(DEFAULT_EVERY_SECS, 60);
1244 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1245 }
1246
1247 #[test]
1248 fn test_market_summary_to_markdown_with_execution_estimates() {
1249 use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1250 let summary = MarketSummary {
1251 pair: "TESTUSDT".to_string(),
1252 peg_target: 1.0,
1253 best_bid: Some(0.9999),
1254 best_ask: Some(1.0001),
1255 mid_price: Some(1.0000),
1256 spread: Some(0.0002),
1257 volume_24h: Some(1_000_000.0),
1258 bid_depth: 50_000.0,
1259 ask_depth: 50_000.0,
1260 bid_outliers: 0,
1261 ask_outliers: 0,
1262 healthy: true,
1263 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1264 execution_10k_buy: Some(ExecutionEstimate {
1265 notional_usdt: 10_000.0,
1266 side: ExecutionSide::Buy,
1267 vwap: 1.0001,
1268 slippage_bps: 1.5,
1269 fillable: true,
1270 }),
1271 execution_10k_sell: Some(ExecutionEstimate {
1272 notional_usdt: 10_000.0,
1273 side: ExecutionSide::Sell,
1274 vwap: 0.0,
1275 slippage_bps: 0.0,
1276 fillable: false,
1277 }),
1278 asks: vec![],
1279 bids: vec![],
1280 };
1281 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1282 assert!(md.contains("Market Health Report"));
1283 assert!(md.contains("TESTUSDT"));
1284 assert!(md.contains("TestVenue"));
1285 assert!(md.contains("1.50 bps"));
1287 assert!(md.contains("insufficient"));
1289 }
1290
1291 #[tokio::test]
1292 async fn test_run_with_summary_command() {
1293 let args = MarketCommands::Summary(SummaryArgs {
1295 pair: "USDC".to_string(),
1296 venue: "eth".to_string(),
1297 chain: "ethereum".to_string(),
1298 peg: 1.0,
1299 min_levels: 1,
1300 min_depth: 50.0,
1301 peg_range: 0.01,
1302 min_bid_ask_ratio: 0.1,
1303 max_bid_ask_ratio: 10.0,
1304 format: SummaryFormat::Text,
1305 every: None,
1306 duration: None,
1307 report: None,
1308 csv: None,
1309 });
1310
1311 let http: std::sync::Arc<dyn crate::http::HttpClient> =
1312 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1313 let factory = DefaultClientFactory {
1314 chains_config: Default::default(),
1315 http,
1316 };
1317 let config = Config::default();
1318 let _result = run(args, &config, &factory).await;
1319 }
1321
1322 #[test]
1323 fn test_is_dex_venue() {
1324 assert!(is_dex_venue("eth"));
1325 assert!(is_dex_venue("ethereum"));
1326 assert!(is_dex_venue("Ethereum"));
1327 assert!(is_dex_venue("solana"));
1328 assert!(is_dex_venue("Solana"));
1329 assert!(!is_dex_venue("binance"));
1330 assert!(!is_dex_venue("okx"));
1331 assert!(!is_dex_venue("mexc"));
1332 }
1333
1334 #[test]
1335 fn test_dex_venue_to_chain() {
1336 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
1337 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
1338 assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
1339 assert_eq!(dex_venue_to_chain("solana"), "solana");
1340 }
1341
1342 #[test]
1343 fn test_venue_registry_loaded_in_cex_path() {
1344 let registry = VenueRegistry::load().unwrap();
1346 assert!(registry.contains("binance"));
1347 let client = registry.create_exchange_client("binance");
1348 assert!(client.is_ok());
1349 }
1350
1351 #[test]
1352 fn test_venue_registry_error_for_unknown() {
1353 let registry = VenueRegistry::load().unwrap();
1354 let result = registry.create_exchange_client("kracken");
1355 assert!(result.is_err());
1356 let err = result.unwrap_err().to_string();
1357 assert!(err.contains("Unknown venue"));
1358 assert!(err.contains("Did you mean")); }
1360
1361 #[tokio::test]
1362 async fn test_run_summary_json_format_with_mock() {
1363 let args = 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::Json,
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 _result = run_summary(args, &factory).await;
1387 }
1388
1389 #[test]
1394 fn test_ohlc_format_default() {
1395 let fmt: OhlcFormat = Default::default();
1396 assert_eq!(fmt, OhlcFormat::Text);
1397 }
1398
1399 #[test]
1400 fn test_ohlc_format_display() {
1401 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1403 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1404 }
1405
1406 #[test]
1407 fn test_ohlc_args_default_values() {
1408 let args = OhlcArgs {
1410 pair: "BTC".to_string(),
1411 venue: "binance".to_string(),
1412 interval: "1h".to_string(),
1413 limit: 100,
1414 format: OhlcFormat::Text,
1415 };
1416 assert_eq!(args.pair, "BTC");
1417 assert_eq!(args.venue, "binance");
1418 assert_eq!(args.interval, "1h");
1419 assert_eq!(args.limit, 100);
1420 }
1421
1422 #[test]
1423 fn test_trades_args_construction() {
1424 let args = TradesArgs {
1425 pair: "ETH".to_string(),
1426 venue: "okx".to_string(),
1427 limit: 50,
1428 format: OhlcFormat::Json,
1429 };
1430 assert_eq!(args.pair, "ETH");
1431 assert_eq!(args.venue, "okx");
1432 assert_eq!(args.limit, 50);
1433 }
1434
1435 #[tokio::test]
1436 async fn test_run_ohlc_unknown_venue() {
1437 let args = OhlcArgs {
1438 pair: "BTC".to_string(),
1439 venue: "nonexistent_venue".to_string(),
1440 interval: "1h".to_string(),
1441 limit: 10,
1442 format: OhlcFormat::Text,
1443 };
1444 let result = run_ohlc(args).await;
1445 assert!(result.is_err());
1446 let err = result.unwrap_err().to_string();
1447 assert!(
1448 err.contains("not found"),
1449 "expected 'not found' error, got: {}",
1450 err
1451 );
1452 }
1453
1454 #[tokio::test]
1455 async fn test_run_trades_unknown_venue() {
1456 let args = TradesArgs {
1457 pair: "BTC".to_string(),
1458 venue: "nonexistent_venue".to_string(),
1459 limit: 10,
1460 format: OhlcFormat::Text,
1461 };
1462 let result = run_trades(args).await;
1463 assert!(result.is_err());
1464 let err = result.unwrap_err().to_string();
1465 assert!(
1466 err.contains("not found"),
1467 "expected 'not found' error, got: {}",
1468 err
1469 );
1470 }
1471
1472 #[tokio::test]
1473 async fn test_run_dispatches_ohlc() {
1474 let cmd = MarketCommands::Ohlc(OhlcArgs {
1475 pair: "BTC".to_string(),
1476 venue: "nonexistent_test_venue".to_string(),
1477 interval: "1h".to_string(),
1478 limit: 5,
1479 format: OhlcFormat::Text,
1480 });
1481 let http: std::sync::Arc<dyn crate::http::HttpClient> =
1482 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1483 let factory = DefaultClientFactory {
1484 chains_config: Default::default(),
1485 http,
1486 };
1487 let config = Config::default();
1488 let result = run(cmd, &config, &factory).await;
1489 assert!(result.is_err());
1491 }
1492
1493 #[tokio::test]
1494 async fn test_run_dispatches_trades() {
1495 let cmd = MarketCommands::Trades(TradesArgs {
1496 pair: "ETH".to_string(),
1497 venue: "nonexistent_test_venue".to_string(),
1498 limit: 5,
1499 format: OhlcFormat::Json,
1500 });
1501 let http: std::sync::Arc<dyn crate::http::HttpClient> =
1502 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1503 let factory = DefaultClientFactory {
1504 chains_config: Default::default(),
1505 http,
1506 };
1507 let config = Config::default();
1508 let result = run(cmd, &config, &factory).await;
1509 assert!(result.is_err());
1510 }
1511
1512 #[tokio::test]
1513 async fn test_run_ohlc_text_format_with_real_venue() {
1514 let args = OhlcArgs {
1517 pair: "BTC".to_string(),
1518 venue: "binance".to_string(),
1519 interval: "1h".to_string(),
1520 limit: 3,
1521 format: OhlcFormat::Text,
1522 };
1523 let _result = run_ohlc(args).await;
1524 }
1526
1527 #[tokio::test]
1528 async fn test_run_ohlc_json_format_with_real_venue() {
1529 let args = OhlcArgs {
1530 pair: "ETH".to_string(),
1531 venue: "binance".to_string(),
1532 interval: "15m".to_string(),
1533 limit: 2,
1534 format: OhlcFormat::Json,
1535 };
1536 let _result = run_ohlc(args).await;
1537 }
1538
1539 #[tokio::test]
1540 async fn test_run_trades_text_format_with_real_venue() {
1541 let args = TradesArgs {
1542 pair: "BTC".to_string(),
1543 venue: "binance".to_string(),
1544 limit: 5,
1545 format: OhlcFormat::Text,
1546 };
1547 let _result = run_trades(args).await;
1548 }
1549
1550 #[tokio::test]
1551 async fn test_run_trades_json_format_with_real_venue() {
1552 let args = TradesArgs {
1553 pair: "ETH".to_string(),
1554 venue: "binance".to_string(),
1555 limit: 3,
1556 format: OhlcFormat::Json,
1557 };
1558 let _result = run_trades(args).await;
1559 }
1560
1561 #[tokio::test]
1562 async fn test_run_ohlc_multiple_venues() {
1563 for venue in &["mexc", "okx", "bybit"] {
1565 let args = OhlcArgs {
1566 pair: "BTC".to_string(),
1567 venue: venue.to_string(),
1568 interval: "1h".to_string(),
1569 limit: 2,
1570 format: OhlcFormat::Json,
1571 };
1572 let _result = run_ohlc(args).await;
1573 }
1574 }
1575
1576 #[tokio::test]
1577 async fn test_run_trades_multiple_venues() {
1578 for venue in &["mexc", "okx", "bybit"] {
1579 let args = TradesArgs {
1580 pair: "BTC".to_string(),
1581 venue: venue.to_string(),
1582 limit: 3,
1583 format: OhlcFormat::Text,
1584 };
1585 let _result = run_trades(args).await;
1586 }
1587 }
1588
1589 #[test]
1594 fn test_parse_duration_whitespace_empty() {
1595 assert!(parse_duration("").is_err());
1596 assert!(parse_duration(" ").is_err());
1597 }
1598
1599 #[test]
1600 fn test_parse_duration_sec_secs_second_seconds() {
1601 assert_eq!(parse_duration("1sec").unwrap(), 1);
1602 assert_eq!(parse_duration("2secs").unwrap(), 2);
1603 assert_eq!(parse_duration("1second").unwrap(), 1);
1604 assert_eq!(parse_duration("3seconds").unwrap(), 3);
1605 }
1606
1607 #[test]
1608 fn test_parse_duration_minute_minutes() {
1609 assert_eq!(parse_duration("1minute").unwrap(), 60);
1610 assert_eq!(parse_duration("2minutes").unwrap(), 120);
1611 }
1612
1613 #[test]
1614 fn test_parse_duration_hr_hrs_hour_hours() {
1615 assert_eq!(parse_duration("1hr").unwrap(), 3600);
1616 assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1617 assert_eq!(parse_duration("1hour").unwrap(), 3600);
1618 assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1619 }
1620
1621 #[test]
1622 fn test_parse_duration_number_only_defaults_to_seconds() {
1623 assert_eq!(parse_duration("1.5").unwrap(), 1);
1624 assert_eq!(parse_duration("42").unwrap(), 42);
1625 }
1626
1627 #[test]
1628 fn test_parse_duration_trimmed_input() {
1629 assert_eq!(parse_duration(" 30s ").unwrap(), 30);
1630 assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1631 }
1632
1633 #[test]
1634 fn test_parse_duration_invalid_number_format() {
1635 assert!(parse_duration("1.2.3s").is_err());
1636 assert!(parse_duration("abc").is_err());
1637 }
1638
1639 #[test]
1644 fn test_dex_venue_to_chain_unknown_returns_ethereum() {
1645 assert_eq!(dex_venue_to_chain("binance"), "ethereum");
1646 assert_eq!(dex_venue_to_chain("kraken"), "ethereum");
1647 assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
1648 }
1649
1650 #[test]
1655 fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1656 use crate::market::{HealthCheck, MarketSummary};
1657 let summary = MarketSummary {
1658 pair: "TESTUSDT".to_string(),
1659 peg_target: 1.0,
1660 best_bid: Some(0.9999),
1661 best_ask: Some(1.0001),
1662 mid_price: Some(1.0000),
1663 spread: Some(0.0002),
1664 volume_24h: Some(500_000.0),
1665 bid_depth: 40_000.0,
1666 ask_depth: 45_000.0,
1667 bid_outliers: 0,
1668 ask_outliers: 0,
1669 healthy: false,
1670 checks: vec![
1671 HealthCheck::Pass("Spread within range".to_string()),
1672 HealthCheck::Fail("Bid depth below minimum".to_string()),
1673 ],
1674 execution_10k_buy: None,
1675 execution_10k_sell: None,
1676 asks: vec![],
1677 bids: vec![],
1678 };
1679 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1680 assert!(md.contains("✓ Spread within range"));
1681 assert!(md.contains("✗ Bid depth below minimum"));
1682 assert!(md.contains("✗")); }
1684
1685 #[test]
1686 fn test_market_summary_to_markdown_empty_checks() {
1687 use crate::market::MarketSummary;
1688 let summary = MarketSummary {
1689 pair: "X".to_string(),
1690 peg_target: 1.0,
1691 best_bid: Some(1.0),
1692 best_ask: Some(1.0),
1693 mid_price: Some(1.0),
1694 spread: Some(0.0),
1695 volume_24h: None,
1696 bid_depth: 100.0,
1697 ask_depth: 100.0,
1698 bid_outliers: 0,
1699 ask_outliers: 0,
1700 healthy: true,
1701 checks: vec![],
1702 execution_10k_buy: None,
1703 execution_10k_sell: None,
1704 asks: vec![],
1705 bids: vec![],
1706 };
1707 let md = market_summary_to_markdown(&summary, "Venue", "X");
1708 assert!(md.contains("Market Health Report"));
1709 assert!(md.contains("Health Checks"));
1710 }
1711
1712 #[test]
1717 fn test_ohlc_format_partial_eq() {
1718 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1719 assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1720 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1721 }
1722
1723 #[test]
1724 fn test_summary_format_clone_copy() {
1725 let text = SummaryFormat::Text;
1726 let cloned = text;
1727 assert!(matches!(cloned, SummaryFormat::Text));
1728 assert!(matches!(text, SummaryFormat::Text));
1729 }
1730
1731 #[tokio::test]
1736 async fn test_run_summary_interval_zero_error() {
1737 let args = SummaryArgs {
1738 pair: "USDC".to_string(),
1739 venue: "eth".to_string(),
1740 chain: "ethereum".to_string(),
1741 peg: 1.0,
1742 min_levels: 1,
1743 min_depth: 50.0,
1744 peg_range: 0.01,
1745 min_bid_ask_ratio: 0.1,
1746 max_bid_ask_ratio: 10.0,
1747 format: SummaryFormat::Text,
1748 every: Some("0.1s".to_string()),
1749 duration: Some("1m".to_string()),
1750 report: None,
1751 csv: None,
1752 };
1753 let http: std::sync::Arc<dyn crate::http::HttpClient> =
1754 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1755 let factory = DefaultClientFactory {
1756 chains_config: Default::default(),
1757 http,
1758 };
1759 let result = run_summary(args, &factory).await;
1760 assert!(result.is_err());
1761 let err = result.unwrap_err().to_string();
1762 assert!(
1763 err.contains("Interval must be positive") || err.contains("positive"),
1764 "expected interval error, got: {}",
1765 err
1766 );
1767 }
1768
1769 #[tokio::test]
1770 async fn test_run_summary_one_shot_with_report() {
1771 let report_dir = tempfile::tempdir().unwrap();
1772 let report_path = report_dir.path().join("report.md");
1773 let args = SummaryArgs {
1774 pair: "USDC".to_string(),
1775 venue: "eth".to_string(),
1776 chain: "ethereum".to_string(),
1777 peg: 1.0,
1778 min_levels: 1,
1779 min_depth: 50.0,
1780 peg_range: 0.01,
1781 min_bid_ask_ratio: 0.1,
1782 max_bid_ask_ratio: 10.0,
1783 format: SummaryFormat::Text,
1784 every: None,
1785 duration: None,
1786 report: Some(report_path.clone()),
1787 csv: None,
1788 };
1789 let http: std::sync::Arc<dyn crate::http::HttpClient> =
1790 std::sync::Arc::new(crate::http::NativeHttpClient::new().unwrap());
1791 let factory = DefaultClientFactory {
1792 chains_config: Default::default(),
1793 http,
1794 };
1795 let result = run_summary(args, &factory).await;
1796 if result.is_ok() {
1797 let content = std::fs::read_to_string(&report_path).unwrap();
1798 assert!(content.contains("Market Health Report"));
1799 }
1800 }
1801
1802 #[test]
1807 fn test_market_commands_debug() {
1808 let cmd = MarketCommands::Summary(SummaryArgs {
1809 pair: "USDC".to_string(),
1810 venue: "binance".to_string(),
1811 chain: "ethereum".to_string(),
1812 peg: 1.0,
1813 min_levels: 6,
1814 min_depth: 3000.0,
1815 peg_range: 0.001,
1816 min_bid_ask_ratio: 0.2,
1817 max_bid_ask_ratio: 5.0,
1818 format: SummaryFormat::Text,
1819 every: None,
1820 duration: None,
1821 report: None,
1822 csv: None,
1823 });
1824 let debug = format!("{:?}", cmd);
1825 assert!(debug.contains("Summary"));
1826
1827 let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
1828 pair: "BTC".to_string(),
1829 venue: "binance".to_string(),
1830 interval: "1h".to_string(),
1831 limit: 100,
1832 format: OhlcFormat::Text,
1833 });
1834 assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
1835
1836 let trades_cmd = MarketCommands::Trades(TradesArgs {
1837 pair: "ETH".to_string(),
1838 venue: "binance".to_string(),
1839 limit: 50,
1840 format: OhlcFormat::Json,
1841 });
1842 assert!(format!("{:?}", trades_cmd).contains("Trades"));
1843 }
1844
1845 #[test]
1846 fn test_summary_args_with_report_csv_options() {
1847 let args = SummaryArgs {
1848 pair: "DAI".to_string(),
1849 venue: "binance".to_string(),
1850 chain: "ethereum".to_string(),
1851 peg: 1.0,
1852 min_levels: 6,
1853 min_depth: 3000.0,
1854 peg_range: 0.001,
1855 min_bid_ask_ratio: 0.2,
1856 max_bid_ask_ratio: 5.0,
1857 format: SummaryFormat::Json,
1858 every: Some("30s".to_string()),
1859 duration: Some("1h".to_string()),
1860 report: Some(std::path::PathBuf::from("/tmp/report.md")),
1861 csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
1862 };
1863 assert_eq!(args.pair, "DAI");
1864 assert_eq!(args.venue, "binance");
1865 assert!(args.every.is_some());
1866 assert!(args.duration.is_some());
1867 assert!(args.report.is_some());
1868 assert!(args.csv.is_some());
1869 }
1870
1871 #[test]
1872 fn test_base_symbol_from_pair_4char_usdt() {
1873 assert_eq!(base_symbol_from_pair("USDT"), "USDT");
1875 }
1876
1877 #[test]
1878 fn test_market_summary_to_markdown_unhealthy() {
1879 use crate::market::{HealthCheck, MarketSummary};
1880 let summary = MarketSummary {
1881 pair: "X".to_string(),
1882 peg_target: 1.0,
1883 best_bid: Some(0.99),
1884 best_ask: Some(1.01),
1885 mid_price: Some(1.0),
1886 spread: Some(0.02),
1887 volume_24h: None,
1888 bid_depth: 100.0,
1889 ask_depth: 100.0,
1890 bid_outliers: 0,
1891 ask_outliers: 0,
1892 healthy: false,
1893 checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
1894 execution_10k_buy: None,
1895 execution_10k_sell: None,
1896 asks: vec![],
1897 bids: vec![],
1898 };
1899 let md = market_summary_to_markdown(&summary, "Test", "X");
1900 assert!(md.contains("✗"));
1901 assert!(md.contains("Peg deviation too high"));
1902 }
1903
1904 #[test]
1905 fn test_ohlc_format_eq() {
1906 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1907 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1908 }
1909
1910 #[test]
1911 fn test_trades_args_default_venue() {
1912 let args = TradesArgs {
1913 pair: "USDC".to_string(),
1914 venue: "binance".to_string(),
1915 limit: 50,
1916 format: OhlcFormat::Text,
1917 };
1918 assert_eq!(args.pair, "USDC");
1919 assert_eq!(args.venue, "binance");
1920 }
1921}