1use crate::cli::crawl::{self, Period};
9use crate::cli::output::Output;
10use crate::{note, out, outln};
11use clap::{Args, Subcommand};
12use scope::chains::{ChainClientFactory, TokenAnalytics};
13use scope::config::Config;
14
15use super::health_args::HealthArgs;
16use crate::cli::repeat::{self, RunOutput, Schedule};
17use scope::display::terminal as t;
18use scope::error::{Result, ScopeError};
19use scope::market::assets::TokenAddress;
20use scope::market::series;
21use scope::market::{
22 DexAnalyticsSource, ExchangeClient, HealthThresholds, MarketSummary, MarketVenue, OrderBook,
23 Pair, VENUE_HELP, VenueRegistry,
24};
25use std::time::Duration;
26
27const PAIR_HELP: &str = "Pair or base symbol. BASE_QUOTE, BASE/QUOTE and BASE-QUOTE work for any quote \
29 (BTC_ETH). BASEQUOTE works only for a stable or fiat quote (OPUSDT, BTCEUR); WETH stays \
30 WETH. A bare base (USDC) uses the venue's default quote.";
31
32pub const DEFAULT_EVERY_SECS: u64 = 60;
34
35pub const DEFAULT_DURATION_SECS: u64 = 3600;
37
38#[derive(Debug, Subcommand)]
40#[allow(clippy::large_enum_variant)]
41pub enum MarketCommands {
42 Summary(SummaryArgs),
49
50 Ohlc(OhlcArgs),
52
53 Trades(TradesArgs),
55}
56
57#[derive(Debug, Args)]
62#[command(
63 after_help = "\x1b[1mExamples:\x1b[0m
64 scope market summary DAI --venue binance
65 scope market summary @dai-token --venue binance \x1b[2m# address book shortcut\x1b[0m
66 scope market summary USDC --venue binance --format json
67 scope market summary DAI --venue binance --every 30s --duration 1h
68 scope market summary DAI --venue binance --report health.md --csv peg.csv",
69 after_long_help = "\x1b[1mExamples:\x1b[0m
70
71 \x1b[1m$ scope market summary DAI --venue binance\x1b[0m
72
73 +-- DAI/USDT (binance) ----------------------------+
74 | |
75 |-- Metrics |
76 | Best Bid 0.9999 (-0.010%) |
77 | Best Ask 1.0001 (+0.010%) |
78 | Mid Price 1.0000 (+0.000%) |
79 | Spread 0.0002 (0.020%) |
80 | Volume (24h) 125.00K USDT |
81 | |
82 |-- Health Checks |
83 | + No sells below peg |
84 | + Bid/Ask ratio: 0.93x |
85 | + Bid levels: 12 >= 10 minimum |
86 | + Bid depth: 42000 USDT >= 3000 USDT minimum |
87 | |
88 | HEALTHY |
89 +-----------------------------------------------------+
90
91 \x1b[1m$ scope market summary DAI --venue binance --every 30s --duration 1h\x1b[0m
92
93 Monitoring DAI/USDT (binance) every 30s for 1h...
94 [2026-02-16 10:00:00] Mid=1.0000 Spread=0.020% Depth=42K/45K HEALTHY
95 [2026-02-16 10:00:30] Mid=1.0000 Spread=0.020% Depth=42K/44K HEALTHY
96 [2026-02-16 10:01:00] Mid=0.9999 Spread=0.030% Depth=41K/44K HEALTHY
97 ..."
98)]
99pub struct SummaryArgs {
100 #[arg(default_value = scope::market::DEFAULT_PAIR, value_name = "SYMBOL", help = PAIR_HELP)]
101 pub pair: String,
102
103 #[arg(long, default_value = scope::market::DEFAULT_VENUE, value_name = "VENUE", help = VENUE_HELP)]
104 pub venue: String,
105
106 #[arg(long, value_name = "CHAIN")]
109 pub chain: Option<String>,
110
111 #[command(flatten)]
113 pub health: HealthArgs,
114
115 #[arg(short, long, default_value = "text")]
117 pub format: SummaryFormat,
118
119 #[arg(long, value_name = "INTERVAL")]
122 pub every: Option<String>,
123
124 #[arg(long, value_name = "DURATION")]
127 pub duration: Option<String>,
128
129 #[arg(long, value_name = "PATH")]
131 pub report: Option<std::path::PathBuf>,
132
133 #[arg(long, value_name = "PATH")]
135 pub csv: Option<std::path::PathBuf>,
136}
137
138#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
139pub enum SummaryFormat {
140 #[default]
142 Text,
143 Json,
145}
146
147#[derive(Debug, Args)]
149#[command(
150 after_help = "\x1b[1mExamples:\x1b[0m
151 scope market ohlc BTC
152 scope market ohlc DAI --venue binance --interval 1d
153 scope market ohlc ETH --venue mexc --limit 50 --format json",
154 after_long_help = "\x1b[1mExamples:\x1b[0m
155
156 \x1b[1m$ scope market ohlc BTC --limit 5\x1b[0m
157
158 OHLC -- BTCUSDT (binance) interval=1h limit=5
159 --------------------------------------------------------
160 Open Time Open High Low Close Volume
161 --------------------------------------------------------
162 2026-02-16 09:00 97250.120000 97380.540000 97210.980000 97345.670000 1234.56
163 2026-02-16 08:00 97100.890000 97260.120000 97080.340000 97250.120000 1456.78
164 2026-02-16 07:00 96950.230000 97120.890000 96920.560000 97100.890000 1678.90
165 ...
166
167 5 candles returned
168
169 \x1b[1m$ scope market ohlc BTC --format json --limit 2\x1b[0m
170
171 [
172 {
173 \"open_time\": 1739696400000,
174 \"open\": 97250.12,
175 \"high\": 97380.54,
176 \"low\": 97210.98,
177 \"close\": 97345.67,
178 \"volume\": 1234.56,
179 \"close_time\": null
180 },
181 ...
182 ]"
183)]
184pub struct OhlcArgs {
185 #[arg(default_value = scope::market::DEFAULT_PAIR, value_name = "SYMBOL", help = PAIR_HELP)]
186 pub pair: String,
187
188 #[arg(long, default_value = scope::market::DEFAULT_VENUE, value_name = "VENUE")]
190 pub venue: String,
191
192 #[arg(long, default_value = "1h", value_name = "INTERVAL")]
194 pub interval: String,
195
196 #[arg(long, default_value = "100", value_name = "LIMIT")]
198 pub limit: u32,
199
200 #[arg(long, default_value = "text")]
202 pub format: OhlcFormat,
203}
204
205#[derive(Debug, Args)]
207#[command(after_help = "\x1b[1mExamples:\x1b[0m
208 scope market trades BTC
209 scope market trades DAI --venue binance --limit 20
210 scope market trades ETH --venue okx --format json")]
211pub struct TradesArgs {
212 #[arg(default_value = scope::market::DEFAULT_PAIR, value_name = "SYMBOL", help = PAIR_HELP)]
213 pub pair: String,
214
215 #[arg(long, default_value = scope::market::DEFAULT_VENUE, value_name = "VENUE")]
217 pub venue: String,
218
219 #[arg(long, default_value = "50", value_name = "LIMIT")]
221 pub limit: u32,
222
223 #[arg(long, default_value = "text")]
225 pub format: OhlcFormat,
226}
227
228#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
230pub enum OhlcFormat {
231 #[default]
233 Text,
234 Json,
236}
237
238pub async fn run(
240 args: MarketCommands,
241 config: &Config,
242 factory: &dyn ChainClientFactory,
243 out: &Output,
244) -> Result<()> {
245 match args {
246 MarketCommands::Summary(summary_args) => {
247 run_summary(summary_args, config, factory, out).await
248 }
249 MarketCommands::Ohlc(ohlc_args) => run_ohlc(ohlc_args, out).await,
250 MarketCommands::Trades(trades_args) => run_trades(trades_args, out).await,
251 }
252}
253
254pub(crate) fn venue_pair(exchange: &scope::market::ExchangeClient, input: &str) -> String {
259 exchange.venue_symbol(&Pair::parse(input))
260}
261
262pub struct CrawlDexSource<'a> {
265 pub factory: &'a dyn ChainClientFactory,
267 pub out: &'a Output,
269}
270
271#[async_trait::async_trait]
272impl DexAnalyticsSource for CrawlDexSource<'_> {
273 async fn token_analytics(&self, token: &str, chain: &str) -> Result<TokenAnalytics> {
274 crawl::fetch_analytics_for_input(
275 token,
276 chain,
277 Period::Hour24,
278 10,
279 self.factory,
280 None,
281 self.out,
282 )
283 .await
284 }
285}
286
287fn parse_duration(s: &str) -> Result<u64> {
289 let s = s.trim();
290 if s.is_empty() {
291 return Err(ScopeError::Chain("Empty duration".to_string()));
292 }
293 let (num_str, unit) = s
294 .char_indices()
295 .find(|(_, c)| !c.is_ascii_digit() && *c != '.')
296 .map(|(i, _)| (&s[..i], s[i..].trim()))
297 .unwrap_or((s, "s"));
298
299 let num: f64 = num_str
300 .parse()
301 .map_err(|_| ScopeError::Chain(format!("Invalid duration number: {}", num_str)))?;
302
303 if num <= 0.0 {
304 return Err(ScopeError::Chain("Duration must be positive".to_string()));
305 }
306
307 let secs = match unit.to_lowercase().as_str() {
308 "s" | "sec" | "secs" | "second" | "seconds" => num,
309 "m" | "min" | "mins" | "minute" | "minutes" => num * 60.0,
310 "h" | "hr" | "hrs" | "hour" | "hours" => num * 3600.0,
311 "d" | "day" | "days" => num * 86400.0,
312 _ => {
313 return Err(ScopeError::Chain(format!(
314 "Unknown duration unit: {}",
315 unit
316 )));
317 }
318 };
319
320 Ok(secs as u64)
321}
322
323fn market_summary_to_markdown(summary: &MarketSummary, venue: &str, pair: &str) -> String {
325 let mut md = format!(
326 "# Market Health Report: {} \n\
327 **Generated:** {} \n\n",
328 pair,
329 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
330 );
331 md.push_str(&summary.format_markdown(Some(venue)));
332 md.push_str(&scope::display::report::report_footer());
333 md
334}
335
336async fn fetch_book_and_volume(
339 args: &SummaryArgs,
340 thresholds: &HealthThresholds,
341 factory: &dyn ChainClientFactory,
342 out: &Output,
343) -> Result<(OrderBook, Option<f64>)> {
344 let venue = MarketVenue::from_name(&args.venue)?;
345 if let Some(chain) = &args.chain {
346 venue.require_chain(chain)?;
347 }
348 let dex = CrawlDexSource { factory, out };
349 scope::market::fetch_book_and_volume(&venue, &Pair::parse(&args.pair), thresholds, &dex).await
350}
351
352async fn token_addresses(args: &SummaryArgs, out: &Output) -> Vec<TokenAddress> {
356 let Ok(MarketVenue::Cex(client)) = MarketVenue::from_name(&args.venue) else {
357 return Vec::new();
358 };
359 match client.fetch_token_addresses(&Pair::parse(&args.pair)).await {
360 None => Vec::new(),
361 Some(Ok(tokens)) => tokens,
362 Some(Err(e)) => {
363 note!(out, "Token addresses are not available: {e}");
364 Vec::new()
365 }
366 }
367}
368
369async fn summary_run(
371 args: &SummaryArgs,
372 factory: &dyn ChainClientFactory,
373 thresholds: &HealthThresholds,
374 tokens: &[TokenAddress],
375 run_num: Option<u64>,
376 out: &Output,
377) -> Result<(MarketSummary, String)> {
378 let (book, volume_24h) = fetch_book_and_volume(args, thresholds, factory, out).await?;
379 let summary = MarketSummary::from_order_book(&book, thresholds, volume_24h);
380 let report = match args.format {
381 SummaryFormat::Text => summary.format_text_with_tokens(Some(&args.venue), tokens),
382 SummaryFormat::Json => {
383 let mut json = summary.to_json();
384 if let Some(obj) = json.as_object_mut() {
385 obj.insert("run".to_string(), serde_json::json!(run_num));
386 obj.insert("venue".to_string(), serde_json::json!(args.venue));
387 if !tokens.is_empty() {
388 obj.insert("tokens".to_string(), serde_json::json!(tokens));
389 }
390 }
391 format!("{}\n", serde_json::to_string_pretty(&json)?)
392 }
393 };
394 Ok((summary, report))
395}
396
397fn write_report(args: &SummaryArgs, summary: &MarketSummary, out: &Output) -> Result<()> {
399 if let Some(ref report_path) = args.report {
400 let md = market_summary_to_markdown(summary, &args.venue, &args.pair);
401 std::fs::write(report_path, md)?;
402 out.note_saved("Report", report_path.display());
403 }
404 Ok(())
405}
406
407fn summary_schedule(args: &SummaryArgs) -> Result<Option<Schedule>> {
409 if args.every.is_none() && args.duration.is_none() {
410 return Ok(None);
411 }
412 let secs = |flag: &Option<String>, default| {
413 flag.as_deref()
414 .map(parse_duration)
415 .transpose()
416 .map(|v| v.unwrap_or(default))
417 };
418 let every = secs(&args.every, DEFAULT_EVERY_SECS)?;
419 let duration = secs(&args.duration, DEFAULT_DURATION_SECS)?;
420 Schedule::new(Duration::from_secs(every), Duration::from_secs(duration)).map(Some)
421}
422
423async fn run_summary(
424 args: SummaryArgs,
425 config: &Config,
426 factory: &dyn ChainClientFactory,
427 out: &Output,
428) -> Result<()> {
429 let thresholds = args.health.resolve(config);
430 let tokens = token_addresses(&args, out).await;
432
433 let Some(schedule) = summary_schedule(&args)? else {
434 let (summary, report) =
435 summary_run(&args, factory, &thresholds, &tokens, None, out).await?;
436 out!(out, "{report}")?;
437 return write_report(&args, &summary, out);
438 };
439
440 note!(
441 out,
442 "Running market summary every {}s for {}s (Ctrl+C to stop early)\n",
443 schedule.every.as_secs(),
444 schedule.duration.as_secs()
445 );
446 let metrics = series::summary_metrics(&thresholds);
447 let last = repeat::run(&schedule, &metrics, args.csv.as_deref(), out, |n| {
448 let (args, thresholds, metrics, tokens) = (&args, &thresholds, &metrics, &tokens);
449 async move {
450 let (summary, report) =
451 summary_run(args, factory, thresholds, tokens, Some(n), out).await?;
452 Ok(RunOutput {
453 sample: series::sample(&summary, metrics),
454 snapshot: report,
455 value: summary,
456 })
457 }
458 })
459 .await?;
460
461 if let Some(summary) = last {
462 write_report(&args, &summary, out)?;
463 }
464 if let Some(ref csv_path) = args.csv {
465 out.note_saved("Time-series CSV", csv_path.display());
466 }
467 Ok(())
468}
469
470async fn run_ohlc(args: OhlcArgs, out: &Output) -> Result<()> {
476 let client = VenueRegistry::load()?.create_exchange_client(&args.venue)?;
477 run_ohlc_with(&client, args, out).await
478}
479
480async fn run_ohlc_with(client: &ExchangeClient, args: OhlcArgs, out: &Output) -> Result<()> {
482 let pair = client.venue_symbol(&Pair::parse(&args.pair));
483
484 let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
485
486 match args.format {
487 OhlcFormat::Json => {
488 let json_str = serde_json::to_string_pretty(&candles)
489 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
490 outln!(out, "{json_str}")?;
491 }
492 OhlcFormat::Text => {
493 outln!(
494 out,
495 "{}",
496 t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
497 )?;
498 outln!(out, "{}", t::kv_row("Interval", &args.interval))?;
499 outln!(out, "{}", t::kv_row("Limit", &args.limit.to_string()))?;
500
501 let cols = [
502 t::Col {
503 label: "Open Time",
504 width: 19,
505 align: '>',
506 },
507 t::Col {
508 label: "Open",
509 width: 12,
510 align: '>',
511 },
512 t::Col {
513 label: "High",
514 width: 12,
515 align: '>',
516 },
517 t::Col {
518 label: "Low",
519 width: 12,
520 align: '>',
521 },
522 t::Col {
523 label: "Close",
524 width: 12,
525 align: '>',
526 },
527 t::Col {
528 label: "Volume",
529 width: 14,
530 align: '>',
531 },
532 ];
533 outln!(out, "{}", t::table_header(&cols))?;
534
535 for c in &candles {
536 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
537 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
538 .unwrap_or_else(|| format!("{}", c.open_time));
539 let open_str = format!("{:.6}", c.open);
540 let high_str = format!("{:.6}", c.high);
541 let low_str = format!("{:.6}", c.low);
542 let close_str = format!("{:.6}", c.close);
543 let volume_str = format!("{:.2}", c.volume);
544 let values = [
545 dt.as_str(),
546 open_str.as_str(),
547 high_str.as_str(),
548 low_str.as_str(),
549 close_str.as_str(),
550 volume_str.as_str(),
551 ];
552 outln!(out, "{}", t::table_row(&cols, &values))?;
553 }
554
555 outln!(
556 out,
557 "{}",
558 t::info_row(&format!("{} candles returned", candles.len()))
559 )?;
560 outln!(out, "{}", t::section_footer())?;
561 }
562 }
563 Ok(())
564}
565
566async fn run_trades(args: TradesArgs, out: &Output) -> Result<()> {
572 let client = VenueRegistry::load()?.create_exchange_client(&args.venue)?;
573 run_trades_with(&client, args, out).await
574}
575
576async fn run_trades_with(client: &ExchangeClient, args: TradesArgs, out: &Output) -> Result<()> {
578 let pair = client.venue_symbol(&Pair::parse(&args.pair));
579
580 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
581
582 match args.format {
583 OhlcFormat::Json => {
584 let json_str = serde_json::to_string_pretty(&trades)
585 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
586 outln!(out, "{json_str}")?;
587 }
588 OhlcFormat::Text => {
589 outln!(
590 out,
591 "{}",
592 t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
593 )?;
594
595 let cols = [
596 t::Col {
597 label: "Time",
598 width: 10,
599 align: '>',
600 },
601 t::Col {
602 label: "Side",
603 width: 5,
604 align: '>',
605 },
606 t::Col {
607 label: "Price",
608 width: 12,
609 align: '>',
610 },
611 t::Col {
612 label: "Qty",
613 width: 12,
614 align: '>',
615 },
616 ];
617 outln!(out, "{}", t::table_header(&cols))?;
618
619 for t in &trades {
620 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
621 .map(|d| d.format("%H:%M:%S").to_string())
622 .unwrap_or_else(|| "?".to_string());
623 let side_str = match t.side {
624 scope::market::TradeSide::Buy => "BUY",
625 scope::market::TradeSide::Sell => "SELL",
626 };
627 let price_str = format!("{:.6}", t.price);
628 let qty_str = format!("{:.2}", t.quantity);
629 let values = [
630 time.as_str(),
631 side_str,
632 price_str.as_str(),
633 qty_str.as_str(),
634 ];
635 outln!(out, "{}", t::table_row(&cols, &values))?;
636 }
637
638 outln!(
639 out,
640 "{}",
641 t::info_row(&format!("{} trades returned", trades.len()))
642 )?;
643 outln!(out, "{}", t::section_footer())?;
644 }
645 }
646 Ok(())
647}
648
649#[cfg(test)]
650mod tests {
651 use super::*;
652 use scope::chains::DefaultClientFactory;
653 use scope::market::DexVenue;
654
655 fn quiet() -> crate::cli::output::Output {
656 crate::cli::output::Output::capture().0
657 }
658
659 #[allow(dead_code)]
663 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
664 let venue_id = format!("test_mock_{}", std::process::id());
665 let yaml = format!(
666 r#"
667id: {venue_id}
668name: Test Mock Venue
669base_url: {server_url}
670timeout_secs: 5
671symbol:
672 template: "{{base}}_{{quote}}"
673 default_quote: USDT
674capabilities:
675 order_book:
676 path: /api/v1/depth
677 params:
678 symbol: "{{pair}}"
679 response:
680 asks_key: asks
681 bids_key: bids
682 level_format: positional
683 ticker:
684 path: /api/v1/ticker
685 params:
686 symbol: "{{pair}}"
687 response:
688 last_price: last
689 volume_24h: vol
690"#
691 );
692 let dir = tempfile::tempdir().unwrap();
693 let file_path = dir.path().join(format!("{}.yaml", venue_id));
694 std::fs::write(&file_path, yaml).unwrap();
695 (venue_id, dir)
698 }
699
700 #[tokio::test]
701 async fn test_run_summary_with_mock_orderbook() {
702 let args = SummaryArgs {
706 pair: "USDC".to_string(),
707 venue: "eth".to_string(),
708 chain: None,
709 health: HealthArgs {
710 peg_target: Some(1.0),
711 peg_range: Some(0.01),
712 min_levels: Some(1),
713 min_depth: Some(50.0),
714 min_bid_ask_ratio: Some(0.1),
715 max_bid_ask_ratio: Some(10.0),
716 ..Default::default()
717 },
718 format: SummaryFormat::Text,
719 every: None,
720 duration: None,
721 report: None,
722 csv: None,
723 };
724
725 let http: std::sync::Arc<dyn scope::http::HttpClient> =
726 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
727 let factory = DefaultClientFactory {
728 chains_config: Default::default(),
729 http,
730 };
731 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
733 }
735
736 #[tokio::test]
737 async fn test_run_summary_json_format() {
738 let args = SummaryArgs {
739 pair: "USDC".to_string(),
740 venue: "eth".to_string(),
741 chain: None,
742 health: HealthArgs {
743 peg_target: Some(1.0),
744 peg_range: Some(0.01),
745 min_levels: Some(1),
746 min_depth: Some(50.0),
747 min_bid_ask_ratio: Some(0.1),
748 max_bid_ask_ratio: Some(10.0),
749 ..Default::default()
750 },
751 format: SummaryFormat::Json,
752 every: None,
753 duration: None,
754 report: None,
755 csv: None,
756 };
757
758 let http: std::sync::Arc<dyn scope::http::HttpClient> =
759 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
760 let factory = DefaultClientFactory {
761 chains_config: Default::default(),
762 http,
763 };
764 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
765 }
766
767 #[test]
768 fn test_parse_duration_seconds() {
769 assert_eq!(parse_duration("30s").unwrap(), 30);
770 assert_eq!(parse_duration("1").unwrap(), 1);
771 assert_eq!(parse_duration("60sec").unwrap(), 60);
772 }
773
774 #[test]
775 fn test_parse_duration_minutes() {
776 assert_eq!(parse_duration("5m").unwrap(), 300);
777 assert_eq!(parse_duration("1min").unwrap(), 60);
778 assert_eq!(parse_duration("2.5m").unwrap(), 150);
779 }
780
781 #[test]
782 fn test_parse_duration_hours() {
783 assert_eq!(parse_duration("1h").unwrap(), 3600);
784 assert_eq!(parse_duration("24h").unwrap(), 86400);
785 }
786
787 #[test]
788 fn test_parse_duration_invalid() {
789 assert!(parse_duration("").is_err());
790 assert!(parse_duration("abc").is_err());
791 assert!(parse_duration("30x").is_err());
792 }
793
794 #[test]
795 fn test_parse_duration_unknown_unit_error_message() {
796 let result = parse_duration("30z");
797 assert!(result.is_err());
798 let err = result.unwrap_err();
799 assert!(err.to_string().contains("Unknown duration unit"));
800 assert!(err.to_string().contains("z"));
801 }
802
803 #[test]
804 fn test_parse_duration_invalid_number_error() {
805 let result = parse_duration("abc30s");
806 assert!(result.is_err());
807 }
808
809 #[test]
810 fn test_parse_duration_non_positive() {
811 assert!(parse_duration("0").is_err());
812 assert!(parse_duration("-5s").is_err());
813 }
814
815 #[test]
820 fn test_base_symbol_from_pair_underscore() {
821 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
822 assert_eq!(Pair::parse("USDC_USDT").base, "USDC");
823 }
824
825 #[test]
826 fn test_base_symbol_from_pair_lowercase_underscore() {
827 assert_eq!(Pair::parse("dai_usdt").base, "dai");
828 }
829
830 #[test]
831 fn test_base_symbol_from_pair_slash() {
832 assert_eq!(Pair::parse("USDC/USDT").base, "USDC");
833 }
834
835 #[test]
836 fn test_base_symbol_from_pair_concat() {
837 assert_eq!(Pair::parse("USDCUSDT").base, "USDC");
838 assert_eq!(Pair::parse("DAIUSDT").base, "DAI");
839 }
840
841 #[test]
842 fn test_base_symbol_from_pair_plain() {
843 assert_eq!(Pair::parse("USDC").base, "USDC");
844 assert_eq!(Pair::parse("ETH").base, "ETH");
845 }
846
847 #[test]
848 fn test_base_symbol_from_pair_whitespace() {
849 assert_eq!(Pair::parse(" DAI_USDT ").base, "DAI");
850 }
851
852 #[test]
853 fn test_normalize_pair_input_key_value() {
854 assert_eq!(Pair::parse("pair=USDT_PUSD").to_string(), "USDT/PUSD");
855 assert_eq!(
856 Pair::parse("pair_symbol=USDT_PUSD").to_string(),
857 "USDT/PUSD"
858 );
859 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
860 }
861
862 #[test]
863 fn test_parse_pair_components_delimited() {
864 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
865 assert_eq!(Pair::parse("ETH/USDC").to_string(), "ETH/USDC");
866 assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
867 }
868
869 #[test]
870 fn test_parse_pair_components_concat_common_quote() {
871 assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
872 assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
873 assert_eq!(Pair::parse("ARB").quote, None);
874 }
875
876 #[test]
881 fn test_market_summary_to_markdown_basic() {
882 use scope::market::{HealthCheck, MarketSummary};
883 let summary = MarketSummary {
884 pair: "USDCUSDT".to_string(),
885 source: scope::market::BookSource::Exchange,
886 peg_target: 1.0,
887 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
888 best_bid: Some(0.9999),
889 best_ask: Some(1.0001),
890 mid_price: Some(1.0000),
891 spread: Some(0.0002),
892 spread_pct: None,
893 top_depth: Default::default(),
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 scope::market::{HealthCheck, MarketSummary};
918 let summary = MarketSummary {
919 pair: "TESTUSDT".to_string(),
920 source: scope::market::BookSource::Exchange,
921 peg_target: 1.0,
922 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
923 best_bid: None,
924 best_ask: None,
925 mid_price: None,
926 spread: None,
927 spread_pct: None,
928 top_depth: Default::default(),
929 volume_24h: None,
930 bid_depth: 0.0,
931 ask_depth: 0.0,
932 bid_outliers: 0,
933 ask_outliers: 0,
934 healthy: false,
935 checks: vec![HealthCheck::Fail("No data".to_string())],
936 execution_10k_buy: None,
937 execution_10k_sell: None,
938 asks: vec![],
939 bids: vec![],
940 };
941 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
942 assert!(md.contains("Market Health Report"));
943 assert!(md.contains("-")); }
945
946 #[test]
951 fn test_parse_duration_days() {
952 assert_eq!(parse_duration("1d").unwrap(), 86400);
953 assert_eq!(parse_duration("7d").unwrap(), 604800);
954 assert_eq!(parse_duration("1day").unwrap(), 86400);
955 assert_eq!(parse_duration("2days").unwrap(), 172800);
956 }
957
958 #[test]
959 fn test_parse_duration_long_names() {
960 assert_eq!(parse_duration("30seconds").unwrap(), 30);
961 assert_eq!(parse_duration("5minutes").unwrap(), 300);
962 assert_eq!(parse_duration("2hours").unwrap(), 7200);
963 }
964
965 #[test]
966 fn test_parse_duration_fractional() {
967 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
968 assert_eq!(parse_duration("1.5m").unwrap(), 90);
969 }
970
971 #[test]
976 fn test_summary_format_default() {
977 let fmt = SummaryFormat::default();
978 assert!(matches!(fmt, SummaryFormat::Text));
979 }
980
981 #[test]
982 fn test_summary_format_debug() {
983 let text = format!("{:?}", SummaryFormat::Text);
984 assert_eq!(text, "Text");
985 let json = format!("{:?}", SummaryFormat::Json);
986 assert_eq!(json, "Json");
987 }
988
989 #[test]
994 fn test_ohlc_args_deserialization() {
995 use crate::cli::{Cli, Commands};
996 use clap::Parser;
997 let cli = Cli::try_parse_from([
998 "scope",
999 "market",
1000 "ohlc",
1001 "USDC",
1002 "--venue",
1003 "binance",
1004 "--interval",
1005 "1h",
1006 "--limit",
1007 "50",
1008 ])
1009 .unwrap();
1010 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1011 assert_eq!(args.pair, "USDC");
1012 assert_eq!(args.venue, "binance");
1013 assert_eq!(args.interval, "1h");
1014 assert_eq!(args.limit, 50);
1015 } else {
1016 panic!("Expected Market Ohlc command");
1017 }
1018 }
1019
1020 #[test]
1021 fn test_trades_args_deserialization() {
1022 use crate::cli::{Cli, Commands};
1023 use clap::Parser;
1024 let cli = Cli::try_parse_from([
1025 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1026 ])
1027 .unwrap();
1028 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1029 assert_eq!(args.pair, "BTC");
1030 assert_eq!(args.venue, "mexc");
1031 assert_eq!(args.limit, 100);
1032 } else {
1033 panic!("Expected Market Trades command");
1034 }
1035 }
1036
1037 #[test]
1038 fn test_base_symbol_from_pair_various_inputs() {
1039 assert_eq!(Pair::parse("USDC").base, "USDC");
1041 assert_eq!(Pair::parse("BTCUSDT").base, "BTC");
1042 assert_eq!(Pair::parse("ETH/USDT").base, "ETH");
1043 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1044 assert_eq!(Pair::parse("X").base, "X"); assert_eq!(Pair::parse("").base, "");
1046 }
1047
1048 #[test]
1049 fn test_summary_args_debug() {
1050 let args = SummaryArgs {
1051 pair: "USDC".to_string(),
1052 venue: "binance".to_string(),
1053 chain: None,
1054 health: HealthArgs {
1055 peg_target: Some(1.0),
1056 peg_range: Some(0.001),
1057 min_levels: Some(6),
1058 min_depth: Some(3000.0),
1059 min_bid_ask_ratio: Some(0.2),
1060 max_bid_ask_ratio: Some(5.0),
1061 ..Default::default()
1062 },
1063 format: SummaryFormat::Text,
1064 every: None,
1065 duration: None,
1066 report: None,
1067 csv: None,
1068 };
1069 let debug = format!("{:?}", args);
1070 assert!(debug.contains("SummaryArgs"));
1071 assert!(debug.contains("USDC"));
1072 }
1073
1074 #[test]
1075 fn test_default_constants() {
1076 assert_eq!(DEFAULT_EVERY_SECS, 60);
1077 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1078 }
1079
1080 #[test]
1081 fn test_markdown_leaves_out_the_peg_deviation_when_there_is_none() {
1082 use scope::market::{BookSource, OrderBook, OrderBookLevel};
1084 let level = |price| OrderBookLevel {
1085 price,
1086 quantity: 100.0,
1087 };
1088 let book = OrderBook {
1089 pair: "USDC/USDT".to_string(),
1090 source: BookSource::Exchange,
1091 bids: vec![level(0.9999)],
1092 asks: vec![level(1.0001)],
1093 };
1094 let thresholds = HealthThresholds {
1095 peg_target: 0.0,
1096 ..HealthThresholds::default()
1097 };
1098 let summary = MarketSummary::from_order_book(&book, &thresholds, None);
1099 let md = market_summary_to_markdown(&summary, "binance", "USDC");
1100 assert!(md.contains("0.9999") && !md.contains("0.9999 ("), "{md}");
1101 assert!(!md.contains("(+0.000%)"), "{md}");
1102 }
1103
1104 #[test]
1105 fn test_market_summary_to_markdown_with_execution_estimates() {
1106 use scope::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1107 let summary = MarketSummary {
1108 pair: "TESTUSDT".to_string(),
1109 source: scope::market::BookSource::Exchange,
1110 peg_target: 1.0,
1111 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1112 best_bid: Some(0.9999),
1113 best_ask: Some(1.0001),
1114 mid_price: Some(1.0000),
1115 spread: Some(0.0002),
1116 spread_pct: None,
1117 top_depth: Default::default(),
1118 volume_24h: Some(1_000_000.0),
1119 bid_depth: 50_000.0,
1120 ask_depth: 50_000.0,
1121 bid_outliers: 0,
1122 ask_outliers: 0,
1123 healthy: true,
1124 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1125 execution_10k_buy: Some(ExecutionEstimate {
1126 notional_usdt: 10_000.0,
1127 side: ExecutionSide::Buy,
1128 vwap: 1.0001,
1129 slippage_bps: 1.5,
1130 fillable: true,
1131 }),
1132 execution_10k_sell: Some(ExecutionEstimate {
1133 notional_usdt: 10_000.0,
1134 side: ExecutionSide::Sell,
1135 vwap: 0.0,
1136 slippage_bps: 0.0,
1137 fillable: false,
1138 }),
1139 asks: vec![],
1140 bids: vec![],
1141 };
1142 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1143 assert!(md.contains("Market Health Report"));
1144 assert!(md.contains("TESTUSDT"));
1145 assert!(md.contains("TestVenue"));
1146 assert!(md.contains("1.50 bps"));
1148 assert!(md.contains("insufficient"));
1150 }
1151
1152 #[tokio::test]
1153 async fn test_run_with_summary_command() {
1154 let args = MarketCommands::Summary(SummaryArgs {
1156 pair: "USDC".to_string(),
1157 venue: "eth".to_string(),
1158 chain: None,
1159 health: HealthArgs {
1160 peg_target: Some(1.0),
1161 peg_range: Some(0.01),
1162 min_levels: Some(1),
1163 min_depth: Some(50.0),
1164 min_bid_ask_ratio: Some(0.1),
1165 max_bid_ask_ratio: Some(10.0),
1166 ..Default::default()
1167 },
1168 format: SummaryFormat::Text,
1169 every: None,
1170 duration: None,
1171 report: None,
1172 csv: None,
1173 });
1174
1175 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1176 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1177 let factory = DefaultClientFactory {
1178 chains_config: Default::default(),
1179 http,
1180 };
1181 let config = Config::default();
1182 let _result = run(args, &config, &factory, &quiet()).await;
1183 }
1185
1186 #[test]
1187 fn test_is_dex_venue() {
1188 assert!(DexVenue::parse("eth").is_some());
1189 assert!(DexVenue::parse("ethereum").is_some());
1190 assert!(DexVenue::parse("Ethereum").is_some());
1191 assert!(DexVenue::parse("solana").is_some());
1192 assert!(DexVenue::parse("Solana").is_some());
1193 assert!(DexVenue::parse("binance").is_none());
1194 assert!(DexVenue::parse("okx").is_none());
1195 assert!(DexVenue::parse("mexc").is_none());
1196 }
1197
1198 #[test]
1199 fn test_dex_venue_to_chain() {
1200 assert_eq!(
1201 DexVenue::parse("eth").map(DexVenue::chain),
1202 Some("ethereum")
1203 );
1204 assert_eq!(
1205 DexVenue::parse("ethereum").map(DexVenue::chain),
1206 Some("ethereum")
1207 );
1208 assert_eq!(
1209 DexVenue::parse("Ethereum").map(DexVenue::chain),
1210 Some("ethereum")
1211 );
1212 assert_eq!(
1213 DexVenue::parse("solana").map(DexVenue::chain),
1214 Some("solana")
1215 );
1216 }
1217
1218 #[test]
1219 fn test_venue_registry_loaded_in_cex_path() {
1220 let registry = VenueRegistry::load().unwrap();
1222 assert!(registry.contains("binance"));
1223 let client = registry.create_exchange_client("binance");
1224 assert!(client.is_ok());
1225 }
1226
1227 #[test]
1228 fn test_venue_registry_error_for_unknown() {
1229 let registry = VenueRegistry::load().unwrap();
1230 let result = registry.create_exchange_client("kracken");
1231 assert!(result.is_err());
1232 let err = result.unwrap_err().to_string();
1233 assert!(err.contains("Unknown venue"));
1234 assert!(err.contains("Did you mean")); }
1236
1237 #[tokio::test]
1238 async fn test_run_summary_json_format_with_mock() {
1239 let args = SummaryArgs {
1240 pair: "USDC".to_string(),
1241 venue: "eth".to_string(),
1242 chain: None,
1243 health: HealthArgs {
1244 peg_target: Some(1.0),
1245 peg_range: Some(0.01),
1246 min_levels: Some(1),
1247 min_depth: Some(50.0),
1248 min_bid_ask_ratio: Some(0.1),
1249 max_bid_ask_ratio: Some(10.0),
1250 ..Default::default()
1251 },
1252 format: SummaryFormat::Json,
1253 every: None,
1254 duration: None,
1255 report: None,
1256 csv: None,
1257 };
1258
1259 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1260 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1261 let factory = DefaultClientFactory {
1262 chains_config: Default::default(),
1263 http,
1264 };
1265 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1266 }
1267
1268 #[test]
1273 fn test_ohlc_format_default() {
1274 let fmt: OhlcFormat = Default::default();
1275 assert_eq!(fmt, OhlcFormat::Text);
1276 }
1277
1278 #[test]
1279 fn test_ohlc_format_display() {
1280 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1282 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1283 }
1284
1285 #[test]
1286 fn test_ohlc_args_default_values() {
1287 let args = OhlcArgs {
1289 pair: "BTC".to_string(),
1290 venue: "binance".to_string(),
1291 interval: "1h".to_string(),
1292 limit: 100,
1293 format: OhlcFormat::Text,
1294 };
1295 assert_eq!(args.pair, "BTC");
1296 assert_eq!(args.venue, "binance");
1297 assert_eq!(args.interval, "1h");
1298 assert_eq!(args.limit, 100);
1299 }
1300
1301 #[test]
1302 fn test_trades_args_construction() {
1303 let args = TradesArgs {
1304 pair: "ETH".to_string(),
1305 venue: "okx".to_string(),
1306 limit: 50,
1307 format: OhlcFormat::Json,
1308 };
1309 assert_eq!(args.pair, "ETH");
1310 assert_eq!(args.venue, "okx");
1311 assert_eq!(args.limit, 50);
1312 }
1313
1314 #[tokio::test]
1315 async fn test_run_ohlc_unknown_venue() {
1316 let args = OhlcArgs {
1317 pair: "BTC".to_string(),
1318 venue: "nonexistent_venue".to_string(),
1319 interval: "1h".to_string(),
1320 limit: 10,
1321 format: OhlcFormat::Text,
1322 };
1323 let result = run_ohlc(args, &quiet()).await;
1324 assert!(result.is_err());
1325 let err = result.unwrap_err().to_string();
1326 assert!(
1327 err.contains("Unknown venue"),
1328 "expected 'Unknown venue' error, got: {}",
1329 err
1330 );
1331 }
1332
1333 #[tokio::test]
1334 async fn test_run_trades_unknown_venue() {
1335 let args = TradesArgs {
1336 pair: "BTC".to_string(),
1337 venue: "nonexistent_venue".to_string(),
1338 limit: 10,
1339 format: OhlcFormat::Text,
1340 };
1341 let result = run_trades(args, &quiet()).await;
1342 assert!(result.is_err());
1343 let err = result.unwrap_err().to_string();
1344 assert!(
1345 err.contains("Unknown venue"),
1346 "expected 'Unknown venue' error, got: {}",
1347 err
1348 );
1349 }
1350
1351 #[tokio::test]
1352 async fn test_run_dispatches_ohlc() {
1353 let cmd = MarketCommands::Ohlc(OhlcArgs {
1354 pair: "BTC".to_string(),
1355 venue: "nonexistent_test_venue".to_string(),
1356 interval: "1h".to_string(),
1357 limit: 5,
1358 format: OhlcFormat::Text,
1359 });
1360 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1361 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1362 let factory = DefaultClientFactory {
1363 chains_config: Default::default(),
1364 http,
1365 };
1366 let config = Config::default();
1367 let result = run(cmd, &config, &factory, &quiet()).await;
1368 assert!(result.is_err());
1370 }
1371
1372 #[tokio::test]
1373 async fn test_run_dispatches_trades() {
1374 let cmd = MarketCommands::Trades(TradesArgs {
1375 pair: "ETH".to_string(),
1376 venue: "nonexistent_test_venue".to_string(),
1377 limit: 5,
1378 format: OhlcFormat::Json,
1379 });
1380 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1381 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1382 let factory = DefaultClientFactory {
1383 chains_config: Default::default(),
1384 http,
1385 };
1386 let config = Config::default();
1387 let result = run(cmd, &config, &factory, &quiet()).await;
1388 assert!(result.is_err());
1389 }
1390
1391 fn binance_at(server: &mockito::ServerGuard) -> ExchangeClient {
1393 scope::market::testing::venue_at("binance", &server.url())
1394 }
1395
1396 const KLINES: &str = r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999],
1397 [1700003600000,"1.25","2.0","1.0","1.75","20.25",1700007199999]]"#;
1398 const TRADES: &str = r#"[{"id":1,"price":"1.5","qty":"2.5","quoteQty":"3.75","time":1700000000000,"isBuyerMaker":true},
1399 {"id":2,"price":"1.6","qty":"1.0","quoteQty":"1.6","time":1700000001000,"isBuyerMaker":false}]"#;
1400
1401 fn ohlc_args(format: OhlcFormat) -> OhlcArgs {
1402 OhlcArgs {
1403 pair: "BTC".to_string(),
1404 venue: "binance".to_string(),
1405 interval: "1h".to_string(),
1406 limit: 2,
1407 format,
1408 }
1409 }
1410
1411 fn trades_args(format: OhlcFormat) -> TradesArgs {
1412 TradesArgs {
1413 pair: "BTC".to_string(),
1414 venue: "binance".to_string(),
1415 limit: 2,
1416 format,
1417 }
1418 }
1419
1420 async fn serve(path: &str, body: &str) -> mockito::ServerGuard {
1421 let mut server = mockito::Server::new_async().await;
1422 server
1423 .mock("GET", path)
1424 .match_query(mockito::Matcher::UrlEncoded(
1425 "symbol".into(),
1426 "BTCUSDT".into(),
1427 ))
1428 .with_body(body)
1429 .create_async()
1430 .await;
1431 server
1432 }
1433
1434 #[tokio::test]
1435 async fn test_ohlc_text_shows_every_candle() {
1436 let server = serve("/api/v3/klines", KLINES).await;
1437 let (out, cap) = Output::capture();
1438 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Text), &out)
1439 .await
1440 .unwrap();
1441 let text = crate::cli::ansi::strip(&cap.out());
1442 assert!(text.contains("OHLC — BTCUSDT (binance)"), "{text}");
1443 assert!(text.contains("1.250000"), "{text}");
1444 assert!(text.contains("1000.50"), "{text}");
1445 assert!(text.contains("2 candles returned"), "{text}");
1446 }
1447
1448 #[tokio::test]
1449 async fn test_ohlc_json_keeps_every_field() {
1450 let server = serve("/api/v3/klines", KLINES).await;
1451 let (out, cap) = Output::capture();
1452 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Json), &out)
1453 .await
1454 .unwrap();
1455 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1456 assert_eq!(v.as_array().unwrap().len(), 2);
1457 assert_eq!(v[0]["open_time"], 1_700_000_000_000u64);
1458 assert_eq!(v[1]["close"], 1.75);
1459 assert_eq!(v[1]["volume"], 20.25);
1460 }
1461
1462 #[tokio::test]
1463 async fn test_trades_text_shows_side_price_and_quantity() {
1464 let server = serve("/api/v3/trades", TRADES).await;
1465 let (out, cap) = Output::capture();
1466 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Text), &out)
1467 .await
1468 .unwrap();
1469 let text = crate::cli::ansi::strip(&cap.out());
1470 assert!(text.contains("Recent Trades — BTCUSDT (binance)"), "{text}");
1471 assert!(text.contains("SELL") && text.contains("BUY"), "{text}");
1473 assert!(text.contains("1.500000") && text.contains("2.50"), "{text}");
1474 assert!(text.contains("2 trades returned"), "{text}");
1475 }
1476
1477 #[tokio::test]
1478 async fn test_trades_json_keeps_every_field() {
1479 let server = serve("/api/v3/trades", TRADES).await;
1480 let (out, cap) = Output::capture();
1481 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Json), &out)
1482 .await
1483 .unwrap();
1484 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1485 assert_eq!(v[0]["side"], "sell");
1486 assert_eq!(v[1]["side"], "buy");
1487 assert_eq!(v[0]["id"], "1");
1488 assert_eq!(v[0]["quote_quantity"], 3.75);
1489 assert_eq!(v[1]["timestamp_ms"], 1_700_000_001_000u64);
1490 }
1491
1492 #[test]
1497 fn test_parse_duration_whitespace_empty() {
1498 assert!(parse_duration("").is_err());
1499 assert!(parse_duration(" ").is_err());
1500 }
1501
1502 #[test]
1503 fn test_parse_duration_sec_secs_second_seconds() {
1504 assert_eq!(parse_duration("1sec").unwrap(), 1);
1505 assert_eq!(parse_duration("2secs").unwrap(), 2);
1506 assert_eq!(parse_duration("1second").unwrap(), 1);
1507 assert_eq!(parse_duration("3seconds").unwrap(), 3);
1508 }
1509
1510 #[test]
1511 fn test_parse_duration_minute_minutes() {
1512 assert_eq!(parse_duration("1minute").unwrap(), 60);
1513 assert_eq!(parse_duration("2minutes").unwrap(), 120);
1514 }
1515
1516 #[test]
1517 fn test_parse_duration_hr_hrs_hour_hours() {
1518 assert_eq!(parse_duration("1hr").unwrap(), 3600);
1519 assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1520 assert_eq!(parse_duration("1hour").unwrap(), 3600);
1521 assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1522 }
1523
1524 #[test]
1525 fn test_parse_duration_number_only_defaults_to_seconds() {
1526 assert_eq!(parse_duration("1.5").unwrap(), 1);
1527 assert_eq!(parse_duration("42").unwrap(), 42);
1528 }
1529
1530 #[test]
1531 fn test_parse_duration_trimmed_input() {
1532 assert_eq!(parse_duration(" 30s ").unwrap(), 30);
1533 assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1534 }
1535
1536 #[test]
1537 fn test_parse_duration_invalid_number_format() {
1538 assert!(parse_duration("1.2.3s").is_err());
1539 assert!(parse_duration("abc").is_err());
1540 }
1541
1542 #[test]
1547 fn test_unknown_venue_is_not_a_dex_venue() {
1548 assert_eq!(DexVenue::parse("binance"), None);
1549 assert_eq!(DexVenue::parse("kraken"), None);
1550 assert_eq!(DexVenue::parse("unknown"), None);
1551 }
1552
1553 #[test]
1558 fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1559 use scope::market::{HealthCheck, MarketSummary};
1560 let summary = MarketSummary {
1561 pair: "TESTUSDT".to_string(),
1562 source: scope::market::BookSource::Exchange,
1563 peg_target: 1.0,
1564 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1565 best_bid: Some(0.9999),
1566 best_ask: Some(1.0001),
1567 mid_price: Some(1.0000),
1568 spread: Some(0.0002),
1569 spread_pct: None,
1570 top_depth: Default::default(),
1571 volume_24h: Some(500_000.0),
1572 bid_depth: 40_000.0,
1573 ask_depth: 45_000.0,
1574 bid_outliers: 0,
1575 ask_outliers: 0,
1576 healthy: false,
1577 checks: vec![
1578 HealthCheck::Pass("Spread within range".to_string()),
1579 HealthCheck::Fail("Bid depth below minimum".to_string()),
1580 ],
1581 execution_10k_buy: None,
1582 execution_10k_sell: None,
1583 asks: vec![],
1584 bids: vec![],
1585 };
1586 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1587 assert!(md.contains("✓ Spread within range"));
1588 assert!(md.contains("✗ Bid depth below minimum"));
1589 assert!(md.contains("✗")); }
1591
1592 #[test]
1593 fn test_market_summary_to_markdown_empty_checks() {
1594 use scope::market::MarketSummary;
1595 let summary = MarketSummary {
1596 pair: "X".to_string(),
1597 source: scope::market::BookSource::Exchange,
1598 peg_target: 1.0,
1599 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1600 best_bid: Some(1.0),
1601 best_ask: Some(1.0),
1602 mid_price: Some(1.0),
1603 spread: Some(0.0),
1604 spread_pct: None,
1605 top_depth: Default::default(),
1606 volume_24h: None,
1607 bid_depth: 100.0,
1608 ask_depth: 100.0,
1609 bid_outliers: 0,
1610 ask_outliers: 0,
1611 healthy: true,
1612 checks: vec![],
1613 execution_10k_buy: None,
1614 execution_10k_sell: None,
1615 asks: vec![],
1616 bids: vec![],
1617 };
1618 let md = market_summary_to_markdown(&summary, "Venue", "X");
1619 assert!(md.contains("Market Health Report"));
1620 assert!(!md.contains("Health Checks:"));
1622 }
1623
1624 #[test]
1629 fn test_ohlc_format_partial_eq() {
1630 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1631 assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1632 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1633 }
1634
1635 #[test]
1636 fn test_summary_format_clone_copy() {
1637 let text = SummaryFormat::Text;
1638 let cloned = text;
1639 assert!(matches!(cloned, SummaryFormat::Text));
1640 assert!(matches!(text, SummaryFormat::Text));
1641 }
1642
1643 #[tokio::test]
1648 async fn test_run_summary_interval_zero_error() {
1649 let args = SummaryArgs {
1650 pair: "USDC".to_string(),
1651 venue: "eth".to_string(),
1652 chain: None,
1653 health: HealthArgs {
1654 peg_target: Some(1.0),
1655 peg_range: Some(0.01),
1656 min_levels: Some(1),
1657 min_depth: Some(50.0),
1658 min_bid_ask_ratio: Some(0.1),
1659 max_bid_ask_ratio: Some(10.0),
1660 ..Default::default()
1661 },
1662 format: SummaryFormat::Text,
1663 every: Some("0.1s".to_string()),
1664 duration: Some("1m".to_string()),
1665 report: None,
1666 csv: None,
1667 };
1668 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1669 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1670 let factory = DefaultClientFactory {
1671 chains_config: Default::default(),
1672 http,
1673 };
1674 let result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1675 assert!(result.is_err());
1676 let err = result.unwrap_err().to_string();
1677 assert!(
1678 err.contains("Interval must be positive") || err.contains("positive"),
1679 "expected interval error, got: {}",
1680 err
1681 );
1682 }
1683
1684 #[test]
1689 fn test_market_commands_debug() {
1690 let cmd = MarketCommands::Summary(SummaryArgs {
1691 pair: "USDC".to_string(),
1692 venue: "binance".to_string(),
1693 chain: None,
1694 health: HealthArgs {
1695 peg_target: Some(1.0),
1696 peg_range: Some(0.001),
1697 min_levels: Some(6),
1698 min_depth: Some(3000.0),
1699 min_bid_ask_ratio: Some(0.2),
1700 max_bid_ask_ratio: Some(5.0),
1701 ..Default::default()
1702 },
1703 format: SummaryFormat::Text,
1704 every: None,
1705 duration: None,
1706 report: None,
1707 csv: None,
1708 });
1709 let debug = format!("{:?}", cmd);
1710 assert!(debug.contains("Summary"));
1711
1712 let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
1713 pair: "BTC".to_string(),
1714 venue: "binance".to_string(),
1715 interval: "1h".to_string(),
1716 limit: 100,
1717 format: OhlcFormat::Text,
1718 });
1719 assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
1720
1721 let trades_cmd = MarketCommands::Trades(TradesArgs {
1722 pair: "ETH".to_string(),
1723 venue: "binance".to_string(),
1724 limit: 50,
1725 format: OhlcFormat::Json,
1726 });
1727 assert!(format!("{:?}", trades_cmd).contains("Trades"));
1728 }
1729
1730 #[test]
1731 fn test_summary_args_with_report_csv_options() {
1732 let args = SummaryArgs {
1733 pair: "DAI".to_string(),
1734 venue: "binance".to_string(),
1735 chain: None,
1736 health: HealthArgs {
1737 peg_target: Some(1.0),
1738 peg_range: Some(0.001),
1739 min_levels: Some(6),
1740 min_depth: Some(3000.0),
1741 min_bid_ask_ratio: Some(0.2),
1742 max_bid_ask_ratio: Some(5.0),
1743 ..Default::default()
1744 },
1745 format: SummaryFormat::Json,
1746 every: Some("30s".to_string()),
1747 duration: Some("1h".to_string()),
1748 report: Some(std::path::PathBuf::from("/tmp/report.md")),
1749 csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
1750 };
1751 assert_eq!(args.pair, "DAI");
1752 assert_eq!(args.venue, "binance");
1753 assert!(args.every.is_some());
1754 assert!(args.duration.is_some());
1755 assert!(args.report.is_some());
1756 assert!(args.csv.is_some());
1757 }
1758
1759 #[test]
1760 fn test_base_symbol_from_pair_4char_usdt() {
1761 assert_eq!(Pair::parse("USDT").base, "USDT");
1763 }
1764
1765 #[test]
1766 fn test_market_summary_to_markdown_unhealthy() {
1767 use scope::market::{HealthCheck, MarketSummary};
1768 let summary = MarketSummary {
1769 pair: "X".to_string(),
1770 source: scope::market::BookSource::Exchange,
1771 peg_target: 1.0,
1772 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1773 best_bid: Some(0.99),
1774 best_ask: Some(1.01),
1775 mid_price: Some(1.0),
1776 spread: Some(0.02),
1777 spread_pct: None,
1778 top_depth: Default::default(),
1779 volume_24h: None,
1780 bid_depth: 100.0,
1781 ask_depth: 100.0,
1782 bid_outliers: 0,
1783 ask_outliers: 0,
1784 healthy: false,
1785 checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
1786 execution_10k_buy: None,
1787 execution_10k_sell: None,
1788 asks: vec![],
1789 bids: vec![],
1790 };
1791 let md = market_summary_to_markdown(&summary, "Test", "X");
1792 assert!(md.contains("✗"));
1793 assert!(md.contains("Peg deviation too high"));
1794 }
1795
1796 #[test]
1797 fn test_ohlc_format_eq() {
1798 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1799 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1800 }
1801
1802 #[test]
1803 fn test_trades_args_default_venue() {
1804 let args = TradesArgs {
1805 pair: "USDC".to_string(),
1806 venue: "binance".to_string(),
1807 limit: 50,
1808 format: OhlcFormat::Text,
1809 };
1810 assert_eq!(args.pair, "USDC");
1811 assert_eq!(args.venue, "binance");
1812 }
1813
1814 const USDC_ADDR: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
1819
1820 fn dex_factory(pairs: &[(f64, f64)]) -> scope::chains::mocks::MockClientFactory {
1822 let mut factory = scope::chains::mocks::MockClientFactory::new();
1823 let data = factory.mock_dex.token_data.as_mut().unwrap();
1824 data.symbol = "USDC".to_string();
1825 data.pairs = pairs
1826 .iter()
1827 .enumerate()
1828 .map(|(i, &(liquidity_usd, volume_24h))| scope::chains::DexPair {
1829 dex_name: format!("DEX {i}"),
1830 pair_address: format!("0xpair{i}"),
1831 base_token: "USDC".to_string(),
1832 quote_token: "USDT".to_string(),
1833 price_usd: 1.0,
1834 volume_24h,
1835 liquidity_usd,
1836 liquidity_base: None,
1837 price_change_24h: 0.0,
1838 buys_24h: 0,
1839 sells_24h: 0,
1840 buys_6h: 0,
1841 sells_6h: 0,
1842 buys_1h: 0,
1843 sells_1h: 0,
1844 pair_created_at: None,
1845 url: None,
1846 })
1847 .collect();
1848 factory
1849 }
1850
1851 fn dex_args(format: SummaryFormat) -> SummaryArgs {
1852 SummaryArgs {
1853 pair: USDC_ADDR.to_string(),
1854 venue: "eth".to_string(),
1855 chain: None,
1856 health: HealthArgs::default(),
1857 format,
1858 every: None,
1859 duration: None,
1860 report: None,
1861 csv: None,
1862 }
1863 }
1864
1865 #[tokio::test]
1866 async fn test_dex_book_uses_most_liquid_pair() {
1867 let factory = dex_factory(&[(1_000.0, 10.0), (50_000.0, 777.0), (2_000.0, 20.0)]);
1869 let (book, volume) = fetch_book_and_volume(
1870 &dex_args(SummaryFormat::Text),
1871 &HealthThresholds::default(),
1872 &factory,
1873 &quiet(),
1874 )
1875 .await
1876 .unwrap();
1877 assert_eq!(volume, Some(777.0));
1878 let depth = book.total_depth(scope::market::BookSide::Bid)
1879 + book.total_depth(scope::market::BookSide::Ask);
1880 assert!((depth - 50_000.0).abs() < 1.0, "depth {depth}");
1881 }
1882
1883 #[tokio::test]
1886 async fn test_venue_sol_reads_the_solana_dex() {
1887 let factory = dex_factory(&[(50_000.0, 777.0)]);
1888 let mut args = dex_args(SummaryFormat::Text);
1889 args.venue = "sol".to_string();
1890 let result =
1891 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet()).await;
1892 let (book, volume) = result.unwrap();
1893 assert_eq!(volume, Some(777.0));
1894 assert_eq!(book.pair, "USDC/USD");
1895 }
1896
1897 #[tokio::test]
1900 async fn test_chain_must_match_the_dex_venue() {
1901 let factory = dex_factory(&[(50_000.0, 1.0)]);
1902 let mut args = dex_args(SummaryFormat::Text);
1903 args.chain = Some("Solana".to_string());
1904 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
1905 .await
1906 .unwrap_err();
1907 assert!(err.to_string().contains("reads chain ethereum"), "{err}");
1908
1909 args.chain = Some("ETH".to_string());
1910 assert!(
1911 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
1912 .await
1913 .is_ok()
1914 );
1915 }
1916
1917 #[tokio::test]
1918 async fn test_unknown_venue_is_an_error() {
1919 let factory = dex_factory(&[(50_000.0, 1.0)]);
1920 let mut args = dex_args(SummaryFormat::Text);
1921 args.venue = "polygon".to_string();
1922 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
1923 .await
1924 .unwrap_err();
1925 assert!(
1926 err.to_string().contains("Unknown venue \"polygon\""),
1927 "{err}"
1928 );
1929 }
1930
1931 #[tokio::test]
1932 async fn test_dex_no_pairs_is_error() {
1933 let factory = dex_factory(&[]);
1934 let err = fetch_book_and_volume(
1935 &dex_args(SummaryFormat::Text),
1936 &HealthThresholds::default(),
1937 &factory,
1938 &quiet(),
1939 )
1940 .await
1941 .unwrap_err();
1942 assert!(err.to_string().contains("No DEX pairs found"), "{err}");
1943 }
1944
1945 #[tokio::test]
1946 async fn test_run_summary_once_json_reflects_resolved_thresholds() {
1947 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
1948 let mut args = dex_args(SummaryFormat::Json);
1949 args.health = HealthArgs {
1951 min_levels: Some(1),
1952 min_top10_depth: Some(1.0),
1953 ..Default::default()
1954 };
1955 let thresholds = args.health.resolve(&Config::default());
1956 let (summary, _) = summary_run(&args, &factory, &thresholds, &[], Some(1), &quiet())
1957 .await
1958 .unwrap();
1959 assert!(summary.healthy, "{:?}", summary.checks);
1960
1961 let strict = HealthThresholds::default();
1962 let (summary, _) = summary_run(&args, &factory, &strict, &[], None, &quiet())
1963 .await
1964 .unwrap();
1965 assert!(!summary.healthy);
1966 }
1967
1968 #[tokio::test]
1969 async fn test_dex_json_shows_amm_source_and_na_checks() {
1970 let mut factory = dex_factory(&[(20_000_000.0, 1_000.0)]);
1972 factory.mock_dex.token_data.as_mut().unwrap().pairs[0].liquidity_base = Some(10_000_000.0);
1973 let args = dex_args(SummaryFormat::Json);
1974 let thresholds = args.health.resolve(&Config::default());
1975 let (summary, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
1976 .await
1977 .unwrap();
1978 assert!(summary.healthy, "{:?}", summary.checks);
1979 let v: serde_json::Value = serde_json::from_str(&report).unwrap();
1980 assert_eq!(v["book_source"], "synthetic_amm");
1981 let statuses: Vec<&str> = v["checks"]
1982 .as_array()
1983 .unwrap()
1984 .iter()
1985 .map(|c| c["status"].as_str().unwrap())
1986 .collect();
1987 assert_eq!(
1988 statuses.iter().filter(|s| **s == "n/a").count(),
1989 3,
1990 "{:?}",
1991 statuses
1992 );
1993 assert!(!statuses.contains(&"fail"));
1994 }
1995
1996 #[tokio::test]
1997 async fn test_dex_without_reserves_says_estimate() {
1998 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
1999 let args = dex_args(SummaryFormat::Text);
2000 let thresholds = args.health.resolve(&Config::default());
2001 let (_, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
2002 .await
2003 .unwrap();
2004 assert!(report.contains("pool reserves not reported"), "{report}");
2005 }
2006
2007 #[tokio::test]
2008 async fn test_run_summary_one_shot_writes_report() {
2009 let dir = tempfile::tempdir().unwrap();
2010 let report = dir.path().join("report.md");
2011 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2012 let mut args = dex_args(SummaryFormat::Text);
2013 args.report = Some(report.clone());
2014 run_summary(args, &Config::default(), &factory, &quiet())
2015 .await
2016 .unwrap();
2017 let md = std::fs::read_to_string(&report).unwrap();
2018 assert!(md.contains("Market Health Report"), "{md}");
2019 assert!(md.contains(USDC_ADDR), "{md}");
2020 }
2021
2022 #[tokio::test]
2023 async fn test_run_summary_repeat_mode_writes_csv_rows_and_report() {
2024 let dir = tempfile::tempdir().unwrap();
2025 let csv = dir.path().join("series.csv");
2026 let report = dir.path().join("final.md");
2027 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2028 let mut args = dex_args(SummaryFormat::Json);
2029 args.every = Some("1s".to_string());
2030 args.duration = Some("1s".to_string());
2031 args.csv = Some(csv.clone());
2032 args.report = Some(report.clone());
2033 run_summary(args, &Config::default(), &factory, &quiet())
2034 .await
2035 .unwrap();
2036
2037 let body = std::fs::read_to_string(&csv).unwrap();
2038 let mut lines = body.lines();
2039 let header = lines.next().unwrap();
2041 assert!(
2042 header.starts_with(
2043 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy,"
2044 ),
2045 "{header}"
2046 );
2047 assert!(
2048 header.contains("spread_pct") && header.ends_with("ask_top10_depth"),
2049 "{header}"
2050 );
2051 let rows: Vec<_> = lines.collect();
2053 assert_eq!(rows.len(), 2, "{body}");
2054 assert!(rows[0].split(',').nth(1) == Some("1"));
2055 assert!(rows[1].split(',').nth(1) == Some("2"));
2056 assert!(report.exists());
2057 }
2058
2059 #[tokio::test]
2060 async fn test_run_summary_repeat_publishes_run_progress() {
2061 let (out, captured) = Output::capture_merged();
2064 let hook = out.progress_hook().expect("a merged capture has a hook");
2065 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2066 let mut args = dex_args(SummaryFormat::Json);
2067 args.every = Some("1s".to_string());
2068 args.duration = Some("2s".to_string());
2069 run_summary(args, &Config::default(), &factory, &out)
2070 .await
2071 .unwrap();
2072 let p = hook.lock().unwrap().clone();
2073 assert_eq!(p.total, Some(3), "{p:?}");
2075 assert_eq!(p.run, 3, "{p:?}");
2076 assert_eq!(p.next_at, None, "no next run after the last: {p:?}");
2077 assert!(captured.out().contains("every 1s for 2s"));
2078 assert!(captured.out().contains("Completed 3 run(s)"));
2079 }
2080
2081 #[tokio::test]
2082 async fn test_a_failed_run_ends_a_repeating_summary() {
2083 let (out, _captured) = Output::capture_merged();
2086 let hook = out.progress_hook().unwrap();
2087 let factory = dex_factory(&[]);
2088 let mut args = dex_args(SummaryFormat::Json);
2089 args.every = Some("1s".to_string());
2090 args.duration = Some("30s".to_string());
2091 let started = std::time::Instant::now();
2092 let result = run_summary(args, &Config::default(), &factory, &out).await;
2093 assert!(result.is_err(), "no DEX pairs is a failed run");
2094 assert_eq!(hook.lock().unwrap().run, 1, "no second run after the error");
2095 assert!(
2096 started.elapsed() < Duration::from_secs(5),
2097 "it did not sleep on"
2098 );
2099 }
2100}