1use crate::cli::crawl::{self, Period};
9use crate::cli::output::{Output, SummaryView};
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 crate::cli::resolve::{
18 BindingStore, ResolveContext, VenueCandidates, VenueKind, picker_for, resolve,
19};
20use scope::display::terminal as t;
21use scope::error::{Result, ScopeError};
22use scope::http::HttpClient;
23use scope::market::assets::TokenAddress;
24use scope::market::{
25 DexAnalyticsSource, ExchangeClient, HealthThresholds, MarketSummary, MarketVenue, OrderBook,
26 Pair, VENUE_HELP, VenueRegistry,
27};
28use scope::metrics as series;
29use std::sync::Arc;
30use std::time::Duration;
31
32const PAIR_HELP: &str = "Pair or base symbol. BASE_QUOTE, BASE/QUOTE and BASE-QUOTE work for any quote \
34 (BTC_ETH). BASEQUOTE works only for a stable or fiat quote (OPUSDT, BTCEUR); WETH stays \
35 WETH. A bare base (USDC) uses the venue's default quote.";
36
37pub const DEFAULT_EVERY_SECS: u64 = scope::watch::time::DEFAULT_INTERVAL_SECS;
40
41pub const DEFAULT_DURATION_SECS: u64 = scope::watch::time::DEFAULT_DURATION_SECS;
43
44#[derive(Debug, Subcommand)]
46#[allow(clippy::large_enum_variant)]
47pub enum MarketCommands {
48 Summary(SummaryArgs),
55
56 Ohlc(OhlcArgs),
58
59 Trades(TradesArgs),
61}
62
63#[derive(Debug, Args)]
68#[command(
69 after_help = "\x1b[1mExamples:\x1b[0m
70 scope market summary DAI --venue binance
71 scope market summary @dai-token --venue binance \x1b[2m# address book shortcut\x1b[0m
72 scope market summary USDC --venue binance --format json
73 scope market summary DAI --venue binance --every 30s --duration 1h
74 scope market summary DAI --venue binance --report health.md --csv peg.csv",
75 after_long_help = "\x1b[1mExamples:\x1b[0m
76
77 \x1b[1m$ scope market summary DAI --venue binance\x1b[0m
78
79 +-- DAI/USDT (binance) ----------------------------+
80 | |
81 |-- Metrics |
82 | Best Bid 0.9999 (-0.010%) |
83 | Best Ask 1.0001 (+0.010%) |
84 | Mid Price 1.0000 (+0.000%) |
85 | Spread 0.0002 (0.020%) |
86 | Volume (24h) 125.00K USDT |
87 | |
88 |-- Health Checks |
89 | + No sells below peg |
90 | + Bid/Ask ratio: 0.93x |
91 | + Bid levels: 12 >= 10 minimum |
92 | + Bid depth: 42000 USDT >= 3000 USDT minimum |
93 | |
94 | HEALTHY |
95 +-----------------------------------------------------+
96
97 \x1b[1m$ scope market summary DAI --venue binance --every 30s --duration 1h\x1b[0m
98
99 Monitoring DAI/USDT (binance) every 30s for 1h...
100 [2026-02-16 10:00:00] Mid=1.0000 Spread=0.020% Depth=42K/45K HEALTHY
101 [2026-02-16 10:00:30] Mid=1.0000 Spread=0.020% Depth=42K/44K HEALTHY
102 [2026-02-16 10:01:00] Mid=0.9999 Spread=0.030% Depth=41K/44K HEALTHY
103 ..."
104)]
105pub struct SummaryArgs {
106 #[arg(default_value = scope::market::DEFAULT_PAIR, value_name = "SYMBOL", help = PAIR_HELP)]
107 pub pair: String,
108
109 #[arg(long, default_value = scope::market::DEFAULT_VENUE, value_name = "VENUE", help = VENUE_HELP)]
110 pub venue: String,
111
112 #[arg(long = "token", value_name = "CONTRACT")]
115 pub token_contract: Option<String>,
116
117 #[arg(long, value_name = "CHAIN")]
120 pub chain: Option<String>,
121
122 #[command(flatten)]
124 pub health: HealthArgs,
125
126 #[arg(short, long, default_value = "text")]
128 pub format: SummaryFormat,
129
130 #[arg(long, value_name = "INTERVAL")]
133 pub every: Option<String>,
134
135 #[arg(long, value_name = "DURATION")]
139 pub duration: Option<String>,
140
141 #[arg(long, value_name = "PATH")]
143 pub report: Option<std::path::PathBuf>,
144
145 #[arg(long, value_name = "PATH")]
147 pub csv: Option<std::path::PathBuf>,
148}
149
150#[derive(Debug, Clone, Copy, Default, clap::ValueEnum)]
151pub enum SummaryFormat {
152 #[default]
154 Text,
155 Json,
157}
158
159#[derive(Debug, Args)]
161#[command(
162 after_help = "\x1b[1mExamples:\x1b[0m
163 scope market ohlc BTC
164 scope market ohlc DAI --venue binance --interval 1d
165 scope market ohlc ETH --venue mexc --limit 50 --format json",
166 after_long_help = "\x1b[1mExamples:\x1b[0m
167
168 \x1b[1m$ scope market ohlc BTC --limit 5\x1b[0m
169
170 OHLC -- BTCUSDT (binance) interval=1h limit=5
171 --------------------------------------------------------
172 Open Time Open High Low Close Volume
173 --------------------------------------------------------
174 2026-02-16 09:00 97250.120000 97380.540000 97210.980000 97345.670000 1234.56
175 2026-02-16 08:00 97100.890000 97260.120000 97080.340000 97250.120000 1456.78
176 2026-02-16 07:00 96950.230000 97120.890000 96920.560000 97100.890000 1678.90
177 ...
178
179 5 candles returned
180
181 \x1b[1m$ scope market ohlc BTC --format json --limit 2\x1b[0m
182
183 [
184 {
185 \"open_time\": 1739696400000,
186 \"open\": 97250.12,
187 \"high\": 97380.54,
188 \"low\": 97210.98,
189 \"close\": 97345.67,
190 \"volume\": 1234.56,
191 \"close_time\": null
192 },
193 ...
194 ]"
195)]
196pub struct OhlcArgs {
197 #[arg(default_value = scope::market::DEFAULT_PAIR, value_name = "SYMBOL", help = PAIR_HELP)]
198 pub pair: String,
199
200 #[arg(long, default_value = scope::market::DEFAULT_VENUE, value_name = "VENUE")]
202 pub venue: String,
203
204 #[arg(long = "token", value_name = "CONTRACT")]
207 pub token_contract: Option<String>,
208
209 #[arg(long, default_value = "1h", value_name = "INTERVAL")]
211 pub interval: String,
212
213 #[arg(long, default_value = "100", value_name = "LIMIT")]
215 pub limit: u32,
216
217 #[arg(long, default_value = "text")]
219 pub format: OhlcFormat,
220}
221
222#[derive(Debug, Args)]
224#[command(after_help = "\x1b[1mExamples:\x1b[0m
225 scope market trades BTC
226 scope market trades DAI --venue binance --limit 20
227 scope market trades ETH --venue okx --format json")]
228pub struct TradesArgs {
229 #[arg(default_value = scope::market::DEFAULT_PAIR, value_name = "SYMBOL", help = PAIR_HELP)]
230 pub pair: String,
231
232 #[arg(long, default_value = scope::market::DEFAULT_VENUE, value_name = "VENUE")]
234 pub venue: String,
235
236 #[arg(long = "token", value_name = "CONTRACT")]
239 pub token_contract: Option<String>,
240
241 #[arg(long, default_value = "50", value_name = "LIMIT")]
243 pub limit: u32,
244
245 #[arg(long, default_value = "text")]
247 pub format: OhlcFormat,
248}
249
250#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, clap::ValueEnum)]
252pub enum OhlcFormat {
253 #[default]
255 Text,
256 Json,
258}
259
260pub async fn run(
262 args: MarketCommands,
263 config: &Config,
264 factory: &dyn ChainClientFactory,
265 out: &Output,
266) -> Result<()> {
267 match args {
268 MarketCommands::Summary(summary_args) => {
269 run_summary(summary_args, config, factory, out).await
270 }
271 MarketCommands::Ohlc(ohlc_args) => run_ohlc(ohlc_args, factory.http(), out).await,
272 MarketCommands::Trades(trades_args) => run_trades(trades_args, factory.http(), out).await,
273 }
274}
275
276pub(crate) fn venue_pair(exchange: &scope::market::ExchangeClient, input: &str) -> String {
281 exchange.venue_symbol(&Pair::parse(input))
282}
283
284pub struct CrawlDexSource<'a> {
287 pub factory: &'a dyn ChainClientFactory,
289 pub out: &'a Output,
291 pub token: Option<String>,
294}
295
296#[async_trait::async_trait]
297impl DexAnalyticsSource for CrawlDexSource<'_> {
298 async fn token_analytics(&self, token: &str, chain: &str) -> Result<TokenAnalytics> {
299 let input = match &self.token {
302 Some(contract) => contract.clone(),
303 None => {
304 let dex = self.factory.create_dex_client();
305 resolve_dex_token(token, chain, None, dex.as_ref(), false, self.out).await?
306 }
307 };
308 crawl::fetch_analytics_for_input(
309 &input,
310 chain,
311 Period::Hour24,
312 10,
313 self.factory,
314 None,
315 self.out,
316 )
317 .await
318 }
319}
320
321fn parse_duration(s: &str) -> Result<u64> {
323 scope::watch::time::parse_duration(s).map_err(ScopeError::Chain)
324}
325
326pub(crate) fn market_summary_to_markdown(
328 summary: &MarketSummary,
329 venue: &str,
330 pair: &str,
331) -> String {
332 let mut md = format!(
333 "# Market Health Report: {} \n\
334 **Generated:** {} \n\n",
335 pair,
336 chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC"),
337 );
338 md.push_str(&summary.format_markdown(Some(venue)));
339 md.push_str(&scope::display::report::report_footer());
340 md
341}
342
343async fn fetch_book_and_volume(
348 args: &SummaryArgs,
349 thresholds: &HealthThresholds,
350 factory: &dyn ChainClientFactory,
351 out: &Output,
352) -> Result<(
353 OrderBook,
354 Option<f64>,
355 std::result::Result<Vec<scope::market::Trade>, String>,
356)> {
357 let venue = MarketVenue::from_name_with_http(&args.venue, factory.http())?;
358 if let Some(chain) = &args.chain {
359 venue.require_chain(chain)?;
360 }
361 let dex = CrawlDexSource {
362 factory,
363 out,
364 token: args.token_contract.clone(),
365 };
366 let pair = Pair::parse(&args.pair);
367 let (book, volume) =
368 scope::market::fetch_book_and_volume(&venue, &pair, thresholds, &dex).await?;
369 let trades = scope::market::fetch_pattern_trades(&venue, &pair).await;
370 Ok((book, volume, trades))
371}
372
373pub(crate) async fn check_cex_token(
378 client: &ExchangeClient,
379 base: &str,
380 chain: Option<String>,
381 token_flag: Option<String>,
382 store: &mut dyn BindingStore,
383 json: bool,
384 out: &Output,
385) -> Result<()> {
386 let ctx = ResolveContext {
387 chain,
388 venue: VenueKind::Cex(client.venue_id().to_string()),
389 token_flag,
390 yes: false,
391 save: false,
392 };
393 let source = VenueCandidates { client };
394 let picker = picker_for(out, json);
395 resolve(base, &ctx, &source, picker.as_ref(), store, out)
396 .await
397 .map(|_| ())
398}
399
400async fn resolve_market_token(
404 args: &SummaryArgs,
405 factory: &dyn ChainClientFactory,
406 out: &Output,
407) -> Result<Option<String>> {
408 let json = matches!(args.format, SummaryFormat::Json);
409 let base = Pair::parse(&args.pair).base;
410 match MarketVenue::from_name_with_http(&args.venue, factory.http())? {
411 MarketVenue::Dex(dex) => {
412 let client = factory.create_dex_client();
413 let flag = args.token_contract.clone();
414 resolve_dex_token(&base, dex.chain(), flag, client.as_ref(), json, out)
415 .await
416 .map(Some)
417 }
418 MarketVenue::Cex(client) => {
419 let mut aliases = scope::tokens::TokenAliases::load();
420 let (chain, flag) = (args.chain.clone(), args.token_contract.clone());
421 check_cex_token(&client, &base, chain, flag, &mut aliases, json, out)
422 .await
423 .map(|()| None)
424 }
425 }
426}
427
428pub(crate) async fn resolve_dex_token(
431 symbol: &str,
432 chain: &str,
433 token_flag: Option<String>,
434 dex: &dyn scope::chains::DexDataSource,
435 json: bool,
436 out: &Output,
437) -> Result<String> {
438 let ctx = ResolveContext {
439 chain: Some(chain.to_string()),
440 venue: VenueKind::Dex,
441 token_flag,
442 yes: false,
443 save: false,
444 };
445 let source = crate::cli::resolve::DexCandidates {
446 dex,
447 out,
448 fallback: None,
449 };
450 let picker = picker_for(out, json);
451 let mut aliases = scope::tokens::TokenAliases::load();
452 let r = resolve(symbol, &ctx, &source, picker.as_ref(), &mut aliases, out).await?;
453 Ok(r.address.unwrap_or_else(|| symbol.to_string()))
454}
455
456async fn check_venue_token(
459 venue: &str,
460 pair: &str,
461 chain: Option<String>,
462 token_flag: Option<String>,
463 json: bool,
464 http: Arc<dyn HttpClient>,
465 out: &Output,
466) -> Result<()> {
467 let Ok(MarketVenue::Cex(client)) = MarketVenue::from_name_with_http(venue, http) else {
468 return Ok(());
469 };
470 let base = Pair::parse(pair).base;
471 let mut aliases = scope::tokens::TokenAliases::load();
472 check_cex_token(&client, &base, chain, token_flag, &mut aliases, json, out).await
473}
474
475async fn token_addresses(
479 args: &SummaryArgs,
480 http: Arc<dyn HttpClient>,
481 out: &Output,
482) -> Vec<TokenAddress> {
483 let Ok(MarketVenue::Cex(client)) = MarketVenue::from_name_with_http(&args.venue, http) else {
484 return Vec::new();
485 };
486 match client.fetch_token_addresses(&Pair::parse(&args.pair)).await {
487 None => Vec::new(),
488 Some(Ok(tokens)) => tokens,
489 Some(Err(e)) => {
490 note!(out, "Token addresses are not available: {e}");
491 Vec::new()
492 }
493 }
494}
495
496async fn summary_run(
498 args: &SummaryArgs,
499 factory: &dyn ChainClientFactory,
500 thresholds: &HealthThresholds,
501 tokens: &[TokenAddress],
502 run_num: Option<u64>,
503 out: &Output,
504) -> Result<(MarketSummary, String)> {
505 let (book, volume_24h, trades) = fetch_book_and_volume(args, thresholds, factory, out).await?;
506 let mut summary = MarketSummary::from_order_book(&book, thresholds, volume_24h);
507 summary.check_bot_patterns(trades.as_deref().map_err(String::as_str));
508 let report = match args.format {
509 SummaryFormat::Text => summary.format_text_with_tokens(Some(&args.venue), tokens),
510 SummaryFormat::Json => {
511 let mut json = summary.to_json();
512 if let Some(obj) = json.as_object_mut() {
513 obj.insert("run".to_string(), serde_json::json!(run_num));
514 obj.insert("venue".to_string(), serde_json::json!(args.venue));
515 if !tokens.is_empty() {
516 obj.insert("tokens".to_string(), serde_json::json!(tokens));
517 }
518 }
519 format!("{}\n", serde_json::to_string_pretty(&json)?)
520 }
521 };
522 Ok((summary, report))
523}
524
525fn write_report(args: &SummaryArgs, summary: &MarketSummary, out: &Output) -> Result<()> {
527 if let Some(ref report_path) = args.report {
528 let md = market_summary_to_markdown(summary, &args.venue, &args.pair);
529 std::fs::write(report_path, md)?;
530 out.note_saved("Report", report_path.display());
531 }
532 Ok(())
533}
534
535fn summary_schedule(args: &SummaryArgs) -> Result<Option<Schedule>> {
537 if args.every.is_none() && args.duration.is_none() {
538 return Ok(None);
539 }
540 let secs = |flag: &Option<String>, default| {
541 flag.as_deref()
542 .map(parse_duration)
543 .transpose()
544 .map(|v| v.unwrap_or(default))
545 };
546 let every = Duration::from_secs(secs(&args.every, DEFAULT_EVERY_SECS)?);
547 if args.duration.as_deref().is_some_and(is_until_cancelled) {
548 return Schedule::until_cancelled(every).map(Some);
549 }
550 let duration = secs(&args.duration, DEFAULT_DURATION_SECS)?;
551 Schedule::new(every, Duration::from_secs(duration)).map(Some)
552}
553
554fn is_until_cancelled(s: &str) -> bool {
556 scope::watch::time::is_until_cancelled(s)
557}
558
559async fn run_summary(
560 args: SummaryArgs,
561 config: &Config,
562 factory: &dyn ChainClientFactory,
563 out: &Output,
564) -> Result<()> {
565 let thresholds = args.health.resolve(config);
566 let schedule = summary_schedule(&args)?;
568 let mut args = args;
569 if let Some(contract) = resolve_market_token(&args, factory, out).await? {
571 args.token_contract = Some(contract);
572 }
573 let tokens = token_addresses(&args, factory.http(), out).await;
575
576 let Some(schedule) = schedule else {
577 let (summary, report) =
578 summary_run(&args, factory, &thresholds, &tokens, None, out).await?;
579 out!(out, "{report}")?;
580 out.record_view(SummaryView {
581 summary: summary.clone(),
582 venue: args.venue.clone(),
583 tokens: tokens.clone(),
584 });
585 return write_report(&args, &summary, out);
586 };
587
588 note!(
589 out,
590 "Running market summary every {}s {} (Ctrl+C to stop)\n",
591 schedule.every.as_secs(),
592 match schedule.duration {
593 Some(d) => format!("for {}s", d.as_secs()),
594 None => "until cancelled".to_string(),
595 }
596 );
597 let metrics = series::summary_metrics(&thresholds);
598 let last = repeat::run(&schedule, &metrics, args.csv.as_deref(), out, |n| {
599 let (args, thresholds, metrics, tokens) = (&args, &thresholds, &metrics, &tokens);
600 async move {
601 let (summary, report) =
602 summary_run(args, factory, thresholds, tokens, Some(n), out).await?;
603 Ok(RunOutput {
604 sample: series::sample(&summary, metrics),
605 snapshot: report,
606 view: Some(SummaryView {
607 summary: summary.clone(),
608 venue: args.venue.clone(),
609 tokens: tokens.to_vec(),
610 }),
611 value: summary,
612 })
613 }
614 })
615 .await?;
616
617 if let Some(summary) = last {
618 write_report(&args, &summary, out)?;
619 }
620 if let Some(ref csv_path) = args.csv {
621 out.note_saved("Time-series CSV", csv_path.display());
622 }
623 Ok(())
624}
625
626async fn run_ohlc(args: OhlcArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
632 let json = matches!(args.format, OhlcFormat::Json);
633 check_venue_token(
634 &args.venue,
635 &args.pair,
636 None,
637 args.token_contract.clone(),
638 json,
639 http.clone(),
640 out,
641 )
642 .await?;
643 let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
644 run_ohlc_with(&client, args, out).await
645}
646
647async fn run_ohlc_with(client: &ExchangeClient, args: OhlcArgs, out: &Output) -> Result<()> {
649 let pair = client.venue_symbol(&Pair::parse(&args.pair));
650
651 let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
652
653 match args.format {
654 OhlcFormat::Json => {
655 let json_str = serde_json::to_string_pretty(&candles)
656 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
657 outln!(out, "{json_str}")?;
658 }
659 OhlcFormat::Text => {
660 outln!(
661 out,
662 "{}",
663 t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
664 )?;
665 outln!(out, "{}", t::kv_row("Interval", &args.interval))?;
666 outln!(out, "{}", t::kv_row("Limit", &args.limit.to_string()))?;
667
668 let cols = [
669 t::Col {
670 label: "Open Time",
671 width: 19,
672 align: '>',
673 },
674 t::Col {
675 label: "Open",
676 width: 12,
677 align: '>',
678 },
679 t::Col {
680 label: "High",
681 width: 12,
682 align: '>',
683 },
684 t::Col {
685 label: "Low",
686 width: 12,
687 align: '>',
688 },
689 t::Col {
690 label: "Close",
691 width: 12,
692 align: '>',
693 },
694 t::Col {
695 label: "Volume",
696 width: 14,
697 align: '>',
698 },
699 ];
700 outln!(out, "{}", t::table_header(&cols))?;
701
702 for c in &candles {
703 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
704 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
705 .unwrap_or_else(|| format!("{}", c.open_time));
706 let open_str = format!("{:.6}", c.open);
707 let high_str = format!("{:.6}", c.high);
708 let low_str = format!("{:.6}", c.low);
709 let close_str = format!("{:.6}", c.close);
710 let volume_str = format!("{:.2}", c.volume);
711 let values = [
712 dt.as_str(),
713 open_str.as_str(),
714 high_str.as_str(),
715 low_str.as_str(),
716 close_str.as_str(),
717 volume_str.as_str(),
718 ];
719 outln!(out, "{}", t::table_row(&cols, &values))?;
720 }
721
722 outln!(
723 out,
724 "{}",
725 t::info_row(&format!("{} candles returned", candles.len()))
726 )?;
727 outln!(out, "{}", t::section_footer())?;
728 }
729 }
730 Ok(())
731}
732
733async fn run_trades(args: TradesArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
739 let json = matches!(args.format, OhlcFormat::Json);
740 check_venue_token(
741 &args.venue,
742 &args.pair,
743 None,
744 args.token_contract.clone(),
745 json,
746 http.clone(),
747 out,
748 )
749 .await?;
750 let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
751 run_trades_with(&client, args, out).await
752}
753
754async fn run_trades_with(client: &ExchangeClient, args: TradesArgs, out: &Output) -> Result<()> {
756 let pair = client.venue_symbol(&Pair::parse(&args.pair));
757
758 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
759
760 match args.format {
761 OhlcFormat::Json => {
762 let json_str = serde_json::to_string_pretty(&trades)
763 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
764 outln!(out, "{json_str}")?;
765 }
766 OhlcFormat::Text => {
767 outln!(
768 out,
769 "{}",
770 t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
771 )?;
772
773 let cols = [
774 t::Col {
775 label: "Time",
776 width: 10,
777 align: '>',
778 },
779 t::Col {
780 label: "Side",
781 width: 5,
782 align: '>',
783 },
784 t::Col {
785 label: "Price",
786 width: 12,
787 align: '>',
788 },
789 t::Col {
790 label: "Qty",
791 width: 12,
792 align: '>',
793 },
794 ];
795 outln!(out, "{}", t::table_header(&cols))?;
796
797 for t in &trades {
798 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
799 .map(|d| d.format("%H:%M:%S").to_string())
800 .unwrap_or_else(|| "?".to_string());
801 let side_str = match t.side {
802 scope::market::TradeSide::Buy => "BUY",
803 scope::market::TradeSide::Sell => "SELL",
804 };
805 let price_str = format!("{:.6}", t.price);
806 let qty_str = format!("{:.2}", t.quantity);
807 let values = [
808 time.as_str(),
809 side_str,
810 price_str.as_str(),
811 qty_str.as_str(),
812 ];
813 outln!(out, "{}", t::table_row(&cols, &values))?;
814 }
815
816 outln!(
817 out,
818 "{}",
819 t::info_row(&format!("{} trades returned", trades.len()))
820 )?;
821 outln!(out, "{}", t::section_footer())?;
822 }
823 }
824 Ok(())
825}
826
827#[cfg(test)]
828mod tests {
829 use super::*;
830 use scope::chains::DefaultClientFactory;
831 use scope::market::DexVenue;
832
833 fn quiet() -> crate::cli::output::Output {
834 crate::cli::output::Output::capture().0
835 }
836
837 #[allow(dead_code)]
841 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
842 let venue_id = format!("test_mock_{}", std::process::id());
843 let yaml = format!(
844 r#"
845id: {venue_id}
846name: Test Mock Venue
847base_url: {server_url}
848timeout_secs: 5
849symbol:
850 template: "{{base}}_{{quote}}"
851 default_quote: USDT
852capabilities:
853 order_book:
854 path: /api/v1/depth
855 params:
856 symbol: "{{pair}}"
857 response:
858 asks_key: asks
859 bids_key: bids
860 level_format: positional
861 ticker:
862 path: /api/v1/ticker
863 params:
864 symbol: "{{pair}}"
865 response:
866 last_price: last
867 volume_24h: vol
868"#
869 );
870 let dir = tempfile::tempdir().unwrap();
871 let file_path = dir.path().join(format!("{}.yaml", venue_id));
872 std::fs::write(&file_path, yaml).unwrap();
873 (venue_id, dir)
876 }
877
878 #[tokio::test]
879 async fn test_run_summary_with_mock_orderbook() {
880 let args = SummaryArgs {
884 pair: "USDC".to_string(),
885 venue: "eth".to_string(),
886 token_contract: None,
887 chain: None,
888 health: HealthArgs {
889 peg_target: Some(1.0),
890 peg_range: Some(0.01),
891 min_levels: Some(1),
892 min_depth: Some(50.0),
893 min_bid_ask_ratio: Some(0.1),
894 max_bid_ask_ratio: Some(10.0),
895 ..Default::default()
896 },
897 format: SummaryFormat::Text,
898 every: None,
899 duration: None,
900 report: None,
901 csv: None,
902 };
903
904 let http: std::sync::Arc<dyn scope::http::HttpClient> =
905 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
906 let factory = DefaultClientFactory {
907 chains_config: Default::default(),
908 http,
909 };
910 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
912 }
914
915 #[tokio::test]
916 async fn test_run_summary_json_format() {
917 let args = SummaryArgs {
918 pair: "USDC".to_string(),
919 venue: "eth".to_string(),
920 token_contract: None,
921 chain: None,
922 health: HealthArgs {
923 peg_target: Some(1.0),
924 peg_range: Some(0.01),
925 min_levels: Some(1),
926 min_depth: Some(50.0),
927 min_bid_ask_ratio: Some(0.1),
928 max_bid_ask_ratio: Some(10.0),
929 ..Default::default()
930 },
931 format: SummaryFormat::Json,
932 every: None,
933 duration: None,
934 report: None,
935 csv: None,
936 };
937
938 let http: std::sync::Arc<dyn scope::http::HttpClient> =
939 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
940 let factory = DefaultClientFactory {
941 chains_config: Default::default(),
942 http,
943 };
944 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
945 }
946
947 #[test]
948 fn test_duration_forever_has_no_end_and_the_default_stays_one_hour() {
949 let args = |duration: Option<&str>| SummaryArgs {
952 pair: "USDC".to_string(),
953 venue: "binance".to_string(),
954 token_contract: None,
955 chain: None,
956 health: HealthArgs::default(),
957 format: SummaryFormat::Text,
958 every: Some("30s".to_string()),
959 duration: duration.map(str::to_string),
960 report: None,
961 csv: None,
962 };
963 for word in ["forever", "inf", "FOREVER"] {
964 let s = summary_schedule(&args(Some(word))).unwrap().unwrap();
965 assert_eq!(s.duration, None, "{word}");
966 }
967 let s = summary_schedule(&args(None)).unwrap().unwrap();
968 assert_eq!(s.duration, Some(Duration::from_secs(DEFAULT_DURATION_SECS)));
969 let s = summary_schedule(&args(Some("10m"))).unwrap().unwrap();
970 assert_eq!(s.duration, Some(Duration::from_secs(600)));
971 }
972
973 #[test]
974 fn test_parse_duration_seconds() {
975 assert_eq!(parse_duration("30s").unwrap(), 30);
976 assert!(
977 parse_duration("forever").is_err(),
978 "not a span; --duration only"
979 );
980 assert_eq!(parse_duration("1").unwrap(), 1);
981 assert_eq!(parse_duration("60sec").unwrap(), 60);
982 }
983
984 #[test]
985 fn test_parse_duration_minutes() {
986 assert_eq!(parse_duration("5m").unwrap(), 300);
987 assert_eq!(parse_duration("1min").unwrap(), 60);
988 assert_eq!(parse_duration("2.5m").unwrap(), 150);
989 }
990
991 #[test]
992 fn test_parse_duration_hours() {
993 assert_eq!(parse_duration("1h").unwrap(), 3600);
994 assert_eq!(parse_duration("24h").unwrap(), 86400);
995 }
996
997 #[test]
998 fn test_parse_duration_invalid() {
999 assert!(parse_duration("").is_err());
1000 assert!(parse_duration("abc").is_err());
1001 assert!(parse_duration("30x").is_err());
1002 }
1003
1004 #[test]
1005 fn test_parse_duration_unknown_unit_error_message() {
1006 let result = parse_duration("30z");
1007 assert!(result.is_err());
1008 let err = result.unwrap_err();
1009 assert!(err.to_string().contains("Unknown duration unit"));
1010 assert!(err.to_string().contains("z"));
1011 }
1012
1013 #[test]
1014 fn test_parse_duration_invalid_number_error() {
1015 let result = parse_duration("abc30s");
1016 assert!(result.is_err());
1017 }
1018
1019 #[test]
1020 fn test_parse_duration_non_positive() {
1021 assert!(parse_duration("0").is_err());
1022 assert!(parse_duration("-5s").is_err());
1023 }
1024
1025 #[test]
1030 fn test_base_symbol_from_pair_underscore() {
1031 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1032 assert_eq!(Pair::parse("USDC_USDT").base, "USDC");
1033 }
1034
1035 #[test]
1036 fn test_base_symbol_from_pair_lowercase_underscore() {
1037 assert_eq!(Pair::parse("dai_usdt").base, "dai");
1038 }
1039
1040 #[test]
1041 fn test_base_symbol_from_pair_slash() {
1042 assert_eq!(Pair::parse("USDC/USDT").base, "USDC");
1043 }
1044
1045 #[test]
1046 fn test_base_symbol_from_pair_concat() {
1047 assert_eq!(Pair::parse("USDCUSDT").base, "USDC");
1048 assert_eq!(Pair::parse("DAIUSDT").base, "DAI");
1049 }
1050
1051 #[test]
1052 fn test_base_symbol_from_pair_plain() {
1053 assert_eq!(Pair::parse("USDC").base, "USDC");
1054 assert_eq!(Pair::parse("ETH").base, "ETH");
1055 }
1056
1057 #[test]
1058 fn test_base_symbol_from_pair_whitespace() {
1059 assert_eq!(Pair::parse(" DAI_USDT ").base, "DAI");
1060 }
1061
1062 #[test]
1063 fn test_normalize_pair_input_key_value() {
1064 assert_eq!(Pair::parse("pair=USDT_PUSD").to_string(), "USDT/PUSD");
1065 assert_eq!(
1066 Pair::parse("pair_symbol=USDT_PUSD").to_string(),
1067 "USDT/PUSD"
1068 );
1069 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1070 }
1071
1072 #[test]
1073 fn test_parse_pair_components_delimited() {
1074 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1075 assert_eq!(Pair::parse("ETH/USDC").to_string(), "ETH/USDC");
1076 assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
1077 }
1078
1079 #[test]
1080 fn test_parse_pair_components_concat_common_quote() {
1081 assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
1082 assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
1083 assert_eq!(Pair::parse("ARB").quote, None);
1084 }
1085
1086 #[test]
1091 fn test_market_summary_to_markdown_basic() {
1092 use scope::market::{HealthCheck, MarketSummary};
1093 let summary = MarketSummary {
1094 pair: "USDCUSDT".to_string(),
1095 source: scope::market::BookSource::Exchange,
1096 peg_target: 1.0,
1097 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1098 best_bid: Some(0.9999),
1099 best_ask: Some(1.0001),
1100 mid_price: Some(1.0000),
1101 spread: Some(0.0002),
1102 spread_pct: None,
1103 top_depth: Default::default(),
1104 volume_24h: Some(1_000_000.0),
1105 bid_depth: 50_000.0,
1106 ask_depth: 50_000.0,
1107 bid_outliers: 0,
1108 ask_outliers: 0,
1109 healthy: true,
1110 bot_patterns: None,
1111 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1112 execution_10k_buy: None,
1113 execution_10k_sell: None,
1114 asks: vec![],
1115 bids: vec![],
1116 };
1117 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1118 assert!(md.contains("Market Health Report"));
1119 assert!(md.contains("USDCUSDT"));
1120 assert!(md.contains("Binance"));
1121 assert!(md.contains("Peg Target"));
1122 assert!(md.contains("1.0000"));
1123 assert!(md.contains("Healthy"));
1124 }
1125
1126 #[test]
1127 fn test_market_summary_to_markdown_no_prices() {
1128 use scope::market::{HealthCheck, MarketSummary};
1129 let summary = MarketSummary {
1130 pair: "TESTUSDT".to_string(),
1131 source: scope::market::BookSource::Exchange,
1132 peg_target: 1.0,
1133 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1134 best_bid: None,
1135 best_ask: None,
1136 mid_price: None,
1137 spread: None,
1138 spread_pct: None,
1139 top_depth: Default::default(),
1140 volume_24h: None,
1141 bid_depth: 0.0,
1142 ask_depth: 0.0,
1143 bid_outliers: 0,
1144 ask_outliers: 0,
1145 healthy: false,
1146 bot_patterns: None,
1147 checks: vec![HealthCheck::Fail("No data".to_string())],
1148 execution_10k_buy: None,
1149 execution_10k_sell: None,
1150 asks: vec![],
1151 bids: vec![],
1152 };
1153 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1154 assert!(md.contains("Market Health Report"));
1155 assert!(md.contains("-")); }
1157
1158 #[test]
1163 fn test_parse_duration_days() {
1164 assert_eq!(parse_duration("1d").unwrap(), 86400);
1165 assert_eq!(parse_duration("7d").unwrap(), 604800);
1166 assert_eq!(parse_duration("1day").unwrap(), 86400);
1167 assert_eq!(parse_duration("2days").unwrap(), 172800);
1168 }
1169
1170 #[test]
1171 fn test_parse_duration_long_names() {
1172 assert_eq!(parse_duration("30seconds").unwrap(), 30);
1173 assert_eq!(parse_duration("5minutes").unwrap(), 300);
1174 assert_eq!(parse_duration("2hours").unwrap(), 7200);
1175 }
1176
1177 #[test]
1178 fn test_parse_duration_fractional() {
1179 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1180 assert_eq!(parse_duration("1.5m").unwrap(), 90);
1181 }
1182
1183 #[test]
1188 fn test_summary_format_default() {
1189 let fmt = SummaryFormat::default();
1190 assert!(matches!(fmt, SummaryFormat::Text));
1191 }
1192
1193 #[test]
1194 fn test_summary_format_debug() {
1195 let text = format!("{:?}", SummaryFormat::Text);
1196 assert_eq!(text, "Text");
1197 let json = format!("{:?}", SummaryFormat::Json);
1198 assert_eq!(json, "Json");
1199 }
1200
1201 #[test]
1206 fn test_ohlc_args_deserialization() {
1207 use crate::cli::{Cli, Commands};
1208 use clap::Parser;
1209 let cli = Cli::try_parse_from([
1210 "scope",
1211 "market",
1212 "ohlc",
1213 "USDC",
1214 "--venue",
1215 "binance",
1216 "--interval",
1217 "1h",
1218 "--limit",
1219 "50",
1220 ])
1221 .unwrap();
1222 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1223 assert_eq!(args.pair, "USDC");
1224 assert_eq!(args.venue, "binance");
1225 assert_eq!(args.interval, "1h");
1226 assert_eq!(args.limit, 50);
1227 } else {
1228 panic!("Expected Market Ohlc command");
1229 }
1230 }
1231
1232 #[test]
1233 fn test_trades_args_deserialization() {
1234 use crate::cli::{Cli, Commands};
1235 use clap::Parser;
1236 let cli = Cli::try_parse_from([
1237 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1238 ])
1239 .unwrap();
1240 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1241 assert_eq!(args.pair, "BTC");
1242 assert_eq!(args.venue, "mexc");
1243 assert_eq!(args.limit, 100);
1244 } else {
1245 panic!("Expected Market Trades command");
1246 }
1247 }
1248
1249 #[test]
1250 fn test_base_symbol_from_pair_various_inputs() {
1251 assert_eq!(Pair::parse("USDC").base, "USDC");
1253 assert_eq!(Pair::parse("BTCUSDT").base, "BTC");
1254 assert_eq!(Pair::parse("ETH/USDT").base, "ETH");
1255 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1256 assert_eq!(Pair::parse("X").base, "X"); assert_eq!(Pair::parse("").base, "");
1258 }
1259
1260 #[test]
1261 fn test_summary_args_debug() {
1262 let args = SummaryArgs {
1263 pair: "USDC".to_string(),
1264 venue: "binance".to_string(),
1265 token_contract: None,
1266 chain: None,
1267 health: HealthArgs {
1268 peg_target: Some(1.0),
1269 peg_range: Some(0.001),
1270 min_levels: Some(6),
1271 min_depth: Some(3000.0),
1272 min_bid_ask_ratio: Some(0.2),
1273 max_bid_ask_ratio: Some(5.0),
1274 ..Default::default()
1275 },
1276 format: SummaryFormat::Text,
1277 every: None,
1278 duration: None,
1279 report: None,
1280 csv: None,
1281 };
1282 let debug = format!("{:?}", args);
1283 assert!(debug.contains("SummaryArgs"));
1284 assert!(debug.contains("USDC"));
1285 }
1286
1287 #[test]
1288 fn test_default_constants() {
1289 assert_eq!(DEFAULT_EVERY_SECS, 60);
1290 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1291 }
1292
1293 #[test]
1294 fn test_markdown_leaves_out_the_peg_deviation_when_there_is_none() {
1295 use scope::market::{BookSource, OrderBook, OrderBookLevel};
1297 let level = |price| OrderBookLevel {
1298 price,
1299 quantity: 100.0,
1300 };
1301 let book = OrderBook {
1302 pair: "USDC/USDT".to_string(),
1303 source: BookSource::Exchange,
1304 bids: vec![level(0.9999)],
1305 asks: vec![level(1.0001)],
1306 };
1307 let thresholds = HealthThresholds {
1308 peg_target: 0.0,
1309 ..HealthThresholds::default()
1310 };
1311 let summary = MarketSummary::from_order_book(&book, &thresholds, None);
1312 let md = market_summary_to_markdown(&summary, "binance", "USDC");
1313 assert!(md.contains("0.9999") && !md.contains("0.9999 ("), "{md}");
1314 assert!(!md.contains("(+0.000%)"), "{md}");
1315 }
1316
1317 #[test]
1318 fn test_market_summary_to_markdown_with_execution_estimates() {
1319 use scope::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1320 let summary = MarketSummary {
1321 pair: "TESTUSDT".to_string(),
1322 source: scope::market::BookSource::Exchange,
1323 peg_target: 1.0,
1324 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1325 best_bid: Some(0.9999),
1326 best_ask: Some(1.0001),
1327 mid_price: Some(1.0000),
1328 spread: Some(0.0002),
1329 spread_pct: None,
1330 top_depth: Default::default(),
1331 volume_24h: Some(1_000_000.0),
1332 bid_depth: 50_000.0,
1333 ask_depth: 50_000.0,
1334 bid_outliers: 0,
1335 ask_outliers: 0,
1336 healthy: true,
1337 bot_patterns: None,
1338 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1339 execution_10k_buy: Some(ExecutionEstimate {
1340 notional_usdt: 10_000.0,
1341 side: ExecutionSide::Buy,
1342 vwap: 1.0001,
1343 slippage_bps: 1.5,
1344 fillable: true,
1345 }),
1346 execution_10k_sell: Some(ExecutionEstimate {
1347 notional_usdt: 10_000.0,
1348 side: ExecutionSide::Sell,
1349 vwap: 0.0,
1350 slippage_bps: 0.0,
1351 fillable: false,
1352 }),
1353 asks: vec![],
1354 bids: vec![],
1355 };
1356 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1357 assert!(md.contains("Market Health Report"));
1358 assert!(md.contains("TESTUSDT"));
1359 assert!(md.contains("TestVenue"));
1360 assert!(md.contains("1.50 bps"));
1362 assert!(md.contains("insufficient"));
1364 }
1365
1366 #[tokio::test]
1367 async fn test_run_with_summary_command() {
1368 let args = MarketCommands::Summary(SummaryArgs {
1370 pair: "USDC".to_string(),
1371 venue: "eth".to_string(),
1372 token_contract: None,
1373 chain: None,
1374 health: HealthArgs {
1375 peg_target: Some(1.0),
1376 peg_range: Some(0.01),
1377 min_levels: Some(1),
1378 min_depth: Some(50.0),
1379 min_bid_ask_ratio: Some(0.1),
1380 max_bid_ask_ratio: Some(10.0),
1381 ..Default::default()
1382 },
1383 format: SummaryFormat::Text,
1384 every: None,
1385 duration: None,
1386 report: None,
1387 csv: None,
1388 });
1389
1390 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1391 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1392 let factory = DefaultClientFactory {
1393 chains_config: Default::default(),
1394 http,
1395 };
1396 let config = Config::default();
1397 let _result = run(args, &config, &factory, &quiet()).await;
1398 }
1400
1401 #[test]
1402 fn test_is_dex_venue() {
1403 assert!(DexVenue::parse("eth").is_some());
1404 assert!(DexVenue::parse("ethereum").is_some());
1405 assert!(DexVenue::parse("Ethereum").is_some());
1406 assert!(DexVenue::parse("solana").is_some());
1407 assert!(DexVenue::parse("Solana").is_some());
1408 assert!(DexVenue::parse("binance").is_none());
1409 assert!(DexVenue::parse("okx").is_none());
1410 assert!(DexVenue::parse("mexc").is_none());
1411 }
1412
1413 #[test]
1414 fn test_dex_venue_to_chain() {
1415 assert_eq!(
1416 DexVenue::parse("eth").map(DexVenue::chain),
1417 Some("ethereum")
1418 );
1419 assert_eq!(
1420 DexVenue::parse("ethereum").map(DexVenue::chain),
1421 Some("ethereum")
1422 );
1423 assert_eq!(
1424 DexVenue::parse("Ethereum").map(DexVenue::chain),
1425 Some("ethereum")
1426 );
1427 assert_eq!(
1428 DexVenue::parse("solana").map(DexVenue::chain),
1429 Some("solana")
1430 );
1431 }
1432
1433 #[test]
1434 fn test_venue_registry_loaded_in_cex_path() {
1435 let registry = VenueRegistry::load().unwrap();
1437 assert!(registry.contains("binance"));
1438 let client =
1439 registry.create_exchange_client_with_http("binance", scope::http::test_transport());
1440 assert!(client.is_ok());
1441 }
1442
1443 #[test]
1444 fn test_venue_registry_error_for_unknown() {
1445 let registry = VenueRegistry::load().unwrap();
1446 let result =
1447 registry.create_exchange_client_with_http("kracken", scope::http::test_transport());
1448 assert!(result.is_err());
1449 let err = result.unwrap_err().to_string();
1450 assert!(err.contains("Unknown venue"));
1451 assert!(err.contains("Did you mean")); }
1453
1454 #[tokio::test]
1455 async fn test_run_summary_json_format_with_mock() {
1456 let args = SummaryArgs {
1457 pair: "USDC".to_string(),
1458 venue: "eth".to_string(),
1459 token_contract: None,
1460 chain: None,
1461 health: HealthArgs {
1462 peg_target: Some(1.0),
1463 peg_range: Some(0.01),
1464 min_levels: Some(1),
1465 min_depth: Some(50.0),
1466 min_bid_ask_ratio: Some(0.1),
1467 max_bid_ask_ratio: Some(10.0),
1468 ..Default::default()
1469 },
1470 format: SummaryFormat::Json,
1471 every: None,
1472 duration: None,
1473 report: None,
1474 csv: None,
1475 };
1476
1477 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1478 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1479 let factory = DefaultClientFactory {
1480 chains_config: Default::default(),
1481 http,
1482 };
1483 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1484 }
1485
1486 #[test]
1491 fn test_ohlc_format_default() {
1492 let fmt: OhlcFormat = Default::default();
1493 assert_eq!(fmt, OhlcFormat::Text);
1494 }
1495
1496 #[test]
1497 fn test_ohlc_format_display() {
1498 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1500 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1501 }
1502
1503 #[test]
1504 fn test_ohlc_args_default_values() {
1505 let args = OhlcArgs {
1507 pair: "BTC".to_string(),
1508 venue: "binance".to_string(),
1509 token_contract: None,
1510 interval: "1h".to_string(),
1511 limit: 100,
1512 format: OhlcFormat::Text,
1513 };
1514 assert_eq!(args.pair, "BTC");
1515 assert_eq!(args.venue, "binance");
1516 assert_eq!(args.interval, "1h");
1517 assert_eq!(args.limit, 100);
1518 }
1519
1520 #[test]
1521 fn test_trades_args_construction() {
1522 let args = TradesArgs {
1523 pair: "ETH".to_string(),
1524 venue: "okx".to_string(),
1525 token_contract: None,
1526 limit: 50,
1527 format: OhlcFormat::Json,
1528 };
1529 assert_eq!(args.pair, "ETH");
1530 assert_eq!(args.venue, "okx");
1531 assert_eq!(args.limit, 50);
1532 }
1533
1534 #[tokio::test]
1535 async fn test_run_ohlc_unknown_venue() {
1536 let args = OhlcArgs {
1537 pair: "BTC".to_string(),
1538 venue: "nonexistent_venue".to_string(),
1539 token_contract: None,
1540 interval: "1h".to_string(),
1541 limit: 10,
1542 format: OhlcFormat::Text,
1543 };
1544 let result = run_ohlc(args, scope::http::test_transport(), &quiet()).await;
1545 assert!(result.is_err());
1546 let err = result.unwrap_err().to_string();
1547 assert!(
1548 err.contains("Unknown venue"),
1549 "expected 'Unknown venue' error, got: {}",
1550 err
1551 );
1552 }
1553
1554 #[tokio::test]
1555 async fn test_run_trades_unknown_venue() {
1556 let args = TradesArgs {
1557 pair: "BTC".to_string(),
1558 venue: "nonexistent_venue".to_string(),
1559 token_contract: None,
1560 limit: 10,
1561 format: OhlcFormat::Text,
1562 };
1563 let result = run_trades(args, scope::http::test_transport(), &quiet()).await;
1564 assert!(result.is_err());
1565 let err = result.unwrap_err().to_string();
1566 assert!(
1567 err.contains("Unknown venue"),
1568 "expected 'Unknown venue' error, got: {}",
1569 err
1570 );
1571 }
1572
1573 #[tokio::test]
1574 async fn test_run_dispatches_ohlc() {
1575 let cmd = MarketCommands::Ohlc(OhlcArgs {
1576 pair: "BTC".to_string(),
1577 venue: "nonexistent_test_venue".to_string(),
1578 token_contract: None,
1579 interval: "1h".to_string(),
1580 limit: 5,
1581 format: OhlcFormat::Text,
1582 });
1583 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1584 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1585 let factory = DefaultClientFactory {
1586 chains_config: Default::default(),
1587 http,
1588 };
1589 let config = Config::default();
1590 let result = run(cmd, &config, &factory, &quiet()).await;
1591 assert!(result.is_err());
1593 }
1594
1595 #[tokio::test]
1596 async fn test_run_dispatches_trades() {
1597 let cmd = MarketCommands::Trades(TradesArgs {
1598 pair: "ETH".to_string(),
1599 venue: "nonexistent_test_venue".to_string(),
1600 token_contract: None,
1601 limit: 5,
1602 format: OhlcFormat::Json,
1603 });
1604 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1605 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1606 let factory = DefaultClientFactory {
1607 chains_config: Default::default(),
1608 http,
1609 };
1610 let config = Config::default();
1611 let result = run(cmd, &config, &factory, &quiet()).await;
1612 assert!(result.is_err());
1613 }
1614
1615 fn venue_with_coins(server: &mockito::ServerGuard) -> ExchangeClient {
1617 let mut desc = VenueRegistry::load()
1618 .unwrap()
1619 .get("binance")
1620 .unwrap()
1621 .clone();
1622 desc.base_url = server.url();
1623 desc.capabilities.assets =
1624 Some(serde_saphyr::from_str("path: /coins\nresponse: {}\n").unwrap());
1625 ExchangeClient::from_descriptor_with_http(&desc, scope::http::test_transport())
1626 }
1627
1628 async fn coins(server: &mut mockito::ServerGuard, body: &str) -> mockito::Mock {
1629 server
1630 .mock("GET", "/coins")
1631 .with_status(200)
1632 .with_body(body)
1633 .create_async()
1634 .await
1635 }
1636
1637 #[tokio::test]
1638 async fn test_a_cex_run_stops_when_the_saved_token_is_not_the_venues() {
1639 let mut server = mockito::Server::new_async().await;
1642 let _m = coins(
1643 &mut server,
1644 r#"[{"coin":"USDN","networkList":[{"network":"ETH","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1645 )
1646 .await;
1647 let client = venue_with_coins(&server);
1648 let mut store = scope::tokens::TokenAliases::default();
1649 store.add_binding(
1650 "USDN",
1651 "ethereum",
1652 "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1653 "USDN",
1654 Some("binance"),
1655 );
1656 let err = check_cex_token(
1657 &client,
1658 "USDN",
1659 Some("ethereum".into()),
1660 None,
1661 &mut store,
1662 false,
1663 &quiet(),
1664 )
1665 .await
1666 .unwrap_err();
1667 assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1668 }
1669
1670 #[tokio::test]
1671 async fn test_a_cex_symbol_on_two_networks_stops_without_a_terminal() {
1672 let mut server = mockito::Server::new_async().await;
1673 let _m = coins(
1674 &mut server,
1675 r#"[{"coin":"USDN","networkList":[
1676 {"network":"ETH","contractAddress":"0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
1677 {"network":"BSC","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1678 )
1679 .await;
1680 let client = venue_with_coins(&server);
1681 let mut store = scope::tokens::TokenAliases::default();
1682 let err = check_cex_token(&client, "USDN", None, None, &mut store, false, &quiet())
1683 .await
1684 .unwrap_err();
1685 assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{err}");
1686 assert!(
1688 check_cex_token(
1689 &client,
1690 "USDN",
1691 None,
1692 Some("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into()),
1693 &mut store,
1694 false,
1695 &quiet()
1696 )
1697 .await
1698 .is_ok()
1699 );
1700 }
1701
1702 fn binance_at(server: &mockito::ServerGuard) -> ExchangeClient {
1704 scope::market::testing::venue_at("binance", &server.url())
1705 }
1706
1707 const KLINES: &str = r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999],
1708 [1700003600000,"1.25","2.0","1.0","1.75","20.25",1700007199999]]"#;
1709 const TRADES: &str = r#"[{"id":1,"price":"1.5","qty":"2.5","quoteQty":"3.75","time":1700000000000,"isBuyerMaker":true},
1710 {"id":2,"price":"1.6","qty":"1.0","quoteQty":"1.6","time":1700000001000,"isBuyerMaker":false}]"#;
1711
1712 fn ohlc_args(format: OhlcFormat) -> OhlcArgs {
1713 OhlcArgs {
1714 pair: "BTC".to_string(),
1715 venue: "binance".to_string(),
1716 token_contract: None,
1717 interval: "1h".to_string(),
1718 limit: 2,
1719 format,
1720 }
1721 }
1722
1723 fn trades_args(format: OhlcFormat) -> TradesArgs {
1724 TradesArgs {
1725 pair: "BTC".to_string(),
1726 venue: "binance".to_string(),
1727 token_contract: None,
1728 limit: 2,
1729 format,
1730 }
1731 }
1732
1733 async fn serve(path: &str, body: &str) -> mockito::ServerGuard {
1734 let mut server = mockito::Server::new_async().await;
1735 server
1736 .mock("GET", path)
1737 .match_query(mockito::Matcher::UrlEncoded(
1738 "symbol".into(),
1739 "BTCUSDT".into(),
1740 ))
1741 .with_body(body)
1742 .create_async()
1743 .await;
1744 server
1745 }
1746
1747 #[tokio::test]
1748 async fn test_ohlc_text_shows_every_candle() {
1749 let server = serve("/api/v3/klines", KLINES).await;
1750 let (out, cap) = Output::capture();
1751 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Text), &out)
1752 .await
1753 .unwrap();
1754 let text = crate::cli::ansi::strip(&cap.out());
1755 assert!(text.contains("OHLC — BTCUSDT (binance)"), "{text}");
1756 assert!(text.contains("1.250000"), "{text}");
1757 assert!(text.contains("1000.50"), "{text}");
1758 assert!(text.contains("2 candles returned"), "{text}");
1759 }
1760
1761 #[tokio::test]
1762 async fn test_ohlc_json_keeps_every_field() {
1763 let server = serve("/api/v3/klines", KLINES).await;
1764 let (out, cap) = Output::capture();
1765 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Json), &out)
1766 .await
1767 .unwrap();
1768 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1769 assert_eq!(v.as_array().unwrap().len(), 2);
1770 assert_eq!(v[0]["open_time"], 1_700_000_000_000u64);
1771 assert_eq!(v[1]["close"], 1.75);
1772 assert_eq!(v[1]["volume"], 20.25);
1773 }
1774
1775 #[tokio::test]
1776 async fn test_trades_text_shows_side_price_and_quantity() {
1777 let server = serve("/api/v3/trades", TRADES).await;
1778 let (out, cap) = Output::capture();
1779 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Text), &out)
1780 .await
1781 .unwrap();
1782 let text = crate::cli::ansi::strip(&cap.out());
1783 assert!(text.contains("Recent Trades — BTCUSDT (binance)"), "{text}");
1784 assert!(text.contains("SELL") && text.contains("BUY"), "{text}");
1786 assert!(text.contains("1.500000") && text.contains("2.50"), "{text}");
1787 assert!(text.contains("2 trades returned"), "{text}");
1788 }
1789
1790 #[tokio::test]
1791 async fn test_trades_json_keeps_every_field() {
1792 let server = serve("/api/v3/trades", TRADES).await;
1793 let (out, cap) = Output::capture();
1794 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Json), &out)
1795 .await
1796 .unwrap();
1797 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1798 assert_eq!(v[0]["side"], "sell");
1799 assert_eq!(v[1]["side"], "buy");
1800 assert_eq!(v[0]["id"], "1");
1801 assert_eq!(v[0]["quote_quantity"], 3.75);
1802 assert_eq!(v[1]["timestamp_ms"], 1_700_000_001_000u64);
1803 }
1804
1805 #[test]
1810 fn test_parse_duration_whitespace_empty() {
1811 assert!(parse_duration("").is_err());
1812 assert!(parse_duration(" ").is_err());
1813 }
1814
1815 #[test]
1816 fn test_parse_duration_sec_secs_second_seconds() {
1817 assert_eq!(parse_duration("1sec").unwrap(), 1);
1818 assert_eq!(parse_duration("2secs").unwrap(), 2);
1819 assert_eq!(parse_duration("1second").unwrap(), 1);
1820 assert_eq!(parse_duration("3seconds").unwrap(), 3);
1821 }
1822
1823 #[test]
1824 fn test_parse_duration_minute_minutes() {
1825 assert_eq!(parse_duration("1minute").unwrap(), 60);
1826 assert_eq!(parse_duration("2minutes").unwrap(), 120);
1827 }
1828
1829 #[test]
1830 fn test_parse_duration_hr_hrs_hour_hours() {
1831 assert_eq!(parse_duration("1hr").unwrap(), 3600);
1832 assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1833 assert_eq!(parse_duration("1hour").unwrap(), 3600);
1834 assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1835 }
1836
1837 #[test]
1838 fn test_parse_duration_number_only_defaults_to_seconds() {
1839 assert_eq!(parse_duration("1.5").unwrap(), 1);
1840 assert_eq!(parse_duration("42").unwrap(), 42);
1841 }
1842
1843 #[test]
1844 fn test_parse_duration_trimmed_input() {
1845 assert_eq!(parse_duration(" 30s ").unwrap(), 30);
1846 assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1847 }
1848
1849 #[test]
1850 fn test_parse_duration_invalid_number_format() {
1851 assert!(parse_duration("1.2.3s").is_err());
1852 assert!(parse_duration("abc").is_err());
1853 }
1854
1855 #[test]
1860 fn test_unknown_venue_is_not_a_dex_venue() {
1861 assert_eq!(DexVenue::parse("binance"), None);
1862 assert_eq!(DexVenue::parse("kraken"), None);
1863 assert_eq!(DexVenue::parse("unknown"), None);
1864 }
1865
1866 #[test]
1871 fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1872 use scope::market::{HealthCheck, MarketSummary};
1873 let summary = MarketSummary {
1874 pair: "TESTUSDT".to_string(),
1875 source: scope::market::BookSource::Exchange,
1876 peg_target: 1.0,
1877 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1878 best_bid: Some(0.9999),
1879 best_ask: Some(1.0001),
1880 mid_price: Some(1.0000),
1881 spread: Some(0.0002),
1882 spread_pct: None,
1883 top_depth: Default::default(),
1884 volume_24h: Some(500_000.0),
1885 bid_depth: 40_000.0,
1886 ask_depth: 45_000.0,
1887 bid_outliers: 0,
1888 ask_outliers: 0,
1889 healthy: false,
1890 bot_patterns: None,
1891 checks: vec![
1892 HealthCheck::Pass("Spread within range".to_string()),
1893 HealthCheck::Fail("Bid depth below minimum".to_string()),
1894 ],
1895 execution_10k_buy: None,
1896 execution_10k_sell: None,
1897 asks: vec![],
1898 bids: vec![],
1899 };
1900 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1901 assert!(md.contains("✓ Spread within range"));
1902 assert!(md.contains("✗ Bid depth below minimum"));
1903 assert!(md.contains("✗")); }
1905
1906 #[test]
1907 fn test_market_summary_to_markdown_empty_checks() {
1908 use scope::market::MarketSummary;
1909 let summary = MarketSummary {
1910 pair: "X".to_string(),
1911 source: scope::market::BookSource::Exchange,
1912 peg_target: 1.0,
1913 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1914 best_bid: Some(1.0),
1915 best_ask: Some(1.0),
1916 mid_price: Some(1.0),
1917 spread: Some(0.0),
1918 spread_pct: None,
1919 top_depth: Default::default(),
1920 volume_24h: None,
1921 bid_depth: 100.0,
1922 ask_depth: 100.0,
1923 bid_outliers: 0,
1924 ask_outliers: 0,
1925 healthy: true,
1926 bot_patterns: None,
1927 checks: vec![],
1928 execution_10k_buy: None,
1929 execution_10k_sell: None,
1930 asks: vec![],
1931 bids: vec![],
1932 };
1933 let md = market_summary_to_markdown(&summary, "Venue", "X");
1934 assert!(md.contains("Market Health Report"));
1935 assert!(!md.contains("Health Checks:"));
1937 }
1938
1939 #[test]
1944 fn test_ohlc_format_partial_eq() {
1945 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1946 assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1947 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1948 }
1949
1950 #[test]
1951 fn test_summary_format_clone_copy() {
1952 let text = SummaryFormat::Text;
1953 let cloned = text;
1954 assert!(matches!(cloned, SummaryFormat::Text));
1955 assert!(matches!(text, SummaryFormat::Text));
1956 }
1957
1958 #[tokio::test]
1963 async fn test_run_summary_interval_zero_error() {
1964 let args = SummaryArgs {
1965 pair: "USDC".to_string(),
1966 venue: "eth".to_string(),
1967 token_contract: None,
1968 chain: None,
1969 health: HealthArgs {
1970 peg_target: Some(1.0),
1971 peg_range: Some(0.01),
1972 min_levels: Some(1),
1973 min_depth: Some(50.0),
1974 min_bid_ask_ratio: Some(0.1),
1975 max_bid_ask_ratio: Some(10.0),
1976 ..Default::default()
1977 },
1978 format: SummaryFormat::Text,
1979 every: Some("0.1s".to_string()),
1980 duration: Some("1m".to_string()),
1981 report: None,
1982 csv: None,
1983 };
1984 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1985 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1986 let factory = DefaultClientFactory {
1987 chains_config: Default::default(),
1988 http,
1989 };
1990 let result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1991 assert!(result.is_err());
1992 let err = result.unwrap_err().to_string();
1993 assert!(
1994 err.contains("Interval must be positive") || err.contains("positive"),
1995 "expected interval error, got: {}",
1996 err
1997 );
1998 }
1999
2000 #[test]
2005 fn test_market_commands_debug() {
2006 let cmd = MarketCommands::Summary(SummaryArgs {
2007 pair: "USDC".to_string(),
2008 venue: "binance".to_string(),
2009 token_contract: None,
2010 chain: None,
2011 health: HealthArgs {
2012 peg_target: Some(1.0),
2013 peg_range: Some(0.001),
2014 min_levels: Some(6),
2015 min_depth: Some(3000.0),
2016 min_bid_ask_ratio: Some(0.2),
2017 max_bid_ask_ratio: Some(5.0),
2018 ..Default::default()
2019 },
2020 format: SummaryFormat::Text,
2021 every: None,
2022 duration: None,
2023 report: None,
2024 csv: None,
2025 });
2026 let debug = format!("{:?}", cmd);
2027 assert!(debug.contains("Summary"));
2028
2029 let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
2030 pair: "BTC".to_string(),
2031 venue: "binance".to_string(),
2032 token_contract: None,
2033 interval: "1h".to_string(),
2034 limit: 100,
2035 format: OhlcFormat::Text,
2036 });
2037 assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
2038
2039 let trades_cmd = MarketCommands::Trades(TradesArgs {
2040 pair: "ETH".to_string(),
2041 venue: "binance".to_string(),
2042 token_contract: None,
2043 limit: 50,
2044 format: OhlcFormat::Json,
2045 });
2046 assert!(format!("{:?}", trades_cmd).contains("Trades"));
2047 }
2048
2049 #[test]
2050 fn test_summary_args_with_report_csv_options() {
2051 let args = SummaryArgs {
2052 pair: "DAI".to_string(),
2053 venue: "binance".to_string(),
2054 token_contract: None,
2055 chain: None,
2056 health: HealthArgs {
2057 peg_target: Some(1.0),
2058 peg_range: Some(0.001),
2059 min_levels: Some(6),
2060 min_depth: Some(3000.0),
2061 min_bid_ask_ratio: Some(0.2),
2062 max_bid_ask_ratio: Some(5.0),
2063 ..Default::default()
2064 },
2065 format: SummaryFormat::Json,
2066 every: Some("30s".to_string()),
2067 duration: Some("1h".to_string()),
2068 report: Some(std::path::PathBuf::from("/tmp/report.md")),
2069 csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
2070 };
2071 assert_eq!(args.pair, "DAI");
2072 assert_eq!(args.venue, "binance");
2073 assert!(args.every.is_some());
2074 assert!(args.duration.is_some());
2075 assert!(args.report.is_some());
2076 assert!(args.csv.is_some());
2077 }
2078
2079 #[test]
2080 fn test_base_symbol_from_pair_4char_usdt() {
2081 assert_eq!(Pair::parse("USDT").base, "USDT");
2083 }
2084
2085 #[test]
2086 fn test_market_summary_to_markdown_unhealthy() {
2087 use scope::market::{HealthCheck, MarketSummary};
2088 let summary = MarketSummary {
2089 pair: "X".to_string(),
2090 source: scope::market::BookSource::Exchange,
2091 peg_target: 1.0,
2092 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
2093 best_bid: Some(0.99),
2094 best_ask: Some(1.01),
2095 mid_price: Some(1.0),
2096 spread: Some(0.02),
2097 spread_pct: None,
2098 top_depth: Default::default(),
2099 volume_24h: None,
2100 bid_depth: 100.0,
2101 ask_depth: 100.0,
2102 bid_outliers: 0,
2103 ask_outliers: 0,
2104 healthy: false,
2105 bot_patterns: None,
2106 checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
2107 execution_10k_buy: None,
2108 execution_10k_sell: None,
2109 asks: vec![],
2110 bids: vec![],
2111 };
2112 let md = market_summary_to_markdown(&summary, "Test", "X");
2113 assert!(md.contains("✗"));
2114 assert!(md.contains("Peg deviation too high"));
2115 }
2116
2117 #[test]
2118 fn test_ohlc_format_eq() {
2119 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
2120 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
2121 }
2122
2123 #[test]
2124 fn test_trades_args_default_venue() {
2125 let args = TradesArgs {
2126 pair: "USDC".to_string(),
2127 venue: "binance".to_string(),
2128 token_contract: None,
2129 limit: 50,
2130 format: OhlcFormat::Text,
2131 };
2132 assert_eq!(args.pair, "USDC");
2133 assert_eq!(args.venue, "binance");
2134 }
2135
2136 const USDC_ADDR: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
2141
2142 fn dex_factory(pairs: &[(f64, f64)]) -> scope::chains::mocks::MockClientFactory {
2144 let mut factory = scope::chains::mocks::MockClientFactory::new();
2145 let data = factory.mock_dex.token_data.as_mut().unwrap();
2146 data.symbol = "USDC".to_string();
2147 data.pairs = pairs
2148 .iter()
2149 .enumerate()
2150 .map(|(i, &(liquidity_usd, volume_24h))| scope::chains::DexPair {
2151 dex_name: format!("DEX {i}"),
2152 pair_address: format!("0xpair{i}"),
2153 base_token: "USDC".to_string(),
2154 quote_token: "USDT".to_string(),
2155 price_usd: 1.0,
2156 volume_24h,
2157 liquidity_usd,
2158 liquidity_base: None,
2159 price_change_24h: 0.0,
2160 buys_24h: 0,
2161 sells_24h: 0,
2162 buys_6h: 0,
2163 sells_6h: 0,
2164 buys_1h: 0,
2165 sells_1h: 0,
2166 pair_created_at: None,
2167 url: None,
2168 })
2169 .collect();
2170 factory
2171 }
2172
2173 fn dex_args(format: SummaryFormat) -> SummaryArgs {
2174 SummaryArgs {
2175 pair: USDC_ADDR.to_string(),
2176 venue: "eth".to_string(),
2177 token_contract: None,
2178 chain: None,
2179 health: HealthArgs::default(),
2180 format,
2181 every: None,
2182 duration: None,
2183 report: None,
2184 csv: None,
2185 }
2186 }
2187
2188 fn two_chain_usdn(factory: &mut scope::chains::mocks::MockClientFactory) {
2189 let usdn = |chain: &str, address: &str| scope::chains::dex::TokenSearchResult {
2190 address: address.into(),
2191 symbol: "USDN".into(),
2192 name: "USDN".into(),
2193 chain: chain.into(),
2194 price_usd: Some(1.0),
2195 volume_24h: 0.0,
2196 liquidity_usd: 1.0,
2197 market_cap: None,
2198 };
2199 factory.mock_dex.search_results = vec![usdn("bsc", "0xB"), usdn("ethereum", "0xA")];
2200 }
2201
2202 #[tokio::test]
2203 async fn test_a_dex_venue_resolves_on_its_own_chain() {
2204 let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2206 two_chain_usdn(&mut factory);
2207 let mut args = dex_args(SummaryFormat::Text);
2208 args.pair = "USDN".into();
2209 let token = resolve_market_token(&args, &factory, &quiet())
2210 .await
2211 .unwrap();
2212 assert_eq!(token.as_deref(), Some("0xA"));
2213 }
2214
2215 #[tokio::test]
2216 async fn test_a_dex_venue_takes_the_token_flag_without_a_search() {
2217 let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2219 two_chain_usdn(&mut factory);
2220 let mut args = dex_args(SummaryFormat::Text);
2221 args.pair = "USDN".into();
2222 args.token_contract = Some("0xT".into());
2223 let token = resolve_market_token(&args, &factory, &quiet())
2224 .await
2225 .unwrap();
2226 assert_eq!(token.as_deref(), Some("0xT"));
2227 }
2228
2229 #[tokio::test]
2230 async fn test_dex_book_uses_most_liquid_pair() {
2231 let factory = dex_factory(&[(1_000.0, 10.0), (50_000.0, 777.0), (2_000.0, 20.0)]);
2233 let (book, volume, trades) = fetch_book_and_volume(
2234 &dex_args(SummaryFormat::Text),
2235 &HealthThresholds::default(),
2236 &factory,
2237 &quiet(),
2238 )
2239 .await
2240 .unwrap();
2241 assert_eq!(volume, Some(777.0));
2242 assert_eq!(trades.unwrap_err(), "a DEX pool has no trade tape here");
2244 let depth = book.total_depth(scope::market::BookSide::Bid)
2245 + book.total_depth(scope::market::BookSide::Ask);
2246 assert!((depth - 50_000.0).abs() < 1.0, "depth {depth}");
2247 }
2248
2249 #[tokio::test]
2252 async fn test_venue_sol_reads_the_solana_dex() {
2253 let factory = dex_factory(&[(50_000.0, 777.0)]);
2254 let mut args = dex_args(SummaryFormat::Text);
2255 args.venue = "sol".to_string();
2256 let result =
2257 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet()).await;
2258 let (book, volume, _trades) = result.unwrap();
2259 assert_eq!(volume, Some(777.0));
2260 assert_eq!(book.pair, "USDC/USD");
2261 }
2262
2263 #[tokio::test]
2266 async fn test_chain_must_match_the_dex_venue() {
2267 let factory = dex_factory(&[(50_000.0, 1.0)]);
2268 let mut args = dex_args(SummaryFormat::Text);
2269 args.chain = Some("Solana".to_string());
2270 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2271 .await
2272 .unwrap_err();
2273 assert!(err.to_string().contains("reads chain ethereum"), "{err}");
2274
2275 args.chain = Some("ETH".to_string());
2276 assert!(
2277 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2278 .await
2279 .is_ok()
2280 );
2281 }
2282
2283 #[tokio::test]
2284 async fn test_unknown_venue_is_an_error() {
2285 let factory = dex_factory(&[(50_000.0, 1.0)]);
2286 let mut args = dex_args(SummaryFormat::Text);
2287 args.venue = "polygon".to_string();
2288 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2289 .await
2290 .unwrap_err();
2291 assert!(
2292 err.to_string().contains("Unknown venue \"polygon\""),
2293 "{err}"
2294 );
2295 }
2296
2297 #[tokio::test]
2298 async fn test_dex_no_pairs_is_error() {
2299 let factory = dex_factory(&[]);
2300 let err = fetch_book_and_volume(
2301 &dex_args(SummaryFormat::Text),
2302 &HealthThresholds::default(),
2303 &factory,
2304 &quiet(),
2305 )
2306 .await
2307 .unwrap_err();
2308 assert!(err.to_string().contains("No DEX pairs found"), "{err}");
2309 }
2310
2311 #[tokio::test]
2312 async fn test_run_summary_once_json_reflects_resolved_thresholds() {
2313 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2314 let mut args = dex_args(SummaryFormat::Json);
2315 args.health = HealthArgs {
2317 min_levels: Some(1),
2318 min_top10_depth: Some(1.0),
2319 ..Default::default()
2320 };
2321 let thresholds = args.health.resolve(&Config::default());
2322 let (summary, _) = summary_run(&args, &factory, &thresholds, &[], Some(1), &quiet())
2323 .await
2324 .unwrap();
2325 assert!(summary.healthy, "{:?}", summary.checks);
2326
2327 let strict = HealthThresholds::default();
2328 let (summary, _) = summary_run(&args, &factory, &strict, &[], None, &quiet())
2329 .await
2330 .unwrap();
2331 assert!(!summary.healthy);
2332 }
2333
2334 #[tokio::test]
2335 async fn test_dex_json_shows_amm_source_and_na_checks() {
2336 let mut factory = dex_factory(&[(20_000_000.0, 1_000.0)]);
2338 factory.mock_dex.token_data.as_mut().unwrap().pairs[0].liquidity_base = Some(10_000_000.0);
2339 let args = dex_args(SummaryFormat::Json);
2340 let thresholds = args.health.resolve(&Config::default());
2341 let (summary, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
2342 .await
2343 .unwrap();
2344 assert!(summary.healthy, "{:?}", summary.checks);
2345 let v: serde_json::Value = serde_json::from_str(&report).unwrap();
2346 assert_eq!(v["book_source"], "synthetic_amm");
2347 let statuses: Vec<&str> = v["checks"]
2348 .as_array()
2349 .unwrap()
2350 .iter()
2351 .map(|c| c["status"].as_str().unwrap())
2352 .collect();
2353 assert_eq!(
2356 statuses.iter().filter(|s| **s == "n/a").count(),
2357 4,
2358 "{:?}",
2359 statuses
2360 );
2361 assert!(!statuses.contains(&"fail"));
2362 assert_eq!(
2363 v["checks"].as_array().unwrap().last().unwrap()["message"],
2364 "Bot patterns: not checked (a DEX pool has no trade tape here)"
2365 );
2366 assert_eq!(v["bot_patterns"]["book_checked"], false);
2367 }
2368
2369 #[tokio::test]
2370 async fn test_dex_without_reserves_says_estimate() {
2371 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2372 let args = dex_args(SummaryFormat::Text);
2373 let thresholds = args.health.resolve(&Config::default());
2374 let (_, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
2375 .await
2376 .unwrap();
2377 assert!(report.contains("pool reserves not reported"), "{report}");
2378 }
2379
2380 #[tokio::test]
2381 async fn test_run_summary_one_shot_writes_report() {
2382 let dir = tempfile::tempdir().unwrap();
2383 let report = dir.path().join("report.md");
2384 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2385 let mut args = dex_args(SummaryFormat::Text);
2386 args.report = Some(report.clone());
2387 run_summary(args, &Config::default(), &factory, &quiet())
2388 .await
2389 .unwrap();
2390 let md = std::fs::read_to_string(&report).unwrap();
2391 assert!(md.contains("Market Health Report"), "{md}");
2392 assert!(md.contains(USDC_ADDR), "{md}");
2393 }
2394
2395 #[tokio::test(start_paused = true)]
2398 async fn test_run_summary_repeat_mode_writes_csv_rows_and_report() {
2399 let dir = tempfile::tempdir().unwrap();
2400 let csv = dir.path().join("series.csv");
2401 let report = dir.path().join("final.md");
2402 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2403 let mut args = dex_args(SummaryFormat::Json);
2404 args.every = Some("1s".to_string());
2405 args.duration = Some("1s".to_string());
2406 args.csv = Some(csv.clone());
2407 args.report = Some(report.clone());
2408 run_summary(args, &Config::default(), &factory, &quiet())
2409 .await
2410 .unwrap();
2411
2412 let body = std::fs::read_to_string(&csv).unwrap();
2413 let mut lines = body.lines();
2414 let header = lines.next().unwrap();
2416 assert!(
2417 header.starts_with(
2418 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy,"
2419 ),
2420 "{header}"
2421 );
2422 assert!(
2423 header.contains("spread_pct") && header.ends_with("ask_top10_depth"),
2424 "{header}"
2425 );
2426 let rows: Vec<_> = lines.collect();
2428 assert_eq!(rows.len(), 2, "{body}");
2429 assert!(rows[0].split(',').nth(1) == Some("1"));
2430 assert!(rows[1].split(',').nth(1) == Some("2"));
2431 assert!(report.exists());
2432 }
2433
2434 #[tokio::test(start_paused = true)]
2437 async fn test_run_summary_repeat_publishes_run_progress() {
2438 let (out, captured) = Output::capture_merged();
2441 let hook = out.progress_hook().expect("a merged capture has a hook");
2442 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2443 let mut args = dex_args(SummaryFormat::Json);
2444 args.every = Some("1s".to_string());
2445 args.duration = Some("2s".to_string());
2446 run_summary(args, &Config::default(), &factory, &out)
2447 .await
2448 .unwrap();
2449 let p = hook.lock().unwrap().clone();
2450 assert_eq!(p.total, Some(3), "{p:?}");
2452 assert_eq!(p.run, 3, "{p:?}");
2453 assert_eq!(p.next_at, None, "no next run after the last: {p:?}");
2454 assert!(captured.out().contains("every 1s for 2s"));
2455 assert!(captured.out().contains("Completed 3 run(s)"));
2456 }
2457
2458 #[tokio::test]
2459 async fn test_a_failed_run_ends_a_repeating_summary() {
2460 let (out, _captured) = Output::capture_merged();
2463 let hook = out.progress_hook().unwrap();
2464 let factory = dex_factory(&[]);
2465 let mut args = dex_args(SummaryFormat::Json);
2466 args.every = Some("1s".to_string());
2467 args.duration = Some("30s".to_string());
2468 let started = std::time::Instant::now();
2469 let result = run_summary(args, &Config::default(), &factory, &out).await;
2470 assert!(result.is_err(), "no DEX pairs is a failed run");
2471 assert_eq!(hook.lock().unwrap().run, 1, "no second run after the error");
2472 assert!(
2473 started.elapsed() < Duration::from_secs(5),
2474 "it did not sleep on"
2475 );
2476 }
2477}