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 .unwrap();
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 println!("{}", serde_json::to_string_pretty(&json_candles).unwrap());
691 }
692 OhlcFormat::Text => {
693 println!(
694 "{}",
695 t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
696 );
697 println!("{}", t::kv_row("Interval", &args.interval));
698 println!("{}", t::kv_row("Limit", &args.limit.to_string()));
699
700 let cols = [
701 t::Col {
702 label: "Open Time",
703 width: 19,
704 align: '>',
705 },
706 t::Col {
707 label: "Open",
708 width: 12,
709 align: '>',
710 },
711 t::Col {
712 label: "High",
713 width: 12,
714 align: '>',
715 },
716 t::Col {
717 label: "Low",
718 width: 12,
719 align: '>',
720 },
721 t::Col {
722 label: "Close",
723 width: 12,
724 align: '>',
725 },
726 t::Col {
727 label: "Volume",
728 width: 14,
729 align: '>',
730 },
731 ];
732 println!("{}", t::table_header(&cols));
733
734 for c in &candles {
735 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
736 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
737 .unwrap_or_else(|| format!("{}", c.open_time));
738 let open_str = format!("{:.6}", c.open);
739 let high_str = format!("{:.6}", c.high);
740 let low_str = format!("{:.6}", c.low);
741 let close_str = format!("{:.6}", c.close);
742 let volume_str = format!("{:.2}", c.volume);
743 let values = [
744 dt.as_str(),
745 open_str.as_str(),
746 high_str.as_str(),
747 low_str.as_str(),
748 close_str.as_str(),
749 volume_str.as_str(),
750 ];
751 println!("{}", t::table_row(&cols, &values));
752 }
753
754 println!(
755 "{}",
756 t::info_row(&format!("{} candles returned", candles.len()))
757 );
758 println!("{}", t::section_footer());
759 }
760 }
761 Ok(())
762}
763
764async fn run_trades(args: TradesArgs) -> Result<()> {
770 let registry = VenueRegistry::load()?;
771 let descriptor = registry.get(&args.venue).ok_or_else(|| {
772 ScopeError::NotFound(format!(
773 "Venue '{}' not found. Use `scope venues list` to see available venues.",
774 args.venue
775 ))
776 })?;
777
778 let client = crate::market::ExchangeClient::from_descriptor(descriptor);
779 let pair = client.format_pair(base_symbol_from_pair(&args.pair));
780
781 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
782
783 match args.format {
784 OhlcFormat::Json => {
785 let json_trades: Vec<serde_json::Value> = trades
786 .iter()
787 .map(|t| {
788 serde_json::json!({
789 "price": t.price,
790 "quantity": t.quantity,
791 "quote_quantity": t.quote_quantity,
792 "timestamp_ms": t.timestamp_ms,
793 "side": format!("{:?}", t.side),
794 })
795 })
796 .collect();
797 println!("{}", serde_json::to_string_pretty(&json_trades).unwrap());
798 }
799 OhlcFormat::Text => {
800 println!(
801 "{}",
802 t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
803 );
804
805 let cols = [
806 t::Col {
807 label: "Time",
808 width: 10,
809 align: '>',
810 },
811 t::Col {
812 label: "Side",
813 width: 5,
814 align: '>',
815 },
816 t::Col {
817 label: "Price",
818 width: 12,
819 align: '>',
820 },
821 t::Col {
822 label: "Qty",
823 width: 12,
824 align: '>',
825 },
826 ];
827 println!("{}", t::table_header(&cols));
828
829 for t in &trades {
830 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
831 .map(|d| d.format("%H:%M:%S").to_string())
832 .unwrap_or_else(|| "?".to_string());
833 let side_str = match t.side {
834 crate::market::TradeSide::Buy => "BUY",
835 crate::market::TradeSide::Sell => "SELL",
836 };
837 let price_str = format!("{:.6}", t.price);
838 let qty_str = format!("{:.2}", t.quantity);
839 let values = [
840 time.as_str(),
841 side_str,
842 price_str.as_str(),
843 qty_str.as_str(),
844 ];
845 println!("{}", t::table_row(&cols, &values));
846 }
847
848 println!(
849 "{}",
850 t::info_row(&format!("{} trades returned", trades.len()))
851 );
852 println!("{}", t::section_footer());
853 }
854 }
855 Ok(())
856}
857
858#[cfg(test)]
859mod tests {
860 use super::*;
861 use crate::chains::DefaultClientFactory;
862
863 #[allow(dead_code)]
867 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
868 let venue_id = format!("test_mock_{}", std::process::id());
869 let yaml = format!(
870 r#"
871id: {venue_id}
872name: Test Mock Venue
873base_url: {server_url}
874timeout_secs: 5
875symbol:
876 template: "{{base}}_{{quote}}"
877 default_quote: USDT
878capabilities:
879 order_book:
880 path: /api/v1/depth
881 params:
882 symbol: "{{pair}}"
883 response:
884 asks_key: asks
885 bids_key: bids
886 level_format: positional
887 ticker:
888 path: /api/v1/ticker
889 params:
890 symbol: "{{pair}}"
891 response:
892 last_price: last
893 volume_24h: vol
894"#
895 );
896 let dir = tempfile::tempdir().unwrap();
897 let file_path = dir.path().join(format!("{}.yaml", venue_id));
898 std::fs::write(&file_path, yaml).unwrap();
899 (venue_id, dir)
902 }
903
904 #[tokio::test]
905 async fn test_run_summary_with_mock_orderbook() {
906 let args = SummaryArgs {
910 pair: "USDC".to_string(),
911 venue: "eth".to_string(),
912 chain: "ethereum".to_string(),
913 peg: 1.0,
914 min_levels: 1,
915 min_depth: 50.0,
916 peg_range: 0.01,
917 min_bid_ask_ratio: 0.1,
918 max_bid_ask_ratio: 10.0,
919 format: SummaryFormat::Text,
920 every: None,
921 duration: None,
922 report: None,
923 csv: None,
924 };
925
926 let factory = DefaultClientFactory {
927 chains_config: Default::default(),
928 };
929 let _result = run_summary(args, &factory).await;
931 }
933
934 #[tokio::test]
935 async fn test_run_summary_json_format() {
936 let args = SummaryArgs {
937 pair: "USDC".to_string(),
938 venue: "eth".to_string(),
939 chain: "ethereum".to_string(),
940 peg: 1.0,
941 min_levels: 1,
942 min_depth: 50.0,
943 peg_range: 0.01,
944 min_bid_ask_ratio: 0.1,
945 max_bid_ask_ratio: 10.0,
946 format: SummaryFormat::Json,
947 every: None,
948 duration: None,
949 report: None,
950 csv: None,
951 };
952
953 let factory = DefaultClientFactory {
954 chains_config: Default::default(),
955 };
956 let _result = run_summary(args, &factory).await;
957 }
958
959 #[test]
960 fn test_parse_duration_seconds() {
961 assert_eq!(parse_duration("30s").unwrap(), 30);
962 assert_eq!(parse_duration("1").unwrap(), 1);
963 assert_eq!(parse_duration("60sec").unwrap(), 60);
964 }
965
966 #[test]
967 fn test_parse_duration_minutes() {
968 assert_eq!(parse_duration("5m").unwrap(), 300);
969 assert_eq!(parse_duration("1min").unwrap(), 60);
970 assert_eq!(parse_duration("2.5m").unwrap(), 150);
971 }
972
973 #[test]
974 fn test_parse_duration_hours() {
975 assert_eq!(parse_duration("1h").unwrap(), 3600);
976 assert_eq!(parse_duration("24h").unwrap(), 86400);
977 }
978
979 #[test]
980 fn test_parse_duration_invalid() {
981 assert!(parse_duration("").is_err());
982 assert!(parse_duration("abc").is_err());
983 assert!(parse_duration("30x").is_err());
984 }
985
986 #[test]
987 fn test_parse_duration_unknown_unit_error_message() {
988 let result = parse_duration("30z");
989 assert!(result.is_err());
990 let err = result.unwrap_err();
991 assert!(err.to_string().contains("Unknown duration unit"));
992 assert!(err.to_string().contains("z"));
993 }
994
995 #[test]
996 fn test_parse_duration_invalid_number_error() {
997 let result = parse_duration("abc30s");
998 assert!(result.is_err());
999 }
1000
1001 #[test]
1002 fn test_parse_duration_non_positive() {
1003 assert!(parse_duration("0").is_err());
1004 assert!(parse_duration("-5s").is_err());
1005 }
1006
1007 #[test]
1012 fn test_base_symbol_from_pair_underscore() {
1013 assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1014 assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
1015 }
1016
1017 #[test]
1018 fn test_base_symbol_from_pair_lowercase_underscore() {
1019 assert_eq!(base_symbol_from_pair("dai_usdt"), "dai");
1020 }
1021
1022 #[test]
1023 fn test_base_symbol_from_pair_slash() {
1024 assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
1025 }
1026
1027 #[test]
1028 fn test_base_symbol_from_pair_concat() {
1029 assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
1030 assert_eq!(base_symbol_from_pair("DAIUSDT"), "DAI");
1031 }
1032
1033 #[test]
1034 fn test_base_symbol_from_pair_plain() {
1035 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1036 assert_eq!(base_symbol_from_pair("ETH"), "ETH");
1037 }
1038
1039 #[test]
1040 fn test_base_symbol_from_pair_whitespace() {
1041 assert_eq!(base_symbol_from_pair(" DAI_USDT "), "DAI");
1042 }
1043
1044 #[test]
1049 fn test_market_summary_to_markdown_basic() {
1050 use crate::market::{HealthCheck, MarketSummary};
1051 let summary = MarketSummary {
1052 pair: "USDCUSDT".to_string(),
1053 peg_target: 1.0,
1054 best_bid: Some(0.9999),
1055 best_ask: Some(1.0001),
1056 mid_price: Some(1.0000),
1057 spread: Some(0.0002),
1058 volume_24h: Some(1_000_000.0),
1059 bid_depth: 50_000.0,
1060 ask_depth: 50_000.0,
1061 bid_outliers: 0,
1062 ask_outliers: 0,
1063 healthy: true,
1064 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1065 execution_10k_buy: None,
1066 execution_10k_sell: None,
1067 asks: vec![],
1068 bids: vec![],
1069 };
1070 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1071 assert!(md.contains("Market Health Report"));
1072 assert!(md.contains("USDCUSDT"));
1073 assert!(md.contains("Binance"));
1074 assert!(md.contains("Peg Target"));
1075 assert!(md.contains("1.0000"));
1076 assert!(md.contains("Healthy"));
1077 }
1078
1079 #[test]
1080 fn test_market_summary_to_markdown_no_prices() {
1081 use crate::market::{HealthCheck, MarketSummary};
1082 let summary = MarketSummary {
1083 pair: "TESTUSDT".to_string(),
1084 peg_target: 1.0,
1085 best_bid: None,
1086 best_ask: None,
1087 mid_price: None,
1088 spread: None,
1089 volume_24h: None,
1090 bid_depth: 0.0,
1091 ask_depth: 0.0,
1092 bid_outliers: 0,
1093 ask_outliers: 0,
1094 healthy: false,
1095 checks: vec![HealthCheck::Fail("No data".to_string())],
1096 execution_10k_buy: None,
1097 execution_10k_sell: None,
1098 asks: vec![],
1099 bids: vec![],
1100 };
1101 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1102 assert!(md.contains("Market Health Report"));
1103 assert!(md.contains("-")); }
1105
1106 #[test]
1111 fn test_parse_duration_days() {
1112 assert_eq!(parse_duration("1d").unwrap(), 86400);
1113 assert_eq!(parse_duration("7d").unwrap(), 604800);
1114 assert_eq!(parse_duration("1day").unwrap(), 86400);
1115 assert_eq!(parse_duration("2days").unwrap(), 172800);
1116 }
1117
1118 #[test]
1119 fn test_parse_duration_long_names() {
1120 assert_eq!(parse_duration("30seconds").unwrap(), 30);
1121 assert_eq!(parse_duration("5minutes").unwrap(), 300);
1122 assert_eq!(parse_duration("2hours").unwrap(), 7200);
1123 }
1124
1125 #[test]
1126 fn test_parse_duration_fractional() {
1127 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1128 assert_eq!(parse_duration("1.5m").unwrap(), 90);
1129 }
1130
1131 #[test]
1136 fn test_summary_format_default() {
1137 let fmt = SummaryFormat::default();
1138 assert!(matches!(fmt, SummaryFormat::Text));
1139 }
1140
1141 #[test]
1142 fn test_summary_format_debug() {
1143 let text = format!("{:?}", SummaryFormat::Text);
1144 assert_eq!(text, "Text");
1145 let json = format!("{:?}", SummaryFormat::Json);
1146 assert_eq!(json, "Json");
1147 }
1148
1149 #[test]
1154 fn test_ohlc_args_deserialization() {
1155 use crate::cli::{Cli, Commands};
1156 use clap::Parser;
1157 let cli = Cli::try_parse_from([
1158 "scope",
1159 "market",
1160 "ohlc",
1161 "USDC",
1162 "--venue",
1163 "binance",
1164 "--interval",
1165 "1h",
1166 "--limit",
1167 "50",
1168 ])
1169 .unwrap();
1170 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1171 assert_eq!(args.pair, "USDC");
1172 assert_eq!(args.venue, "binance");
1173 assert_eq!(args.interval, "1h");
1174 assert_eq!(args.limit, 50);
1175 } else {
1176 panic!("Expected Market Ohlc command");
1177 }
1178 }
1179
1180 #[test]
1181 fn test_trades_args_deserialization() {
1182 use crate::cli::{Cli, Commands};
1183 use clap::Parser;
1184 let cli = Cli::try_parse_from([
1185 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1186 ])
1187 .unwrap();
1188 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1189 assert_eq!(args.pair, "BTC");
1190 assert_eq!(args.venue, "mexc");
1191 assert_eq!(args.limit, 100);
1192 } else {
1193 panic!("Expected Market Trades command");
1194 }
1195 }
1196
1197 #[test]
1198 fn test_base_symbol_from_pair_various_inputs() {
1199 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1201 assert_eq!(base_symbol_from_pair("BTCUSDT"), "BTC");
1202 assert_eq!(base_symbol_from_pair("ETH/USDT"), "ETH");
1203 assert_eq!(base_symbol_from_pair("DAI_USDT"), "DAI");
1204 assert_eq!(base_symbol_from_pair("X"), "X"); assert_eq!(base_symbol_from_pair(""), "");
1206 }
1207
1208 #[test]
1209 fn test_summary_args_debug() {
1210 let args = SummaryArgs {
1211 pair: "USDC".to_string(),
1212 venue: "binance".to_string(),
1213 chain: "ethereum".to_string(),
1214 peg: 1.0,
1215 min_levels: 6,
1216 min_depth: 3000.0,
1217 peg_range: 0.001,
1218 min_bid_ask_ratio: 0.2,
1219 max_bid_ask_ratio: 5.0,
1220 format: SummaryFormat::Text,
1221 every: None,
1222 duration: None,
1223 report: None,
1224 csv: None,
1225 };
1226 let debug = format!("{:?}", args);
1227 assert!(debug.contains("SummaryArgs"));
1228 assert!(debug.contains("USDC"));
1229 }
1230
1231 #[test]
1232 fn test_default_constants() {
1233 assert_eq!(DEFAULT_EVERY_SECS, 60);
1234 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1235 }
1236
1237 #[test]
1238 fn test_market_summary_to_markdown_with_execution_estimates() {
1239 use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1240 let summary = MarketSummary {
1241 pair: "TESTUSDT".to_string(),
1242 peg_target: 1.0,
1243 best_bid: Some(0.9999),
1244 best_ask: Some(1.0001),
1245 mid_price: Some(1.0000),
1246 spread: Some(0.0002),
1247 volume_24h: Some(1_000_000.0),
1248 bid_depth: 50_000.0,
1249 ask_depth: 50_000.0,
1250 bid_outliers: 0,
1251 ask_outliers: 0,
1252 healthy: true,
1253 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1254 execution_10k_buy: Some(ExecutionEstimate {
1255 notional_usdt: 10_000.0,
1256 side: ExecutionSide::Buy,
1257 vwap: 1.0001,
1258 slippage_bps: 1.5,
1259 fillable: true,
1260 }),
1261 execution_10k_sell: Some(ExecutionEstimate {
1262 notional_usdt: 10_000.0,
1263 side: ExecutionSide::Sell,
1264 vwap: 0.0,
1265 slippage_bps: 0.0,
1266 fillable: false,
1267 }),
1268 asks: vec![],
1269 bids: vec![],
1270 };
1271 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1272 assert!(md.contains("Market Health Report"));
1273 assert!(md.contains("TESTUSDT"));
1274 assert!(md.contains("TestVenue"));
1275 assert!(md.contains("1.50 bps"));
1277 assert!(md.contains("insufficient"));
1279 }
1280
1281 #[tokio::test]
1282 async fn test_run_with_summary_command() {
1283 let args = MarketCommands::Summary(SummaryArgs {
1285 pair: "USDC".to_string(),
1286 venue: "eth".to_string(),
1287 chain: "ethereum".to_string(),
1288 peg: 1.0,
1289 min_levels: 1,
1290 min_depth: 50.0,
1291 peg_range: 0.01,
1292 min_bid_ask_ratio: 0.1,
1293 max_bid_ask_ratio: 10.0,
1294 format: SummaryFormat::Text,
1295 every: None,
1296 duration: None,
1297 report: None,
1298 csv: None,
1299 });
1300
1301 let factory = DefaultClientFactory {
1302 chains_config: Default::default(),
1303 };
1304 let config = Config::default();
1305 let _result = run(args, &config, &factory).await;
1306 }
1308
1309 #[test]
1310 fn test_is_dex_venue() {
1311 assert!(is_dex_venue("eth"));
1312 assert!(is_dex_venue("ethereum"));
1313 assert!(is_dex_venue("Ethereum"));
1314 assert!(is_dex_venue("solana"));
1315 assert!(is_dex_venue("Solana"));
1316 assert!(!is_dex_venue("binance"));
1317 assert!(!is_dex_venue("okx"));
1318 assert!(!is_dex_venue("mexc"));
1319 }
1320
1321 #[test]
1322 fn test_dex_venue_to_chain() {
1323 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
1324 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
1325 assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
1326 assert_eq!(dex_venue_to_chain("solana"), "solana");
1327 }
1328
1329 #[test]
1330 fn test_venue_registry_loaded_in_cex_path() {
1331 let registry = VenueRegistry::load().unwrap();
1333 assert!(registry.contains("binance"));
1334 let client = registry.create_exchange_client("binance");
1335 assert!(client.is_ok());
1336 }
1337
1338 #[test]
1339 fn test_venue_registry_error_for_unknown() {
1340 let registry = VenueRegistry::load().unwrap();
1341 let result = registry.create_exchange_client("kracken");
1342 assert!(result.is_err());
1343 let err = result.unwrap_err().to_string();
1344 assert!(err.contains("Unknown venue"));
1345 assert!(err.contains("Did you mean")); }
1347
1348 #[tokio::test]
1349 async fn test_run_summary_json_format_with_mock() {
1350 let args = SummaryArgs {
1351 pair: "USDC".to_string(),
1352 venue: "eth".to_string(),
1353 chain: "ethereum".to_string(),
1354 peg: 1.0,
1355 min_levels: 1,
1356 min_depth: 50.0,
1357 peg_range: 0.01,
1358 min_bid_ask_ratio: 0.1,
1359 max_bid_ask_ratio: 10.0,
1360 format: SummaryFormat::Json,
1361 every: None,
1362 duration: None,
1363 report: None,
1364 csv: None,
1365 };
1366
1367 let factory = DefaultClientFactory {
1368 chains_config: Default::default(),
1369 };
1370 let _result = run_summary(args, &factory).await;
1371 }
1372
1373 #[test]
1378 fn test_ohlc_format_default() {
1379 let fmt: OhlcFormat = Default::default();
1380 assert_eq!(fmt, OhlcFormat::Text);
1381 }
1382
1383 #[test]
1384 fn test_ohlc_format_display() {
1385 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1387 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1388 }
1389
1390 #[test]
1391 fn test_ohlc_args_default_values() {
1392 let args = OhlcArgs {
1394 pair: "BTC".to_string(),
1395 venue: "binance".to_string(),
1396 interval: "1h".to_string(),
1397 limit: 100,
1398 format: OhlcFormat::Text,
1399 };
1400 assert_eq!(args.pair, "BTC");
1401 assert_eq!(args.venue, "binance");
1402 assert_eq!(args.interval, "1h");
1403 assert_eq!(args.limit, 100);
1404 }
1405
1406 #[test]
1407 fn test_trades_args_construction() {
1408 let args = TradesArgs {
1409 pair: "ETH".to_string(),
1410 venue: "okx".to_string(),
1411 limit: 50,
1412 format: OhlcFormat::Json,
1413 };
1414 assert_eq!(args.pair, "ETH");
1415 assert_eq!(args.venue, "okx");
1416 assert_eq!(args.limit, 50);
1417 }
1418
1419 #[tokio::test]
1420 async fn test_run_ohlc_unknown_venue() {
1421 let args = OhlcArgs {
1422 pair: "BTC".to_string(),
1423 venue: "nonexistent_venue".to_string(),
1424 interval: "1h".to_string(),
1425 limit: 10,
1426 format: OhlcFormat::Text,
1427 };
1428 let result = run_ohlc(args).await;
1429 assert!(result.is_err());
1430 let err = result.unwrap_err().to_string();
1431 assert!(
1432 err.contains("not found"),
1433 "expected 'not found' error, got: {}",
1434 err
1435 );
1436 }
1437
1438 #[tokio::test]
1439 async fn test_run_trades_unknown_venue() {
1440 let args = TradesArgs {
1441 pair: "BTC".to_string(),
1442 venue: "nonexistent_venue".to_string(),
1443 limit: 10,
1444 format: OhlcFormat::Text,
1445 };
1446 let result = run_trades(args).await;
1447 assert!(result.is_err());
1448 let err = result.unwrap_err().to_string();
1449 assert!(
1450 err.contains("not found"),
1451 "expected 'not found' error, got: {}",
1452 err
1453 );
1454 }
1455
1456 #[tokio::test]
1457 async fn test_run_dispatches_ohlc() {
1458 let cmd = MarketCommands::Ohlc(OhlcArgs {
1459 pair: "BTC".to_string(),
1460 venue: "nonexistent_test_venue".to_string(),
1461 interval: "1h".to_string(),
1462 limit: 5,
1463 format: OhlcFormat::Text,
1464 });
1465 let factory = DefaultClientFactory {
1466 chains_config: Default::default(),
1467 };
1468 let config = Config::default();
1469 let result = run(cmd, &config, &factory).await;
1470 assert!(result.is_err());
1472 }
1473
1474 #[tokio::test]
1475 async fn test_run_dispatches_trades() {
1476 let cmd = MarketCommands::Trades(TradesArgs {
1477 pair: "ETH".to_string(),
1478 venue: "nonexistent_test_venue".to_string(),
1479 limit: 5,
1480 format: OhlcFormat::Json,
1481 });
1482 let factory = DefaultClientFactory {
1483 chains_config: Default::default(),
1484 };
1485 let config = Config::default();
1486 let result = run(cmd, &config, &factory).await;
1487 assert!(result.is_err());
1488 }
1489
1490 #[tokio::test]
1491 async fn test_run_ohlc_text_format_with_real_venue() {
1492 let args = OhlcArgs {
1495 pair: "BTC".to_string(),
1496 venue: "binance".to_string(),
1497 interval: "1h".to_string(),
1498 limit: 3,
1499 format: OhlcFormat::Text,
1500 };
1501 let _result = run_ohlc(args).await;
1502 }
1504
1505 #[tokio::test]
1506 async fn test_run_ohlc_json_format_with_real_venue() {
1507 let args = OhlcArgs {
1508 pair: "ETH".to_string(),
1509 venue: "binance".to_string(),
1510 interval: "15m".to_string(),
1511 limit: 2,
1512 format: OhlcFormat::Json,
1513 };
1514 let _result = run_ohlc(args).await;
1515 }
1516
1517 #[tokio::test]
1518 async fn test_run_trades_text_format_with_real_venue() {
1519 let args = TradesArgs {
1520 pair: "BTC".to_string(),
1521 venue: "binance".to_string(),
1522 limit: 5,
1523 format: OhlcFormat::Text,
1524 };
1525 let _result = run_trades(args).await;
1526 }
1527
1528 #[tokio::test]
1529 async fn test_run_trades_json_format_with_real_venue() {
1530 let args = TradesArgs {
1531 pair: "ETH".to_string(),
1532 venue: "binance".to_string(),
1533 limit: 3,
1534 format: OhlcFormat::Json,
1535 };
1536 let _result = run_trades(args).await;
1537 }
1538
1539 #[tokio::test]
1540 async fn test_run_ohlc_multiple_venues() {
1541 for venue in &["mexc", "okx", "bybit"] {
1543 let args = OhlcArgs {
1544 pair: "BTC".to_string(),
1545 venue: venue.to_string(),
1546 interval: "1h".to_string(),
1547 limit: 2,
1548 format: OhlcFormat::Json,
1549 };
1550 let _result = run_ohlc(args).await;
1551 }
1552 }
1553
1554 #[tokio::test]
1555 async fn test_run_trades_multiple_venues() {
1556 for venue in &["mexc", "okx", "bybit"] {
1557 let args = TradesArgs {
1558 pair: "BTC".to_string(),
1559 venue: venue.to_string(),
1560 limit: 3,
1561 format: OhlcFormat::Text,
1562 };
1563 let _result = run_trades(args).await;
1564 }
1565 }
1566
1567 #[test]
1572 fn test_parse_duration_whitespace_empty() {
1573 assert!(parse_duration("").is_err());
1574 assert!(parse_duration(" ").is_err());
1575 }
1576
1577 #[test]
1578 fn test_parse_duration_sec_secs_second_seconds() {
1579 assert_eq!(parse_duration("1sec").unwrap(), 1);
1580 assert_eq!(parse_duration("2secs").unwrap(), 2);
1581 assert_eq!(parse_duration("1second").unwrap(), 1);
1582 assert_eq!(parse_duration("3seconds").unwrap(), 3);
1583 }
1584
1585 #[test]
1586 fn test_parse_duration_minute_minutes() {
1587 assert_eq!(parse_duration("1minute").unwrap(), 60);
1588 assert_eq!(parse_duration("2minutes").unwrap(), 120);
1589 }
1590
1591 #[test]
1592 fn test_parse_duration_hr_hrs_hour_hours() {
1593 assert_eq!(parse_duration("1hr").unwrap(), 3600);
1594 assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1595 assert_eq!(parse_duration("1hour").unwrap(), 3600);
1596 assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1597 }
1598
1599 #[test]
1600 fn test_parse_duration_number_only_defaults_to_seconds() {
1601 assert_eq!(parse_duration("1.5").unwrap(), 1);
1602 assert_eq!(parse_duration("42").unwrap(), 42);
1603 }
1604
1605 #[test]
1606 fn test_parse_duration_trimmed_input() {
1607 assert_eq!(parse_duration(" 30s ").unwrap(), 30);
1608 assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1609 }
1610
1611 #[test]
1612 fn test_parse_duration_invalid_number_format() {
1613 assert!(parse_duration("1.2.3s").is_err());
1614 assert!(parse_duration("abc").is_err());
1615 }
1616
1617 #[test]
1622 fn test_dex_venue_to_chain_unknown_returns_ethereum() {
1623 assert_eq!(dex_venue_to_chain("binance"), "ethereum");
1624 assert_eq!(dex_venue_to_chain("kraken"), "ethereum");
1625 assert_eq!(dex_venue_to_chain("unknown"), "ethereum");
1626 }
1627
1628 #[test]
1633 fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1634 use crate::market::{HealthCheck, MarketSummary};
1635 let summary = MarketSummary {
1636 pair: "TESTUSDT".to_string(),
1637 peg_target: 1.0,
1638 best_bid: Some(0.9999),
1639 best_ask: Some(1.0001),
1640 mid_price: Some(1.0000),
1641 spread: Some(0.0002),
1642 volume_24h: Some(500_000.0),
1643 bid_depth: 40_000.0,
1644 ask_depth: 45_000.0,
1645 bid_outliers: 0,
1646 ask_outliers: 0,
1647 healthy: false,
1648 checks: vec![
1649 HealthCheck::Pass("Spread within range".to_string()),
1650 HealthCheck::Fail("Bid depth below minimum".to_string()),
1651 ],
1652 execution_10k_buy: None,
1653 execution_10k_sell: None,
1654 asks: vec![],
1655 bids: vec![],
1656 };
1657 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1658 assert!(md.contains("✓ Spread within range"));
1659 assert!(md.contains("✗ Bid depth below minimum"));
1660 assert!(md.contains("✗")); }
1662
1663 #[test]
1664 fn test_market_summary_to_markdown_empty_checks() {
1665 use crate::market::MarketSummary;
1666 let summary = MarketSummary {
1667 pair: "X".to_string(),
1668 peg_target: 1.0,
1669 best_bid: Some(1.0),
1670 best_ask: Some(1.0),
1671 mid_price: Some(1.0),
1672 spread: Some(0.0),
1673 volume_24h: None,
1674 bid_depth: 100.0,
1675 ask_depth: 100.0,
1676 bid_outliers: 0,
1677 ask_outliers: 0,
1678 healthy: true,
1679 checks: vec![],
1680 execution_10k_buy: None,
1681 execution_10k_sell: None,
1682 asks: vec![],
1683 bids: vec![],
1684 };
1685 let md = market_summary_to_markdown(&summary, "Venue", "X");
1686 assert!(md.contains("Market Health Report"));
1687 assert!(md.contains("Health Checks"));
1688 }
1689
1690 #[test]
1695 fn test_ohlc_format_partial_eq() {
1696 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1697 assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1698 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1699 }
1700
1701 #[test]
1702 fn test_summary_format_clone_copy() {
1703 let text = SummaryFormat::Text;
1704 let cloned = text;
1705 assert!(matches!(cloned, SummaryFormat::Text));
1706 assert!(matches!(text, SummaryFormat::Text));
1707 }
1708
1709 #[tokio::test]
1714 async fn test_run_summary_interval_zero_error() {
1715 let args = SummaryArgs {
1716 pair: "USDC".to_string(),
1717 venue: "eth".to_string(),
1718 chain: "ethereum".to_string(),
1719 peg: 1.0,
1720 min_levels: 1,
1721 min_depth: 50.0,
1722 peg_range: 0.01,
1723 min_bid_ask_ratio: 0.1,
1724 max_bid_ask_ratio: 10.0,
1725 format: SummaryFormat::Text,
1726 every: Some("0.1s".to_string()),
1727 duration: Some("1m".to_string()),
1728 report: None,
1729 csv: None,
1730 };
1731 let factory = DefaultClientFactory {
1732 chains_config: Default::default(),
1733 };
1734 let result = run_summary(args, &factory).await;
1735 assert!(result.is_err());
1736 let err = result.unwrap_err().to_string();
1737 assert!(
1738 err.contains("Interval must be positive") || err.contains("positive"),
1739 "expected interval error, got: {}",
1740 err
1741 );
1742 }
1743
1744 #[tokio::test]
1745 async fn test_run_summary_one_shot_with_report() {
1746 let report_dir = tempfile::tempdir().unwrap();
1747 let report_path = report_dir.path().join("report.md");
1748 let args = SummaryArgs {
1749 pair: "USDC".to_string(),
1750 venue: "eth".to_string(),
1751 chain: "ethereum".to_string(),
1752 peg: 1.0,
1753 min_levels: 1,
1754 min_depth: 50.0,
1755 peg_range: 0.01,
1756 min_bid_ask_ratio: 0.1,
1757 max_bid_ask_ratio: 10.0,
1758 format: SummaryFormat::Text,
1759 every: None,
1760 duration: None,
1761 report: Some(report_path.clone()),
1762 csv: None,
1763 };
1764 let factory = DefaultClientFactory {
1765 chains_config: Default::default(),
1766 };
1767 let result = run_summary(args, &factory).await;
1768 if result.is_ok() {
1769 let content = std::fs::read_to_string(&report_path).unwrap();
1770 assert!(content.contains("Market Health Report"));
1771 }
1772 }
1773
1774 #[test]
1779 fn test_market_commands_debug() {
1780 let cmd = MarketCommands::Summary(SummaryArgs {
1781 pair: "USDC".to_string(),
1782 venue: "binance".to_string(),
1783 chain: "ethereum".to_string(),
1784 peg: 1.0,
1785 min_levels: 6,
1786 min_depth: 3000.0,
1787 peg_range: 0.001,
1788 min_bid_ask_ratio: 0.2,
1789 max_bid_ask_ratio: 5.0,
1790 format: SummaryFormat::Text,
1791 every: None,
1792 duration: None,
1793 report: None,
1794 csv: None,
1795 });
1796 let debug = format!("{:?}", cmd);
1797 assert!(debug.contains("Summary"));
1798
1799 let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
1800 pair: "BTC".to_string(),
1801 venue: "binance".to_string(),
1802 interval: "1h".to_string(),
1803 limit: 100,
1804 format: OhlcFormat::Text,
1805 });
1806 assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
1807
1808 let trades_cmd = MarketCommands::Trades(TradesArgs {
1809 pair: "ETH".to_string(),
1810 venue: "binance".to_string(),
1811 limit: 50,
1812 format: OhlcFormat::Json,
1813 });
1814 assert!(format!("{:?}", trades_cmd).contains("Trades"));
1815 }
1816
1817 #[test]
1818 fn test_summary_args_with_report_csv_options() {
1819 let args = SummaryArgs {
1820 pair: "DAI".to_string(),
1821 venue: "binance".to_string(),
1822 chain: "ethereum".to_string(),
1823 peg: 1.0,
1824 min_levels: 6,
1825 min_depth: 3000.0,
1826 peg_range: 0.001,
1827 min_bid_ask_ratio: 0.2,
1828 max_bid_ask_ratio: 5.0,
1829 format: SummaryFormat::Json,
1830 every: Some("30s".to_string()),
1831 duration: Some("1h".to_string()),
1832 report: Some(std::path::PathBuf::from("/tmp/report.md")),
1833 csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
1834 };
1835 assert_eq!(args.pair, "DAI");
1836 assert_eq!(args.venue, "binance");
1837 assert!(args.every.is_some());
1838 assert!(args.duration.is_some());
1839 assert!(args.report.is_some());
1840 assert!(args.csv.is_some());
1841 }
1842
1843 #[test]
1844 fn test_base_symbol_from_pair_4char_usdt() {
1845 assert_eq!(base_symbol_from_pair("USDT"), "USDT");
1847 }
1848
1849 #[test]
1850 fn test_market_summary_to_markdown_unhealthy() {
1851 use crate::market::{HealthCheck, MarketSummary};
1852 let summary = MarketSummary {
1853 pair: "X".to_string(),
1854 peg_target: 1.0,
1855 best_bid: Some(0.99),
1856 best_ask: Some(1.01),
1857 mid_price: Some(1.0),
1858 spread: Some(0.02),
1859 volume_24h: None,
1860 bid_depth: 100.0,
1861 ask_depth: 100.0,
1862 bid_outliers: 0,
1863 ask_outliers: 0,
1864 healthy: false,
1865 checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
1866 execution_10k_buy: None,
1867 execution_10k_sell: None,
1868 asks: vec![],
1869 bids: vec![],
1870 };
1871 let md = market_summary_to_markdown(&summary, "Test", "X");
1872 assert!(md.contains("✗"));
1873 assert!(md.contains("Peg deviation too high"));
1874 }
1875
1876 #[test]
1877 fn test_ohlc_format_eq() {
1878 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1879 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1880 }
1881
1882 #[test]
1883 fn test_trades_args_default_venue() {
1884 let args = TradesArgs {
1885 pair: "USDC".to_string(),
1886 venue: "binance".to_string(),
1887 limit: 50,
1888 format: OhlcFormat::Text,
1889 };
1890 assert_eq!(args.pair, "USDC");
1891 assert_eq!(args.venue, "binance");
1892 }
1893}