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