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)]
47pub struct SummaryArgs {
48 #[arg(default_value = "USDC", value_name = "SYMBOL")]
50 pub pair: String,
51
52 #[arg(long, default_value = "binance", value_name = "VENUE")]
55 pub venue: String,
56
57 #[arg(long, default_value = "ethereum", value_name = "CHAIN")]
59 pub chain: String,
60
61 #[arg(long, default_value = "1.0", value_name = "TARGET")]
63 pub peg: f64,
64
65 #[arg(long, default_value = "6", value_name = "N")]
67 pub min_levels: usize,
68
69 #[arg(long, default_value = "3000", value_name = "USDT")]
71 pub min_depth: f64,
72
73 #[arg(long, default_value = "0.001", value_name = "RANGE")]
76 pub peg_range: f64,
77
78 #[arg(long, default_value = "0.2", value_name = "RATIO")]
80 pub min_bid_ask_ratio: f64,
81
82 #[arg(long, default_value = "5.0", value_name = "RATIO")]
84 pub max_bid_ask_ratio: f64,
85
86 #[arg(short, long, default_value = "text")]
88 pub format: SummaryFormat,
89
90 #[arg(long, value_name = "INTERVAL")]
93 pub every: Option<String>,
94
95 #[arg(long, value_name = "DURATION")]
98 pub duration: Option<String>,
99
100 #[arg(long, value_name = "PATH")]
102 pub report: Option<std::path::PathBuf>,
103
104 #[arg(long, value_name = "PATH")]
106 pub csv: Option<std::path::PathBuf>,
107}
108
109#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
110pub enum SummaryFormat {
111 #[default]
113 Text,
114 Json,
116}
117
118#[derive(Debug, Args)]
120pub struct OhlcArgs {
121 #[arg(default_value = "USDC", value_name = "SYMBOL")]
123 pub pair: String,
124
125 #[arg(long, default_value = "binance", value_name = "VENUE")]
127 pub venue: String,
128
129 #[arg(long, default_value = "1h", value_name = "INTERVAL")]
131 pub interval: String,
132
133 #[arg(long, default_value = "100", value_name = "LIMIT")]
135 pub limit: u32,
136
137 #[arg(long, default_value = "text")]
139 pub format: OhlcFormat,
140}
141
142#[derive(Debug, Args)]
144pub struct TradesArgs {
145 #[arg(default_value = "USDC", value_name = "SYMBOL")]
147 pub pair: String,
148
149 #[arg(long, default_value = "binance", value_name = "VENUE")]
151 pub venue: String,
152
153 #[arg(long, default_value = "50", value_name = "LIMIT")]
155 pub limit: u32,
156
157 #[arg(long, default_value = "text")]
159 pub format: OhlcFormat,
160}
161
162#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
164pub enum OhlcFormat {
165 #[default]
167 Text,
168 Json,
170}
171
172pub async fn run(
174 args: MarketCommands,
175 _config: &Config,
176 factory: &dyn ChainClientFactory,
177) -> Result<()> {
178 match args {
179 MarketCommands::Summary(summary_args) => run_summary(summary_args, factory).await,
180 MarketCommands::Ohlc(ohlc_args) => run_ohlc(ohlc_args).await,
181 MarketCommands::Trades(trades_args) => run_trades(trades_args).await,
182 }
183}
184
185fn base_symbol_from_pair(pair: &str) -> &str {
188 let p = pair.trim();
189 if let Some(i) = p.find("_USDT") {
190 return &p[..i];
191 }
192 if let Some(i) = p.find("_usdt") {
193 return &p[..i];
194 }
195 if let Some(i) = p.find("/USDT") {
196 return &p[..i];
197 }
198 if p.to_uppercase().ends_with("USDT") && p.len() > 4 {
199 return &p[..p.len() - 4];
200 }
201 p
202}
203
204fn parse_duration(s: &str) -> Result<u64> {
205 let s = s.trim();
206 if s.is_empty() {
207 return Err(ScopeError::Chain("Empty duration".to_string()));
208 }
209 let (num_str, unit) = s
210 .char_indices()
211 .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
212 .map(|(i, _)| (&s[..i], s[i..].trim()))
213 .unwrap_or((s, "s"));
214
215 let num: f64 = num_str
216 .parse()
217 .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
218
219 if num <= 0.0 {
220 return Err(ScopeError::Chain("Duration must be positive".to_string()));
221 }
222
223 let secs = match unit.to_lowercase().as_str() {
224 "s" | "sec" | "secs" | "second" | "seconds" => num,
225 "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
226 "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
227 "d" | "day" | "days" => num * 86400.0,
228 _ => {
229 return Err(ScopeError::Chain(format!(
230 "Unknown duration unit: {}",
231 unit
232 )));
233 }
234 };
235
236 Ok(secs as u64)
237}
238
239fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
241 let bid_dev = summary
242 .best_bid
243 .map(|b| (b - summary.peg_target) / summary.peg_target * 100.0);
244 let ask_dev = summary
245 .best_ask
246 .map(|a| (a - summary.peg_target) / summary.peg_target * 100.0);
247 let volume_row = summary
248 .volume_24h
249 .map(|v| format!("| Volume (24h) | {:.0} USDT | \n", v))
250 .unwrap_or_default();
251 let exec_buy = summary
252 .execution_10k_buy
253 .as_ref()
254 .map(|e| {
255 if e.fillable {
256 format!("{:.2} bps", e.slippage_bps)
257 } else {
258 "insufficient".to_string()
259 }
260 })
261 .unwrap_or_else(|| "-".to_string());
262 let exec_sell = summary
263 .execution_10k_sell
264 .as_ref()
265 .map(|e| {
266 if e.fillable {
267 format!("{:.2} bps", e.slippage_bps)
268 } else {
269 "insufficient".to_string()
270 }
271 })
272 .unwrap_or_else(|| "-".to_string());
273 let mut md = format!(
274 "# Market Health Report: {} \n\
275 **Venue:** {} \n\
276 **Generated:** {} \n\n\
277 ## Peg & Spread \n\
278 | Metric | Value | \n\
279 |--------|-------| \n\
280 | Peg Target | {:.4} | \n\
281 | Best Bid | {} | \n\
282 | Best Ask | {} | \n\
283 | Mid Price | {} | \n\
284 | Spread | {} | \n\
285 {}\
286 | 10k Buy Slippage | {} | \n\
287 | 10k Sell Slippage | {} | \n\
288 | Bid Depth | {:.0} | \n\
289 | Ask Depth | {:.0} | \n\
290 | Healthy | {} | \n\n\
291 ## Health Checks \n",
292 pair,
293 venue,
294 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
295 summary.peg_target,
296 summary
297 .best_bid
298 .map(|b| format!("{:.4} ({:+.3}%)", b, bid_dev.unwrap_or(0.0)))
299 .unwrap_or_else(|| "-".to_string()),
300 summary
301 .best_ask
302 .map(|a| format!("{:.4} ({:+.3}%)", a, ask_dev.unwrap_or(0.0)))
303 .unwrap_or_else(|| "-".to_string()),
304 summary
305 .mid_price
306 .map(|m| format!("{:.4}", m))
307 .unwrap_or_else(|| "-".to_string()),
308 summary
309 .spread
310 .map(|s| format!("{:.4}", s))
311 .unwrap_or_else(|| "-".to_string()),
312 volume_row,
313 exec_buy,
314 exec_sell,
315 summary.bid_depth,
316 summary.ask_depth,
317 if summary.healthy { "✓" } else { "✗" }
318 );
319 for check in &summary.checks {
320 let (icon, msg) = match check {
321 crate::market::HealthCheck::Pass(m) => ("✓", m.as_str()),
322 crate::market::HealthCheck::Fail(m) => ("✗", m.as_str()),
323 };
324 md.push_str(&format!("- {} {}\n", icon, msg));
325 }
326 md.push_str(&crate::display::report::report_footer());
327 md
328}
329
330fn is_dex_venue(venue: &str) -> bool {
332 matches!(venue.to_lowercase().as_str(), "ethereum" | "eth" | "solana")
333}
334
335fn dex_venue_to_chain(venue: &str) -> &str {
337 match venue.to_lowercase().as_str() {
338 "ethereum" | "eth" => "ethereum",
339 "solana" => "solana",
340 _ => "ethereum",
341 }
342}
343
344async fn fetch_book_and_volume(
345 args: &SummaryArgs,
346 factory: &dyn ChainClientFactory,
347) -> Result<(OrderBook, Option<f64>)> {
348 let base = base_symbol_from_pair(&args.pair).to_string();
349
350 if is_dex_venue(&args.venue) {
351 let chain = dex_venue_to_chain(&args.venue);
353 let analytics =
354 crawl::fetch_analytics_for_input(&base, chain, Period::Hour24, 10, factory, None)
355 .await?;
356 if analytics.dex_pairs.is_empty() {
357 return Err(ScopeError::Chain(format!(
358 "No DEX pairs found for {} on {}",
359 base, chain
360 )));
361 }
362 let best_pair = analytics
363 .dex_pairs
364 .iter()
365 .max_by(|a, b| {
366 a.liquidity_usd
367 .partial_cmp(&b.liquidity_usd)
368 .unwrap_or(std::cmp::Ordering::Equal)
369 })
370 .unwrap();
371 let book = order_book_from_analytics(chain, best_pair, &analytics.token.symbol);
372 let volume = Some(best_pair.volume_24h);
373 Ok((book, volume))
374 } else {
375 let registry = VenueRegistry::load()?;
377 let exchange = registry.create_exchange_client(&args.venue)?;
378 let pair = exchange.format_pair(&base);
379 let book = exchange.fetch_order_book(&pair).await?;
380
381 let volume = if exchange.has_ticker() {
383 exchange
384 .fetch_ticker(&pair)
385 .await
386 .ok()
387 .and_then(|t| t.quote_volume_24h.or(t.volume_24h))
388 } else {
389 None
390 };
391 Ok((book, volume))
392 }
393}
394
395async fn run_summary_once(
396 args: &SummaryArgs,
397 factory: &dyn ChainClientFactory,
398 thresholds: &HealthThresholds,
399 run_num: Option<u64>,
400) -> Result<MarketSummary> {
401 if let Some(n) = run_num {
402 let ts = chrono::Utc::now().format("%H:%M:%S");
403 eprintln!(" --- Run #{} at {} ---\n", n, ts);
404 }
405
406 let (book, volume_24h) = fetch_book_and_volume(args, factory).await?;
407 let summary = MarketSummary::from_order_book(&book, args.peg, thresholds, volume_24h);
408
409 let venue_label = args.venue.clone();
410
411 match args.format {
412 SummaryFormat::Text => {
413 print!("{}", summary.format_text(Some(&venue_label)));
414 }
415 SummaryFormat::Json => {
416 let json = serde_json::json!({
417 "run": run_num,
418 "venue": venue_label,
419 "pair": summary.pair,
420 "peg_target": summary.peg_target,
421 "best_bid": summary.best_bid,
422 "best_ask": summary.best_ask,
423 "mid_price": summary.mid_price,
424 "spread": summary.spread,
425 "volume_24h": summary.volume_24h,
426 "execution_10k_buy": summary.execution_10k_buy.as_ref().map(|e| serde_json::json!({
427 "fillable": e.fillable,
428 "slippage_bps": e.slippage_bps
429 })),
430 "execution_10k_sell": summary.execution_10k_sell.as_ref().map(|e| serde_json::json!({
431 "fillable": e.fillable,
432 "slippage_bps": e.slippage_bps
433 })),
434 "ask_depth": summary.ask_depth,
435 "bid_depth": summary.bid_depth,
436 "ask_levels": summary.asks.len(),
437 "bid_levels": summary.bids.len(),
438 "healthy": summary.healthy,
439 "checks": summary.checks.iter().map(|c| match c {
440 crate::market::HealthCheck::Pass(m) => serde_json::json!({"status": "pass", "message": m}),
441 crate::market::HealthCheck::Fail(m) => serde_json::json!({"status": "fail", "message": m}),
442 }).collect::<Vec<_>>(),
443 });
444 println!("{}", serde_json::to_string_pretty(&json)?);
445 }
446 }
447
448 Ok(summary)
449}
450
451async fn run_summary(args: SummaryArgs, factory: &dyn ChainClientFactory) -> Result<()> {
452 let thresholds = HealthThresholds {
453 peg_target: args.peg,
454 peg_range: args.peg_range,
455 min_levels: args.min_levels,
456 min_depth: args.min_depth,
457 min_bid_ask_ratio: args.min_bid_ask_ratio,
458 max_bid_ask_ratio: args.max_bid_ask_ratio,
459 };
460
461 let repeat_mode = args.every.is_some() || args.duration.is_some();
462
463 if !repeat_mode {
464 let summary = run_summary_once(&args, factory, &thresholds, None).await?;
465 if let Some(ref report_path) = args.report {
466 let venue_label = args.venue.clone();
467 let md = market_summary_to_markdown(&summary, &venue_label, &args.pair);
468 std::fs::write(report_path, md)?;
469 eprintln!("\nReport saved to: {}", report_path.display());
470 }
471 return Ok(());
472 }
473
474 let every_secs = args
475 .every
476 .as_ref()
477 .map(|s| parse_duration(s))
478 .transpose()?
479 .unwrap_or(DEFAULT_EVERY_SECS);
480
481 let duration_secs = args
482 .duration
483 .as_ref()
484 .map(|s| parse_duration(s))
485 .transpose()?
486 .unwrap_or(DEFAULT_DURATION_SECS);
487
488 if every_secs == 0 {
489 return Err(ScopeError::Chain("Interval must be positive".to_string()));
490 }
491
492 let every = Duration::from_secs(every_secs);
493 let start = std::time::Instant::now();
494 let duration = Duration::from_secs(duration_secs);
495
496 eprintln!(
497 "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
498 every_secs, duration_secs
499 );
500
501 let mut run_num: u64 = 1;
502 #[allow(unused_assignments)]
503 let mut last_summary: Option<MarketSummary> = None;
504
505 if let Some(ref csv_path) = args.csv {
507 let header =
508 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy\n";
509 std::fs::write(csv_path, header)?;
510 }
511
512 loop {
513 let summary = run_summary_once(&args, factory, &thresholds, Some(run_num)).await?;
514 last_summary = Some(summary.clone());
515
516 if let Some(ref csv_path) = args.csv {
518 let ts = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
519 let bid = summary
520 .best_bid
521 .map(|v| v.to_string())
522 .unwrap_or_else(|| "-".to_string());
523 let ask = summary
524 .best_ask
525 .map(|v| v.to_string())
526 .unwrap_or_else(|| "-".to_string());
527 let mid = summary
528 .mid_price
529 .map(|v| v.to_string())
530 .unwrap_or_else(|| "-".to_string());
531 let spread = summary
532 .spread
533 .map(|v| v.to_string())
534 .unwrap_or_else(|| "-".to_string());
535 let row = format!(
536 "{},{},{},{},{},{},{},{},{}\n",
537 ts,
538 run_num,
539 bid,
540 ask,
541 mid,
542 spread,
543 summary.bid_depth,
544 summary.ask_depth,
545 summary.healthy
546 );
547 let mut f = std::fs::OpenOptions::new().append(true).open(csv_path)?;
548 use std::io::Write;
549 f.write_all(row.as_bytes())?;
550 }
551
552 if start.elapsed() >= duration {
553 eprintln!("\nCompleted {} run(s) over {}s.", run_num, duration_secs);
554 break;
555 }
556
557 run_num += 1;
558
559 let remaining = duration.saturating_sub(start.elapsed());
560 let sleep_duration = if remaining < every { remaining } else { every };
561 tokio::time::sleep(sleep_duration).await;
562 }
563
564 if let (Some(ref report_path), Some(summary)) = (args.report, last_summary.as_ref()) {
566 let venue_label = args.venue.clone();
567 let md = market_summary_to_markdown(summary, &venue_label, &args.pair);
568 std::fs::write(report_path, md)?;
569 eprintln!("Report saved to: {}", report_path.display());
570 }
571 if let Some(ref csv_path) = args.csv {
572 eprintln!("Time-series CSV saved to: {}", csv_path.display());
573 }
574
575 Ok(())
576}
577
578async fn run_ohlc(args: OhlcArgs) -> Result<()> {
584 let registry = VenueRegistry::load()?;
585 let descriptor = registry.get(&args.venue).ok_or_else(|| {
586 ScopeError::NotFound(format!(
587 "Venue '{}' not found. Use `scope venues list` to see available venues.",
588 args.venue
589 ))
590 })?;
591
592 let client = crate::market::ExchangeClient::from_descriptor(descriptor);
593 let pair = client.format_pair(base_symbol_from_pair(&args.pair));
594
595 let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
596
597 match args.format {
598 OhlcFormat::Json => {
599 let json_candles: Vec<serde_json::Value> = candles
600 .iter()
601 .map(|c| {
602 serde_json::json!({
603 "open_time": c.open_time,
604 "open": c.open,
605 "high": c.high,
606 "low": c.low,
607 "close": c.close,
608 "volume": c.volume,
609 "close_time": c.close_time,
610 })
611 })
612 .collect();
613 println!("{}", serde_json::to_string_pretty(&json_candles).unwrap());
614 }
615 OhlcFormat::Text => {
616 println!();
617 println!(
618 "OHLC — {} ({}) interval={} limit={}",
619 pair, args.venue, args.interval, args.limit
620 );
621 println!("──────────────────────────────────────────────────────────");
622 println!(
623 " {:>19} {:>12} {:>12} {:>12} {:>12} {:>14}",
624 "Open Time", "Open", "High", "Low", "Close", "Volume"
625 );
626 println!("──────────────────────────────────────────────────────────");
627 for c in &candles {
628 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
629 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
630 .unwrap_or_else(|| format!("{}", c.open_time));
631 println!(
632 " {:>19} {:>12.6} {:>12.6} {:>12.6} {:>12.6} {:>14.2}",
633 dt, c.open, c.high, c.low, c.close, c.volume
634 );
635 }
636 println!();
637 println!(" {} candles returned", candles.len());
638 println!();
639 }
640 }
641 Ok(())
642}
643
644async fn run_trades(args: TradesArgs) -> Result<()> {
650 let registry = VenueRegistry::load()?;
651 let descriptor = registry.get(&args.venue).ok_or_else(|| {
652 ScopeError::NotFound(format!(
653 "Venue '{}' not found. Use `scope venues list` to see available venues.",
654 args.venue
655 ))
656 })?;
657
658 let client = crate::market::ExchangeClient::from_descriptor(descriptor);
659 let pair = client.format_pair(base_symbol_from_pair(&args.pair));
660
661 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
662
663 match args.format {
664 OhlcFormat::Json => {
665 let json_trades: Vec<serde_json::Value> = trades
666 .iter()
667 .map(|t| {
668 serde_json::json!({
669 "price": t.price,
670 "quantity": t.quantity,
671 "quote_quantity": t.quote_quantity,
672 "timestamp_ms": t.timestamp_ms,
673 "side": format!("{:?}", t.side),
674 })
675 })
676 .collect();
677 println!("{}", serde_json::to_string_pretty(&json_trades).unwrap());
678 }
679 OhlcFormat::Text => {
680 println!();
681 println!("Recent Trades — {} ({})", pair, args.venue);
682 println!("──────────────────────────────────────");
683 println!(
684 " {:>10} {:>5} {:>12} {:>12}",
685 "Time", "Side", "Price", "Qty"
686 );
687 println!("──────────────────────────────────────");
688 for t in &trades {
689 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
690 .map(|d| d.format("%H:%M:%S").to_string())
691 .unwrap_or_else(|| "?".to_string());
692 let side = match t.side {
693 crate::market::TradeSide::Buy => "BUY",
694 crate::market::TradeSide::Sell => "SELL",
695 };
696 println!(
697 " {:>10} {:>5} {:>12.6} {:>12.2}",
698 time, side, t.price, t.quantity
699 );
700 }
701 println!();
702 println!(" {} trades returned", trades.len());
703 println!();
704 }
705 }
706 Ok(())
707}
708
709#[cfg(test)]
710mod tests {
711 use super::*;
712 use crate::chains::DefaultClientFactory;
713
714 #[allow(dead_code)]
718 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
719 let venue_id = format!("test_mock_{}", std::process::id());
720 let yaml = format!(
721 r#"
722id: {venue_id}
723name: Test Mock Venue
724base_url: {server_url}
725timeout_secs: 5
726symbol:
727 template: "{{base}}_{{quote}}"
728 default_quote: USDT
729capabilities:
730 order_book:
731 path: /api/v1/depth
732 params:
733 symbol: "{{pair}}"
734 response:
735 asks_key: asks
736 bids_key: bids
737 level_format: positional
738 ticker:
739 path: /api/v1/ticker
740 params:
741 symbol: "{{pair}}"
742 response:
743 last_price: last
744 volume_24h: vol
745"#
746 );
747 let dir = tempfile::tempdir().unwrap();
748 let file_path = dir.path().join(format!("{}.yaml", venue_id));
749 std::fs::write(&file_path, yaml).unwrap();
750 (venue_id, dir)
753 }
754
755 #[tokio::test]
756 async fn test_run_summary_with_mock_orderbook() {
757 let args = SummaryArgs {
761 pair: "USDC".to_string(),
762 venue: "eth".to_string(),
763 chain: "ethereum".to_string(),
764 peg: 1.0,
765 min_levels: 1,
766 min_depth: 50.0,
767 peg_range: 0.01,
768 min_bid_ask_ratio: 0.1,
769 max_bid_ask_ratio: 10.0,
770 format: SummaryFormat::Text,
771 every: None,
772 duration: None,
773 report: None,
774 csv: None,
775 };
776
777 let factory = DefaultClientFactory {
778 chains_config: Default::default(),
779 };
780 let _result = run_summary(args, &factory).await;
782 }
784
785 #[tokio::test]
786 async fn test_run_summary_json_format() {
787 let args = SummaryArgs {
788 pair: "USDC".to_string(),
789 venue: "eth".to_string(),
790 chain: "ethereum".to_string(),
791 peg: 1.0,
792 min_levels: 1,
793 min_depth: 50.0,
794 peg_range: 0.01,
795 min_bid_ask_ratio: 0.1,
796 max_bid_ask_ratio: 10.0,
797 format: SummaryFormat::Json,
798 every: None,
799 duration: None,
800 report: None,
801 csv: None,
802 };
803
804 let factory = DefaultClientFactory {
805 chains_config: Default::default(),
806 };
807 let _result = run_summary(args, &factory).await;
808 }
809
810 #[test]
811 fn test_parse_duration_seconds() {
812 assert_eq!(parse_duration("30s").unwrap(), 30);
813 assert_eq!(parse_duration("1").unwrap(), 1);
814 assert_eq!(parse_duration("60sec").unwrap(), 60);
815 }
816
817 #[test]
818 fn test_parse_duration_minutes() {
819 assert_eq!(parse_duration("5m").unwrap(), 300);
820 assert_eq!(parse_duration("1min").unwrap(), 60);
821 assert_eq!(parse_duration("2.5m").unwrap(), 150);
822 }
823
824 #[test]
825 fn test_parse_duration_hours() {
826 assert_eq!(parse_duration("1h").unwrap(), 3600);
827 assert_eq!(parse_duration("24h").unwrap(), 86400);
828 }
829
830 #[test]
831 fn test_parse_duration_invalid() {
832 assert!(parse_duration("").is_err());
833 assert!(parse_duration("abc").is_err());
834 assert!(parse_duration("30x").is_err());
835 }
836
837 #[test]
838 fn test_parse_duration_non_positive() {
839 assert!(parse_duration("0").is_err());
840 assert!(parse_duration("-5s").is_err());
841 }
842
843 #[test]
848 fn test_base_symbol_from_pair_underscore() {
849 assert_eq!(base_symbol_from_pair("PUSD_USDT"), "PUSD");
850 assert_eq!(base_symbol_from_pair("USDC_USDT"), "USDC");
851 }
852
853 #[test]
854 fn test_base_symbol_from_pair_lowercase_underscore() {
855 assert_eq!(base_symbol_from_pair("pusd_usdt"), "pusd");
856 }
857
858 #[test]
859 fn test_base_symbol_from_pair_slash() {
860 assert_eq!(base_symbol_from_pair("USDC/USDT"), "USDC");
861 }
862
863 #[test]
864 fn test_base_symbol_from_pair_concat() {
865 assert_eq!(base_symbol_from_pair("USDCUSDT"), "USDC");
866 assert_eq!(base_symbol_from_pair("PUSDUSDT"), "PUSD");
867 }
868
869 #[test]
870 fn test_base_symbol_from_pair_plain() {
871 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
872 assert_eq!(base_symbol_from_pair("ETH"), "ETH");
873 }
874
875 #[test]
876 fn test_base_symbol_from_pair_whitespace() {
877 assert_eq!(base_symbol_from_pair(" PUSD_USDT "), "PUSD");
878 }
879
880 #[test]
885 fn test_market_summary_to_markdown_basic() {
886 use crate::market::{HealthCheck, MarketSummary};
887 let summary = MarketSummary {
888 pair: "USDCUSDT".to_string(),
889 peg_target: 1.0,
890 best_bid: Some(0.9999),
891 best_ask: Some(1.0001),
892 mid_price: Some(1.0000),
893 spread: Some(0.0002),
894 volume_24h: Some(1_000_000.0),
895 bid_depth: 50_000.0,
896 ask_depth: 50_000.0,
897 bid_outliers: 0,
898 ask_outliers: 0,
899 healthy: true,
900 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
901 execution_10k_buy: None,
902 execution_10k_sell: None,
903 asks: vec![],
904 bids: vec![],
905 };
906 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
907 assert!(md.contains("Market Health Report"));
908 assert!(md.contains("USDCUSDT"));
909 assert!(md.contains("Binance"));
910 assert!(md.contains("Peg Target"));
911 assert!(md.contains("1.0000"));
912 assert!(md.contains("Healthy"));
913 }
914
915 #[test]
916 fn test_market_summary_to_markdown_no_prices() {
917 use crate::market::{HealthCheck, MarketSummary};
918 let summary = MarketSummary {
919 pair: "TESTUSDT".to_string(),
920 peg_target: 1.0,
921 best_bid: None,
922 best_ask: None,
923 mid_price: None,
924 spread: None,
925 volume_24h: None,
926 bid_depth: 0.0,
927 ask_depth: 0.0,
928 bid_outliers: 0,
929 ask_outliers: 0,
930 healthy: false,
931 checks: vec![HealthCheck::Fail("No data".to_string())],
932 execution_10k_buy: None,
933 execution_10k_sell: None,
934 asks: vec![],
935 bids: vec![],
936 };
937 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
938 assert!(md.contains("Market Health Report"));
939 assert!(md.contains("-")); }
941
942 #[test]
947 fn test_parse_duration_days() {
948 assert_eq!(parse_duration("1d").unwrap(), 86400);
949 assert_eq!(parse_duration("7d").unwrap(), 604800);
950 assert_eq!(parse_duration("1day").unwrap(), 86400);
951 assert_eq!(parse_duration("2days").unwrap(), 172800);
952 }
953
954 #[test]
955 fn test_parse_duration_long_names() {
956 assert_eq!(parse_duration("30seconds").unwrap(), 30);
957 assert_eq!(parse_duration("5minutes").unwrap(), 300);
958 assert_eq!(parse_duration("2hours").unwrap(), 7200);
959 }
960
961 #[test]
962 fn test_parse_duration_fractional() {
963 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
964 assert_eq!(parse_duration("1.5m").unwrap(), 90);
965 }
966
967 #[test]
972 fn test_summary_format_default() {
973 let fmt = SummaryFormat::default();
974 assert!(matches!(fmt, SummaryFormat::Text));
975 }
976
977 #[test]
978 fn test_summary_format_debug() {
979 let text = format!("{:?}", SummaryFormat::Text);
980 assert_eq!(text, "Text");
981 let json = format!("{:?}", SummaryFormat::Json);
982 assert_eq!(json, "Json");
983 }
984
985 #[test]
990 fn test_ohlc_args_deserialization() {
991 use crate::cli::{Cli, Commands};
992 use clap::Parser;
993 let cli = Cli::try_parse_from([
994 "scope",
995 "market",
996 "ohlc",
997 "USDC",
998 "--venue",
999 "binance",
1000 "--interval",
1001 "1h",
1002 "--limit",
1003 "50",
1004 ])
1005 .unwrap();
1006 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1007 assert_eq!(args.pair, "USDC");
1008 assert_eq!(args.venue, "binance");
1009 assert_eq!(args.interval, "1h");
1010 assert_eq!(args.limit, 50);
1011 } else {
1012 panic!("Expected Market Ohlc command");
1013 }
1014 }
1015
1016 #[test]
1017 fn test_trades_args_deserialization() {
1018 use crate::cli::{Cli, Commands};
1019 use clap::Parser;
1020 let cli = Cli::try_parse_from([
1021 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1022 ])
1023 .unwrap();
1024 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1025 assert_eq!(args.pair, "BTC");
1026 assert_eq!(args.venue, "mexc");
1027 assert_eq!(args.limit, 100);
1028 } else {
1029 panic!("Expected Market Trades command");
1030 }
1031 }
1032
1033 #[test]
1034 fn test_base_symbol_from_pair_various_inputs() {
1035 assert_eq!(base_symbol_from_pair("USDC"), "USDC");
1037 assert_eq!(base_symbol_from_pair("BTCUSDT"), "BTC");
1038 assert_eq!(base_symbol_from_pair("ETH/USDT"), "ETH");
1039 assert_eq!(base_symbol_from_pair("PUSD_USDT"), "PUSD");
1040 assert_eq!(base_symbol_from_pair("X"), "X"); assert_eq!(base_symbol_from_pair(""), "");
1042 }
1043
1044 #[test]
1045 fn test_summary_args_debug() {
1046 let args = SummaryArgs {
1047 pair: "USDC".to_string(),
1048 venue: "binance".to_string(),
1049 chain: "ethereum".to_string(),
1050 peg: 1.0,
1051 min_levels: 6,
1052 min_depth: 3000.0,
1053 peg_range: 0.001,
1054 min_bid_ask_ratio: 0.2,
1055 max_bid_ask_ratio: 5.0,
1056 format: SummaryFormat::Text,
1057 every: None,
1058 duration: None,
1059 report: None,
1060 csv: None,
1061 };
1062 let debug = format!("{:?}", args);
1063 assert!(debug.contains("SummaryArgs"));
1064 assert!(debug.contains("USDC"));
1065 }
1066
1067 #[test]
1068 fn test_default_constants() {
1069 assert_eq!(DEFAULT_EVERY_SECS, 60);
1070 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1071 }
1072
1073 #[test]
1074 fn test_market_summary_to_markdown_with_execution_estimates() {
1075 use crate::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1076 let summary = MarketSummary {
1077 pair: "TESTUSDT".to_string(),
1078 peg_target: 1.0,
1079 best_bid: Some(0.9999),
1080 best_ask: Some(1.0001),
1081 mid_price: Some(1.0000),
1082 spread: Some(0.0002),
1083 volume_24h: Some(1_000_000.0),
1084 bid_depth: 50_000.0,
1085 ask_depth: 50_000.0,
1086 bid_outliers: 0,
1087 ask_outliers: 0,
1088 healthy: true,
1089 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1090 execution_10k_buy: Some(ExecutionEstimate {
1091 notional_usdt: 10_000.0,
1092 side: ExecutionSide::Buy,
1093 vwap: 1.0001,
1094 slippage_bps: 1.5,
1095 fillable: true,
1096 }),
1097 execution_10k_sell: Some(ExecutionEstimate {
1098 notional_usdt: 10_000.0,
1099 side: ExecutionSide::Sell,
1100 vwap: 0.0,
1101 slippage_bps: 0.0,
1102 fillable: false,
1103 }),
1104 asks: vec![],
1105 bids: vec![],
1106 };
1107 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1108 assert!(md.contains("Market Health Report"));
1109 assert!(md.contains("TESTUSDT"));
1110 assert!(md.contains("TestVenue"));
1111 assert!(md.contains("1.50 bps"));
1113 assert!(md.contains("insufficient"));
1115 }
1116
1117 #[tokio::test]
1118 async fn test_run_with_summary_command() {
1119 let args = MarketCommands::Summary(SummaryArgs {
1121 pair: "USDC".to_string(),
1122 venue: "eth".to_string(),
1123 chain: "ethereum".to_string(),
1124 peg: 1.0,
1125 min_levels: 1,
1126 min_depth: 50.0,
1127 peg_range: 0.01,
1128 min_bid_ask_ratio: 0.1,
1129 max_bid_ask_ratio: 10.0,
1130 format: SummaryFormat::Text,
1131 every: None,
1132 duration: None,
1133 report: None,
1134 csv: None,
1135 });
1136
1137 let factory = DefaultClientFactory {
1138 chains_config: Default::default(),
1139 };
1140 let config = Config::default();
1141 let _result = run(args, &config, &factory).await;
1142 }
1144
1145 #[test]
1146 fn test_is_dex_venue() {
1147 assert!(is_dex_venue("eth"));
1148 assert!(is_dex_venue("ethereum"));
1149 assert!(is_dex_venue("Ethereum"));
1150 assert!(is_dex_venue("solana"));
1151 assert!(is_dex_venue("Solana"));
1152 assert!(!is_dex_venue("binance"));
1153 assert!(!is_dex_venue("okx"));
1154 assert!(!is_dex_venue("mexc"));
1155 }
1156
1157 #[test]
1158 fn test_dex_venue_to_chain() {
1159 assert_eq!(dex_venue_to_chain("eth"), "ethereum");
1160 assert_eq!(dex_venue_to_chain("ethereum"), "ethereum");
1161 assert_eq!(dex_venue_to_chain("Ethereum"), "ethereum");
1162 assert_eq!(dex_venue_to_chain("solana"), "solana");
1163 }
1164
1165 #[test]
1166 fn test_venue_registry_loaded_in_cex_path() {
1167 let registry = VenueRegistry::load().unwrap();
1169 assert!(registry.contains("binance"));
1170 let client = registry.create_exchange_client("binance");
1171 assert!(client.is_ok());
1172 }
1173
1174 #[test]
1175 fn test_venue_registry_error_for_unknown() {
1176 let registry = VenueRegistry::load().unwrap();
1177 let result = registry.create_exchange_client("kracken");
1178 assert!(result.is_err());
1179 let err = result.unwrap_err().to_string();
1180 assert!(err.contains("Unknown venue"));
1181 assert!(err.contains("Did you mean")); }
1183
1184 #[tokio::test]
1185 async fn test_run_summary_json_format_with_mock() {
1186 let args = SummaryArgs {
1187 pair: "USDC".to_string(),
1188 venue: "eth".to_string(),
1189 chain: "ethereum".to_string(),
1190 peg: 1.0,
1191 min_levels: 1,
1192 min_depth: 50.0,
1193 peg_range: 0.01,
1194 min_bid_ask_ratio: 0.1,
1195 max_bid_ask_ratio: 10.0,
1196 format: SummaryFormat::Json,
1197 every: None,
1198 duration: None,
1199 report: None,
1200 csv: None,
1201 };
1202
1203 let factory = DefaultClientFactory {
1204 chains_config: Default::default(),
1205 };
1206 let _result = run_summary(args, &factory).await;
1207 }
1208
1209 #[test]
1214 fn test_ohlc_format_default() {
1215 let fmt: OhlcFormat = Default::default();
1216 assert_eq!(fmt, OhlcFormat::Text);
1217 }
1218
1219 #[test]
1220 fn test_ohlc_format_display() {
1221 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1223 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1224 }
1225
1226 #[test]
1227 fn test_ohlc_args_default_values() {
1228 let args = OhlcArgs {
1230 pair: "BTC".to_string(),
1231 venue: "binance".to_string(),
1232 interval: "1h".to_string(),
1233 limit: 100,
1234 format: OhlcFormat::Text,
1235 };
1236 assert_eq!(args.pair, "BTC");
1237 assert_eq!(args.venue, "binance");
1238 assert_eq!(args.interval, "1h");
1239 assert_eq!(args.limit, 100);
1240 }
1241
1242 #[test]
1243 fn test_trades_args_construction() {
1244 let args = TradesArgs {
1245 pair: "ETH".to_string(),
1246 venue: "okx".to_string(),
1247 limit: 50,
1248 format: OhlcFormat::Json,
1249 };
1250 assert_eq!(args.pair, "ETH");
1251 assert_eq!(args.venue, "okx");
1252 assert_eq!(args.limit, 50);
1253 }
1254
1255 #[tokio::test]
1256 async fn test_run_ohlc_unknown_venue() {
1257 let args = OhlcArgs {
1258 pair: "BTC".to_string(),
1259 venue: "nonexistent_venue".to_string(),
1260 interval: "1h".to_string(),
1261 limit: 10,
1262 format: OhlcFormat::Text,
1263 };
1264 let result = run_ohlc(args).await;
1265 assert!(result.is_err());
1266 let err = result.unwrap_err().to_string();
1267 assert!(
1268 err.contains("not found"),
1269 "expected 'not found' error, got: {}",
1270 err
1271 );
1272 }
1273
1274 #[tokio::test]
1275 async fn test_run_trades_unknown_venue() {
1276 let args = TradesArgs {
1277 pair: "BTC".to_string(),
1278 venue: "nonexistent_venue".to_string(),
1279 limit: 10,
1280 format: OhlcFormat::Text,
1281 };
1282 let result = run_trades(args).await;
1283 assert!(result.is_err());
1284 let err = result.unwrap_err().to_string();
1285 assert!(
1286 err.contains("not found"),
1287 "expected 'not found' error, got: {}",
1288 err
1289 );
1290 }
1291
1292 #[tokio::test]
1293 async fn test_run_dispatches_ohlc() {
1294 let cmd = MarketCommands::Ohlc(OhlcArgs {
1295 pair: "BTC".to_string(),
1296 venue: "nonexistent_test_venue".to_string(),
1297 interval: "1h".to_string(),
1298 limit: 5,
1299 format: OhlcFormat::Text,
1300 });
1301 let factory = DefaultClientFactory {
1302 chains_config: Default::default(),
1303 };
1304 let config = Config::default();
1305 let result = run(cmd, &config, &factory).await;
1306 assert!(result.is_err());
1308 }
1309
1310 #[tokio::test]
1311 async fn test_run_dispatches_trades() {
1312 let cmd = MarketCommands::Trades(TradesArgs {
1313 pair: "ETH".to_string(),
1314 venue: "nonexistent_test_venue".to_string(),
1315 limit: 5,
1316 format: OhlcFormat::Json,
1317 });
1318 let factory = DefaultClientFactory {
1319 chains_config: Default::default(),
1320 };
1321 let config = Config::default();
1322 let result = run(cmd, &config, &factory).await;
1323 assert!(result.is_err());
1324 }
1325
1326 #[tokio::test]
1327 async fn test_run_ohlc_text_format_with_real_venue() {
1328 let args = OhlcArgs {
1331 pair: "BTC".to_string(),
1332 venue: "binance".to_string(),
1333 interval: "1h".to_string(),
1334 limit: 3,
1335 format: OhlcFormat::Text,
1336 };
1337 let _result = run_ohlc(args).await;
1338 }
1340
1341 #[tokio::test]
1342 async fn test_run_ohlc_json_format_with_real_venue() {
1343 let args = OhlcArgs {
1344 pair: "ETH".to_string(),
1345 venue: "binance".to_string(),
1346 interval: "15m".to_string(),
1347 limit: 2,
1348 format: OhlcFormat::Json,
1349 };
1350 let _result = run_ohlc(args).await;
1351 }
1352
1353 #[tokio::test]
1354 async fn test_run_trades_text_format_with_real_venue() {
1355 let args = TradesArgs {
1356 pair: "BTC".to_string(),
1357 venue: "binance".to_string(),
1358 limit: 5,
1359 format: OhlcFormat::Text,
1360 };
1361 let _result = run_trades(args).await;
1362 }
1363
1364 #[tokio::test]
1365 async fn test_run_trades_json_format_with_real_venue() {
1366 let args = TradesArgs {
1367 pair: "ETH".to_string(),
1368 venue: "binance".to_string(),
1369 limit: 3,
1370 format: OhlcFormat::Json,
1371 };
1372 let _result = run_trades(args).await;
1373 }
1374
1375 #[tokio::test]
1376 async fn test_run_ohlc_multiple_venues() {
1377 for venue in &["mexc", "okx", "bybit"] {
1379 let args = OhlcArgs {
1380 pair: "BTC".to_string(),
1381 venue: venue.to_string(),
1382 interval: "1h".to_string(),
1383 limit: 2,
1384 format: OhlcFormat::Json,
1385 };
1386 let _result = run_ohlc(args).await;
1387 }
1388 }
1389
1390 #[tokio::test]
1391 async fn test_run_trades_multiple_venues() {
1392 for venue in &["mexc", "okx", "bybit"] {
1393 let args = TradesArgs {
1394 pair: "BTC".to_string(),
1395 venue: venue.to_string(),
1396 limit: 3,
1397 format: OhlcFormat::Text,
1398 };
1399 let _result = run_trades(args).await;
1400 }
1401 }
1402}