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 });
584 return write_report(&args, &summary, out);
585 };
586
587 note!(
588 out,
589 "Running market summary every {}s {} (Ctrl+C to stop)\n",
590 schedule.every.as_secs(),
591 match schedule.duration {
592 Some(d) => format!("for {}s", d.as_secs()),
593 None => "until cancelled".to_string(),
594 }
595 );
596 let metrics = series::summary_metrics(&thresholds);
597 let last = repeat::run(&schedule, &metrics, args.csv.as_deref(), out, |n| {
598 let (args, thresholds, metrics, tokens) = (&args, &thresholds, &metrics, &tokens);
599 async move {
600 let (summary, report) =
601 summary_run(args, factory, thresholds, tokens, Some(n), out).await?;
602 Ok(RunOutput {
603 sample: series::sample(&summary, metrics),
604 snapshot: report,
605 view: Some(SummaryView {
606 summary: summary.clone(),
607 venue: args.venue.clone(),
608 }),
609 value: summary,
610 })
611 }
612 })
613 .await?;
614
615 if let Some(summary) = last {
616 write_report(&args, &summary, out)?;
617 }
618 if let Some(ref csv_path) = args.csv {
619 out.note_saved("Time-series CSV", csv_path.display());
620 }
621 Ok(())
622}
623
624async fn run_ohlc(args: OhlcArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
630 let json = matches!(args.format, OhlcFormat::Json);
631 check_venue_token(
632 &args.venue,
633 &args.pair,
634 None,
635 args.token_contract.clone(),
636 json,
637 http.clone(),
638 out,
639 )
640 .await?;
641 let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
642 run_ohlc_with(&client, args, out).await
643}
644
645async fn run_ohlc_with(client: &ExchangeClient, args: OhlcArgs, out: &Output) -> Result<()> {
647 let pair = client.venue_symbol(&Pair::parse(&args.pair));
648
649 let candles = client.fetch_ohlc(&pair, &args.interval, args.limit).await?;
650
651 match args.format {
652 OhlcFormat::Json => {
653 let json_str = serde_json::to_string_pretty(&candles)
654 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
655 outln!(out, "{json_str}")?;
656 }
657 OhlcFormat::Text => {
658 outln!(
659 out,
660 "{}",
661 t::section_header(&format!("OHLC — {} ({})", pair, args.venue))
662 )?;
663 outln!(out, "{}", t::kv_row("Interval", &args.interval))?;
664 outln!(out, "{}", t::kv_row("Limit", &args.limit.to_string()))?;
665
666 let cols = [
667 t::Col {
668 label: "Open Time",
669 width: 19,
670 align: '>',
671 },
672 t::Col {
673 label: "Open",
674 width: 12,
675 align: '>',
676 },
677 t::Col {
678 label: "High",
679 width: 12,
680 align: '>',
681 },
682 t::Col {
683 label: "Low",
684 width: 12,
685 align: '>',
686 },
687 t::Col {
688 label: "Close",
689 width: 12,
690 align: '>',
691 },
692 t::Col {
693 label: "Volume",
694 width: 14,
695 align: '>',
696 },
697 ];
698 outln!(out, "{}", t::table_header(&cols))?;
699
700 for c in &candles {
701 let dt = chrono::DateTime::from_timestamp_millis(c.open_time as i64)
702 .map(|d| d.format("%Y-%m-%d %H:%M").to_string())
703 .unwrap_or_else(|| format!("{}", c.open_time));
704 let open_str = format!("{:.6}", c.open);
705 let high_str = format!("{:.6}", c.high);
706 let low_str = format!("{:.6}", c.low);
707 let close_str = format!("{:.6}", c.close);
708 let volume_str = format!("{:.2}", c.volume);
709 let values = [
710 dt.as_str(),
711 open_str.as_str(),
712 high_str.as_str(),
713 low_str.as_str(),
714 close_str.as_str(),
715 volume_str.as_str(),
716 ];
717 outln!(out, "{}", t::table_row(&cols, &values))?;
718 }
719
720 outln!(
721 out,
722 "{}",
723 t::info_row(&format!("{} candles returned", candles.len()))
724 )?;
725 outln!(out, "{}", t::section_footer())?;
726 }
727 }
728 Ok(())
729}
730
731async fn run_trades(args: TradesArgs, http: Arc<dyn HttpClient>, out: &Output) -> Result<()> {
737 let json = matches!(args.format, OhlcFormat::Json);
738 check_venue_token(
739 &args.venue,
740 &args.pair,
741 None,
742 args.token_contract.clone(),
743 json,
744 http.clone(),
745 out,
746 )
747 .await?;
748 let client = VenueRegistry::load()?.create_exchange_client_with_http(&args.venue, http)?;
749 run_trades_with(&client, args, out).await
750}
751
752async fn run_trades_with(client: &ExchangeClient, args: TradesArgs, out: &Output) -> Result<()> {
754 let pair = client.venue_symbol(&Pair::parse(&args.pair));
755
756 let trades = client.fetch_recent_trades(&pair, args.limit).await?;
757
758 match args.format {
759 OhlcFormat::Json => {
760 let json_str = serde_json::to_string_pretty(&trades)
761 .map_err(|e| ScopeError::Chain(format!("JSON serialization failed: {e}")))?;
762 outln!(out, "{json_str}")?;
763 }
764 OhlcFormat::Text => {
765 outln!(
766 out,
767 "{}",
768 t::section_header(&format!("Recent Trades — {} ({})", pair, args.venue))
769 )?;
770
771 let cols = [
772 t::Col {
773 label: "Time",
774 width: 10,
775 align: '>',
776 },
777 t::Col {
778 label: "Side",
779 width: 5,
780 align: '>',
781 },
782 t::Col {
783 label: "Price",
784 width: 12,
785 align: '>',
786 },
787 t::Col {
788 label: "Qty",
789 width: 12,
790 align: '>',
791 },
792 ];
793 outln!(out, "{}", t::table_header(&cols))?;
794
795 for t in &trades {
796 let time = chrono::DateTime::from_timestamp_millis(t.timestamp_ms as i64)
797 .map(|d| d.format("%H:%M:%S").to_string())
798 .unwrap_or_else(|| "?".to_string());
799 let side_str = match t.side {
800 scope::market::TradeSide::Buy => "BUY",
801 scope::market::TradeSide::Sell => "SELL",
802 };
803 let price_str = format!("{:.6}", t.price);
804 let qty_str = format!("{:.2}", t.quantity);
805 let values = [
806 time.as_str(),
807 side_str,
808 price_str.as_str(),
809 qty_str.as_str(),
810 ];
811 outln!(out, "{}", t::table_row(&cols, &values))?;
812 }
813
814 outln!(
815 out,
816 "{}",
817 t::info_row(&format!("{} trades returned", trades.len()))
818 )?;
819 outln!(out, "{}", t::section_footer())?;
820 }
821 }
822 Ok(())
823}
824
825#[cfg(test)]
826mod tests {
827 use super::*;
828 use scope::chains::DefaultClientFactory;
829 use scope::market::DexVenue;
830
831 fn quiet() -> crate::cli::output::Output {
832 crate::cli::output::Output::capture().0
833 }
834
835 #[allow(dead_code)]
839 fn setup_mock_venue(server_url: &str) -> (String, tempfile::TempDir) {
840 let venue_id = format!("test_mock_{}", std::process::id());
841 let yaml = format!(
842 r#"
843id: {venue_id}
844name: Test Mock Venue
845base_url: {server_url}
846timeout_secs: 5
847symbol:
848 template: "{{base}}_{{quote}}"
849 default_quote: USDT
850capabilities:
851 order_book:
852 path: /api/v1/depth
853 params:
854 symbol: "{{pair}}"
855 response:
856 asks_key: asks
857 bids_key: bids
858 level_format: positional
859 ticker:
860 path: /api/v1/ticker
861 params:
862 symbol: "{{pair}}"
863 response:
864 last_price: last
865 volume_24h: vol
866"#
867 );
868 let dir = tempfile::tempdir().unwrap();
869 let file_path = dir.path().join(format!("{}.yaml", venue_id));
870 std::fs::write(&file_path, yaml).unwrap();
871 (venue_id, dir)
874 }
875
876 #[tokio::test]
877 async fn test_run_summary_with_mock_orderbook() {
878 let args = SummaryArgs {
882 pair: "USDC".to_string(),
883 venue: "eth".to_string(),
884 token_contract: None,
885 chain: None,
886 health: HealthArgs {
887 peg_target: Some(1.0),
888 peg_range: Some(0.01),
889 min_levels: Some(1),
890 min_depth: Some(50.0),
891 min_bid_ask_ratio: Some(0.1),
892 max_bid_ask_ratio: Some(10.0),
893 ..Default::default()
894 },
895 format: SummaryFormat::Text,
896 every: None,
897 duration: None,
898 report: None,
899 csv: None,
900 };
901
902 let http: std::sync::Arc<dyn scope::http::HttpClient> =
903 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
904 let factory = DefaultClientFactory {
905 chains_config: Default::default(),
906 http,
907 };
908 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
910 }
912
913 #[tokio::test]
914 async fn test_run_summary_json_format() {
915 let args = SummaryArgs {
916 pair: "USDC".to_string(),
917 venue: "eth".to_string(),
918 token_contract: None,
919 chain: None,
920 health: HealthArgs {
921 peg_target: Some(1.0),
922 peg_range: Some(0.01),
923 min_levels: Some(1),
924 min_depth: Some(50.0),
925 min_bid_ask_ratio: Some(0.1),
926 max_bid_ask_ratio: Some(10.0),
927 ..Default::default()
928 },
929 format: SummaryFormat::Json,
930 every: None,
931 duration: None,
932 report: None,
933 csv: None,
934 };
935
936 let http: std::sync::Arc<dyn scope::http::HttpClient> =
937 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
938 let factory = DefaultClientFactory {
939 chains_config: Default::default(),
940 http,
941 };
942 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
943 }
944
945 #[test]
946 fn test_duration_forever_has_no_end_and_the_default_stays_one_hour() {
947 let args = |duration: Option<&str>| SummaryArgs {
950 pair: "USDC".to_string(),
951 venue: "binance".to_string(),
952 token_contract: None,
953 chain: None,
954 health: HealthArgs::default(),
955 format: SummaryFormat::Text,
956 every: Some("30s".to_string()),
957 duration: duration.map(str::to_string),
958 report: None,
959 csv: None,
960 };
961 for word in ["forever", "inf", "FOREVER"] {
962 let s = summary_schedule(&args(Some(word))).unwrap().unwrap();
963 assert_eq!(s.duration, None, "{word}");
964 }
965 let s = summary_schedule(&args(None)).unwrap().unwrap();
966 assert_eq!(s.duration, Some(Duration::from_secs(DEFAULT_DURATION_SECS)));
967 let s = summary_schedule(&args(Some("10m"))).unwrap().unwrap();
968 assert_eq!(s.duration, Some(Duration::from_secs(600)));
969 }
970
971 #[test]
972 fn test_parse_duration_seconds() {
973 assert_eq!(parse_duration("30s").unwrap(), 30);
974 assert!(
975 parse_duration("forever").is_err(),
976 "not a span; --duration only"
977 );
978 assert_eq!(parse_duration("1").unwrap(), 1);
979 assert_eq!(parse_duration("60sec").unwrap(), 60);
980 }
981
982 #[test]
983 fn test_parse_duration_minutes() {
984 assert_eq!(parse_duration("5m").unwrap(), 300);
985 assert_eq!(parse_duration("1min").unwrap(), 60);
986 assert_eq!(parse_duration("2.5m").unwrap(), 150);
987 }
988
989 #[test]
990 fn test_parse_duration_hours() {
991 assert_eq!(parse_duration("1h").unwrap(), 3600);
992 assert_eq!(parse_duration("24h").unwrap(), 86400);
993 }
994
995 #[test]
996 fn test_parse_duration_invalid() {
997 assert!(parse_duration("").is_err());
998 assert!(parse_duration("abc").is_err());
999 assert!(parse_duration("30x").is_err());
1000 }
1001
1002 #[test]
1003 fn test_parse_duration_unknown_unit_error_message() {
1004 let result = parse_duration("30z");
1005 assert!(result.is_err());
1006 let err = result.unwrap_err();
1007 assert!(err.to_string().contains("Unknown duration unit"));
1008 assert!(err.to_string().contains("z"));
1009 }
1010
1011 #[test]
1012 fn test_parse_duration_invalid_number_error() {
1013 let result = parse_duration("abc30s");
1014 assert!(result.is_err());
1015 }
1016
1017 #[test]
1018 fn test_parse_duration_non_positive() {
1019 assert!(parse_duration("0").is_err());
1020 assert!(parse_duration("-5s").is_err());
1021 }
1022
1023 #[test]
1028 fn test_base_symbol_from_pair_underscore() {
1029 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1030 assert_eq!(Pair::parse("USDC_USDT").base, "USDC");
1031 }
1032
1033 #[test]
1034 fn test_base_symbol_from_pair_lowercase_underscore() {
1035 assert_eq!(Pair::parse("dai_usdt").base, "dai");
1036 }
1037
1038 #[test]
1039 fn test_base_symbol_from_pair_slash() {
1040 assert_eq!(Pair::parse("USDC/USDT").base, "USDC");
1041 }
1042
1043 #[test]
1044 fn test_base_symbol_from_pair_concat() {
1045 assert_eq!(Pair::parse("USDCUSDT").base, "USDC");
1046 assert_eq!(Pair::parse("DAIUSDT").base, "DAI");
1047 }
1048
1049 #[test]
1050 fn test_base_symbol_from_pair_plain() {
1051 assert_eq!(Pair::parse("USDC").base, "USDC");
1052 assert_eq!(Pair::parse("ETH").base, "ETH");
1053 }
1054
1055 #[test]
1056 fn test_base_symbol_from_pair_whitespace() {
1057 assert_eq!(Pair::parse(" DAI_USDT ").base, "DAI");
1058 }
1059
1060 #[test]
1061 fn test_normalize_pair_input_key_value() {
1062 assert_eq!(Pair::parse("pair=USDT_PUSD").to_string(), "USDT/PUSD");
1063 assert_eq!(
1064 Pair::parse("pair_symbol=USDT_PUSD").to_string(),
1065 "USDT/PUSD"
1066 );
1067 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1068 }
1069
1070 #[test]
1071 fn test_parse_pair_components_delimited() {
1072 assert_eq!(Pair::parse("USDT_PUSD").to_string(), "USDT/PUSD");
1073 assert_eq!(Pair::parse("ETH/USDC").to_string(), "ETH/USDC");
1074 assert_eq!(Pair::parse("BTC-USDT").to_string(), "BTC/USDT");
1075 }
1076
1077 #[test]
1078 fn test_parse_pair_components_concat_common_quote() {
1079 assert_eq!(Pair::parse("BTCUSDT").to_string(), "BTC/USDT");
1080 assert_eq!(Pair::parse("ETHUSD").to_string(), "ETH/USD");
1081 assert_eq!(Pair::parse("ARB").quote, None);
1082 }
1083
1084 #[test]
1089 fn test_market_summary_to_markdown_basic() {
1090 use scope::market::{HealthCheck, MarketSummary};
1091 let summary = MarketSummary {
1092 pair: "USDCUSDT".to_string(),
1093 source: scope::market::BookSource::Exchange,
1094 peg_target: 1.0,
1095 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1096 best_bid: Some(0.9999),
1097 best_ask: Some(1.0001),
1098 mid_price: Some(1.0000),
1099 spread: Some(0.0002),
1100 spread_pct: None,
1101 top_depth: Default::default(),
1102 volume_24h: Some(1_000_000.0),
1103 bid_depth: 50_000.0,
1104 ask_depth: 50_000.0,
1105 bid_outliers: 0,
1106 ask_outliers: 0,
1107 healthy: true,
1108 bot_patterns: None,
1109 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1110 execution_10k_buy: None,
1111 execution_10k_sell: None,
1112 asks: vec![],
1113 bids: vec![],
1114 };
1115 let md = market_summary_to_markdown(&summary, "Binance", "USDCUSDT");
1116 assert!(md.contains("Market Health Report"));
1117 assert!(md.contains("USDCUSDT"));
1118 assert!(md.contains("Binance"));
1119 assert!(md.contains("Peg Target"));
1120 assert!(md.contains("1.0000"));
1121 assert!(md.contains("Healthy"));
1122 }
1123
1124 #[test]
1125 fn test_market_summary_to_markdown_no_prices() {
1126 use scope::market::{HealthCheck, MarketSummary};
1127 let summary = MarketSummary {
1128 pair: "TESTUSDT".to_string(),
1129 source: scope::market::BookSource::Exchange,
1130 peg_target: 1.0,
1131 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1132 best_bid: None,
1133 best_ask: None,
1134 mid_price: None,
1135 spread: None,
1136 spread_pct: None,
1137 top_depth: Default::default(),
1138 volume_24h: None,
1139 bid_depth: 0.0,
1140 ask_depth: 0.0,
1141 bid_outliers: 0,
1142 ask_outliers: 0,
1143 healthy: false,
1144 bot_patterns: None,
1145 checks: vec![HealthCheck::Fail("No data".to_string())],
1146 execution_10k_buy: None,
1147 execution_10k_sell: None,
1148 asks: vec![],
1149 bids: vec![],
1150 };
1151 let md = market_summary_to_markdown(&summary, "Test", "TESTUSDT");
1152 assert!(md.contains("Market Health Report"));
1153 assert!(md.contains("-")); }
1155
1156 #[test]
1161 fn test_parse_duration_days() {
1162 assert_eq!(parse_duration("1d").unwrap(), 86400);
1163 assert_eq!(parse_duration("7d").unwrap(), 604800);
1164 assert_eq!(parse_duration("1day").unwrap(), 86400);
1165 assert_eq!(parse_duration("2days").unwrap(), 172800);
1166 }
1167
1168 #[test]
1169 fn test_parse_duration_long_names() {
1170 assert_eq!(parse_duration("30seconds").unwrap(), 30);
1171 assert_eq!(parse_duration("5minutes").unwrap(), 300);
1172 assert_eq!(parse_duration("2hours").unwrap(), 7200);
1173 }
1174
1175 #[test]
1176 fn test_parse_duration_fractional() {
1177 assert_eq!(parse_duration("0.5h").unwrap(), 1800);
1178 assert_eq!(parse_duration("1.5m").unwrap(), 90);
1179 }
1180
1181 #[test]
1186 fn test_summary_format_default() {
1187 let fmt = SummaryFormat::default();
1188 assert!(matches!(fmt, SummaryFormat::Text));
1189 }
1190
1191 #[test]
1192 fn test_summary_format_debug() {
1193 let text = format!("{:?}", SummaryFormat::Text);
1194 assert_eq!(text, "Text");
1195 let json = format!("{:?}", SummaryFormat::Json);
1196 assert_eq!(json, "Json");
1197 }
1198
1199 #[test]
1204 fn test_ohlc_args_deserialization() {
1205 use crate::cli::{Cli, Commands};
1206 use clap::Parser;
1207 let cli = Cli::try_parse_from([
1208 "scope",
1209 "market",
1210 "ohlc",
1211 "USDC",
1212 "--venue",
1213 "binance",
1214 "--interval",
1215 "1h",
1216 "--limit",
1217 "50",
1218 ])
1219 .unwrap();
1220 if let Commands::Market(MarketCommands::Ohlc(args)) = cli.command {
1221 assert_eq!(args.pair, "USDC");
1222 assert_eq!(args.venue, "binance");
1223 assert_eq!(args.interval, "1h");
1224 assert_eq!(args.limit, 50);
1225 } else {
1226 panic!("Expected Market Ohlc command");
1227 }
1228 }
1229
1230 #[test]
1231 fn test_trades_args_deserialization() {
1232 use crate::cli::{Cli, Commands};
1233 use clap::Parser;
1234 let cli = Cli::try_parse_from([
1235 "scope", "market", "trades", "BTC", "--venue", "mexc", "--limit", "100",
1236 ])
1237 .unwrap();
1238 if let Commands::Market(MarketCommands::Trades(args)) = cli.command {
1239 assert_eq!(args.pair, "BTC");
1240 assert_eq!(args.venue, "mexc");
1241 assert_eq!(args.limit, 100);
1242 } else {
1243 panic!("Expected Market Trades command");
1244 }
1245 }
1246
1247 #[test]
1248 fn test_base_symbol_from_pair_various_inputs() {
1249 assert_eq!(Pair::parse("USDC").base, "USDC");
1251 assert_eq!(Pair::parse("BTCUSDT").base, "BTC");
1252 assert_eq!(Pair::parse("ETH/USDT").base, "ETH");
1253 assert_eq!(Pair::parse("DAI_USDT").base, "DAI");
1254 assert_eq!(Pair::parse("X").base, "X"); assert_eq!(Pair::parse("").base, "");
1256 }
1257
1258 #[test]
1259 fn test_summary_args_debug() {
1260 let args = SummaryArgs {
1261 pair: "USDC".to_string(),
1262 venue: "binance".to_string(),
1263 token_contract: None,
1264 chain: None,
1265 health: HealthArgs {
1266 peg_target: Some(1.0),
1267 peg_range: Some(0.001),
1268 min_levels: Some(6),
1269 min_depth: Some(3000.0),
1270 min_bid_ask_ratio: Some(0.2),
1271 max_bid_ask_ratio: Some(5.0),
1272 ..Default::default()
1273 },
1274 format: SummaryFormat::Text,
1275 every: None,
1276 duration: None,
1277 report: None,
1278 csv: None,
1279 };
1280 let debug = format!("{:?}", args);
1281 assert!(debug.contains("SummaryArgs"));
1282 assert!(debug.contains("USDC"));
1283 }
1284
1285 #[test]
1286 fn test_default_constants() {
1287 assert_eq!(DEFAULT_EVERY_SECS, 60);
1288 assert_eq!(DEFAULT_DURATION_SECS, 3600);
1289 }
1290
1291 #[test]
1292 fn test_markdown_leaves_out_the_peg_deviation_when_there_is_none() {
1293 use scope::market::{BookSource, OrderBook, OrderBookLevel};
1295 let level = |price| OrderBookLevel {
1296 price,
1297 quantity: 100.0,
1298 };
1299 let book = OrderBook {
1300 pair: "USDC/USDT".to_string(),
1301 source: BookSource::Exchange,
1302 bids: vec![level(0.9999)],
1303 asks: vec![level(1.0001)],
1304 };
1305 let thresholds = HealthThresholds {
1306 peg_target: 0.0,
1307 ..HealthThresholds::default()
1308 };
1309 let summary = MarketSummary::from_order_book(&book, &thresholds, None);
1310 let md = market_summary_to_markdown(&summary, "binance", "USDC");
1311 assert!(md.contains("0.9999") && !md.contains("0.9999 ("), "{md}");
1312 assert!(!md.contains("(+0.000%)"), "{md}");
1313 }
1314
1315 #[test]
1316 fn test_market_summary_to_markdown_with_execution_estimates() {
1317 use scope::market::{ExecutionEstimate, ExecutionSide, HealthCheck, MarketSummary};
1318 let summary = MarketSummary {
1319 pair: "TESTUSDT".to_string(),
1320 source: scope::market::BookSource::Exchange,
1321 peg_target: 1.0,
1322 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1323 best_bid: Some(0.9999),
1324 best_ask: Some(1.0001),
1325 mid_price: Some(1.0000),
1326 spread: Some(0.0002),
1327 spread_pct: None,
1328 top_depth: Default::default(),
1329 volume_24h: Some(1_000_000.0),
1330 bid_depth: 50_000.0,
1331 ask_depth: 50_000.0,
1332 bid_outliers: 0,
1333 ask_outliers: 0,
1334 healthy: true,
1335 bot_patterns: None,
1336 checks: vec![HealthCheck::Pass("Spread within range".to_string())],
1337 execution_10k_buy: Some(ExecutionEstimate {
1338 notional_usdt: 10_000.0,
1339 side: ExecutionSide::Buy,
1340 vwap: 1.0001,
1341 slippage_bps: 1.5,
1342 fillable: true,
1343 }),
1344 execution_10k_sell: Some(ExecutionEstimate {
1345 notional_usdt: 10_000.0,
1346 side: ExecutionSide::Sell,
1347 vwap: 0.0,
1348 slippage_bps: 0.0,
1349 fillable: false,
1350 }),
1351 asks: vec![],
1352 bids: vec![],
1353 };
1354 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1355 assert!(md.contains("Market Health Report"));
1356 assert!(md.contains("TESTUSDT"));
1357 assert!(md.contains("TestVenue"));
1358 assert!(md.contains("1.50 bps"));
1360 assert!(md.contains("insufficient"));
1362 }
1363
1364 #[tokio::test]
1365 async fn test_run_with_summary_command() {
1366 let args = MarketCommands::Summary(SummaryArgs {
1368 pair: "USDC".to_string(),
1369 venue: "eth".to_string(),
1370 token_contract: None,
1371 chain: None,
1372 health: HealthArgs {
1373 peg_target: Some(1.0),
1374 peg_range: Some(0.01),
1375 min_levels: Some(1),
1376 min_depth: Some(50.0),
1377 min_bid_ask_ratio: Some(0.1),
1378 max_bid_ask_ratio: Some(10.0),
1379 ..Default::default()
1380 },
1381 format: SummaryFormat::Text,
1382 every: None,
1383 duration: None,
1384 report: None,
1385 csv: None,
1386 });
1387
1388 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1389 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1390 let factory = DefaultClientFactory {
1391 chains_config: Default::default(),
1392 http,
1393 };
1394 let config = Config::default();
1395 let _result = run(args, &config, &factory, &quiet()).await;
1396 }
1398
1399 #[test]
1400 fn test_is_dex_venue() {
1401 assert!(DexVenue::parse("eth").is_some());
1402 assert!(DexVenue::parse("ethereum").is_some());
1403 assert!(DexVenue::parse("Ethereum").is_some());
1404 assert!(DexVenue::parse("solana").is_some());
1405 assert!(DexVenue::parse("Solana").is_some());
1406 assert!(DexVenue::parse("binance").is_none());
1407 assert!(DexVenue::parse("okx").is_none());
1408 assert!(DexVenue::parse("mexc").is_none());
1409 }
1410
1411 #[test]
1412 fn test_dex_venue_to_chain() {
1413 assert_eq!(
1414 DexVenue::parse("eth").map(DexVenue::chain),
1415 Some("ethereum")
1416 );
1417 assert_eq!(
1418 DexVenue::parse("ethereum").map(DexVenue::chain),
1419 Some("ethereum")
1420 );
1421 assert_eq!(
1422 DexVenue::parse("Ethereum").map(DexVenue::chain),
1423 Some("ethereum")
1424 );
1425 assert_eq!(
1426 DexVenue::parse("solana").map(DexVenue::chain),
1427 Some("solana")
1428 );
1429 }
1430
1431 #[test]
1432 fn test_venue_registry_loaded_in_cex_path() {
1433 let registry = VenueRegistry::load().unwrap();
1435 assert!(registry.contains("binance"));
1436 let client =
1437 registry.create_exchange_client_with_http("binance", scope::http::test_transport());
1438 assert!(client.is_ok());
1439 }
1440
1441 #[test]
1442 fn test_venue_registry_error_for_unknown() {
1443 let registry = VenueRegistry::load().unwrap();
1444 let result =
1445 registry.create_exchange_client_with_http("kracken", scope::http::test_transport());
1446 assert!(result.is_err());
1447 let err = result.unwrap_err().to_string();
1448 assert!(err.contains("Unknown venue"));
1449 assert!(err.contains("Did you mean")); }
1451
1452 #[tokio::test]
1453 async fn test_run_summary_json_format_with_mock() {
1454 let args = SummaryArgs {
1455 pair: "USDC".to_string(),
1456 venue: "eth".to_string(),
1457 token_contract: None,
1458 chain: None,
1459 health: HealthArgs {
1460 peg_target: Some(1.0),
1461 peg_range: Some(0.01),
1462 min_levels: Some(1),
1463 min_depth: Some(50.0),
1464 min_bid_ask_ratio: Some(0.1),
1465 max_bid_ask_ratio: Some(10.0),
1466 ..Default::default()
1467 },
1468 format: SummaryFormat::Json,
1469 every: None,
1470 duration: None,
1471 report: None,
1472 csv: None,
1473 };
1474
1475 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1476 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1477 let factory = DefaultClientFactory {
1478 chains_config: Default::default(),
1479 http,
1480 };
1481 let _result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1482 }
1483
1484 #[test]
1489 fn test_ohlc_format_default() {
1490 let fmt: OhlcFormat = Default::default();
1491 assert_eq!(fmt, OhlcFormat::Text);
1492 }
1493
1494 #[test]
1495 fn test_ohlc_format_display() {
1496 assert_eq!(format!("{:?}", OhlcFormat::Text), "Text");
1498 assert_eq!(format!("{:?}", OhlcFormat::Json), "Json");
1499 }
1500
1501 #[test]
1502 fn test_ohlc_args_default_values() {
1503 let args = OhlcArgs {
1505 pair: "BTC".to_string(),
1506 venue: "binance".to_string(),
1507 token_contract: None,
1508 interval: "1h".to_string(),
1509 limit: 100,
1510 format: OhlcFormat::Text,
1511 };
1512 assert_eq!(args.pair, "BTC");
1513 assert_eq!(args.venue, "binance");
1514 assert_eq!(args.interval, "1h");
1515 assert_eq!(args.limit, 100);
1516 }
1517
1518 #[test]
1519 fn test_trades_args_construction() {
1520 let args = TradesArgs {
1521 pair: "ETH".to_string(),
1522 venue: "okx".to_string(),
1523 token_contract: None,
1524 limit: 50,
1525 format: OhlcFormat::Json,
1526 };
1527 assert_eq!(args.pair, "ETH");
1528 assert_eq!(args.venue, "okx");
1529 assert_eq!(args.limit, 50);
1530 }
1531
1532 #[tokio::test]
1533 async fn test_run_ohlc_unknown_venue() {
1534 let args = OhlcArgs {
1535 pair: "BTC".to_string(),
1536 venue: "nonexistent_venue".to_string(),
1537 token_contract: None,
1538 interval: "1h".to_string(),
1539 limit: 10,
1540 format: OhlcFormat::Text,
1541 };
1542 let result = run_ohlc(args, scope::http::test_transport(), &quiet()).await;
1543 assert!(result.is_err());
1544 let err = result.unwrap_err().to_string();
1545 assert!(
1546 err.contains("Unknown venue"),
1547 "expected 'Unknown venue' error, got: {}",
1548 err
1549 );
1550 }
1551
1552 #[tokio::test]
1553 async fn test_run_trades_unknown_venue() {
1554 let args = TradesArgs {
1555 pair: "BTC".to_string(),
1556 venue: "nonexistent_venue".to_string(),
1557 token_contract: None,
1558 limit: 10,
1559 format: OhlcFormat::Text,
1560 };
1561 let result = run_trades(args, scope::http::test_transport(), &quiet()).await;
1562 assert!(result.is_err());
1563 let err = result.unwrap_err().to_string();
1564 assert!(
1565 err.contains("Unknown venue"),
1566 "expected 'Unknown venue' error, got: {}",
1567 err
1568 );
1569 }
1570
1571 #[tokio::test]
1572 async fn test_run_dispatches_ohlc() {
1573 let cmd = MarketCommands::Ohlc(OhlcArgs {
1574 pair: "BTC".to_string(),
1575 venue: "nonexistent_test_venue".to_string(),
1576 token_contract: None,
1577 interval: "1h".to_string(),
1578 limit: 5,
1579 format: OhlcFormat::Text,
1580 });
1581 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1582 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1583 let factory = DefaultClientFactory {
1584 chains_config: Default::default(),
1585 http,
1586 };
1587 let config = Config::default();
1588 let result = run(cmd, &config, &factory, &quiet()).await;
1589 assert!(result.is_err());
1591 }
1592
1593 #[tokio::test]
1594 async fn test_run_dispatches_trades() {
1595 let cmd = MarketCommands::Trades(TradesArgs {
1596 pair: "ETH".to_string(),
1597 venue: "nonexistent_test_venue".to_string(),
1598 token_contract: None,
1599 limit: 5,
1600 format: OhlcFormat::Json,
1601 });
1602 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1603 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1604 let factory = DefaultClientFactory {
1605 chains_config: Default::default(),
1606 http,
1607 };
1608 let config = Config::default();
1609 let result = run(cmd, &config, &factory, &quiet()).await;
1610 assert!(result.is_err());
1611 }
1612
1613 fn venue_with_coins(server: &mockito::ServerGuard) -> ExchangeClient {
1615 let mut desc = VenueRegistry::load()
1616 .unwrap()
1617 .get("binance")
1618 .unwrap()
1619 .clone();
1620 desc.base_url = server.url();
1621 desc.capabilities.assets =
1622 Some(serde_saphyr::from_str("path: /coins\nresponse: {}\n").unwrap());
1623 ExchangeClient::from_descriptor_with_http(&desc, scope::http::test_transport())
1624 }
1625
1626 async fn coins(server: &mut mockito::ServerGuard, body: &str) -> mockito::Mock {
1627 server
1628 .mock("GET", "/coins")
1629 .with_status(200)
1630 .with_body(body)
1631 .create_async()
1632 .await
1633 }
1634
1635 #[tokio::test]
1636 async fn test_a_cex_run_stops_when_the_saved_token_is_not_the_venues() {
1637 let mut server = mockito::Server::new_async().await;
1640 let _m = coins(
1641 &mut server,
1642 r#"[{"coin":"USDN","networkList":[{"network":"ETH","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1643 )
1644 .await;
1645 let client = venue_with_coins(&server);
1646 let mut store = scope::tokens::TokenAliases::default();
1647 store.add_binding(
1648 "USDN",
1649 "ethereum",
1650 "0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1651 "USDN",
1652 Some("binance"),
1653 );
1654 let err = check_cex_token(
1655 &client,
1656 "USDN",
1657 Some("ethereum".into()),
1658 None,
1659 &mut store,
1660 false,
1661 &quiet(),
1662 )
1663 .await
1664 .unwrap_err();
1665 assert!(matches!(err, ScopeError::TokenMismatch { .. }), "{err}");
1666 }
1667
1668 #[tokio::test]
1669 async fn test_a_cex_symbol_on_two_networks_stops_without_a_terminal() {
1670 let mut server = mockito::Server::new_async().await;
1671 let _m = coins(
1672 &mut server,
1673 r#"[{"coin":"USDN","networkList":[
1674 {"network":"ETH","contractAddress":"0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"},
1675 {"network":"BSC","contractAddress":"0xBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"}]}]"#,
1676 )
1677 .await;
1678 let client = venue_with_coins(&server);
1679 let mut store = scope::tokens::TokenAliases::default();
1680 let err = check_cex_token(&client, "USDN", None, None, &mut store, false, &quiet())
1681 .await
1682 .unwrap_err();
1683 assert!(matches!(err, ScopeError::AmbiguousToken { .. }), "{err}");
1684 assert!(
1686 check_cex_token(
1687 &client,
1688 "USDN",
1689 None,
1690 Some("0xAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".into()),
1691 &mut store,
1692 false,
1693 &quiet()
1694 )
1695 .await
1696 .is_ok()
1697 );
1698 }
1699
1700 fn binance_at(server: &mockito::ServerGuard) -> ExchangeClient {
1702 scope::market::testing::venue_at("binance", &server.url())
1703 }
1704
1705 const KLINES: &str = r#"[[1700000000000,"1.0","1.5","0.5","1.25","1000.5",1700003599999],
1706 [1700003600000,"1.25","2.0","1.0","1.75","20.25",1700007199999]]"#;
1707 const TRADES: &str = r#"[{"id":1,"price":"1.5","qty":"2.5","quoteQty":"3.75","time":1700000000000,"isBuyerMaker":true},
1708 {"id":2,"price":"1.6","qty":"1.0","quoteQty":"1.6","time":1700000001000,"isBuyerMaker":false}]"#;
1709
1710 fn ohlc_args(format: OhlcFormat) -> OhlcArgs {
1711 OhlcArgs {
1712 pair: "BTC".to_string(),
1713 venue: "binance".to_string(),
1714 token_contract: None,
1715 interval: "1h".to_string(),
1716 limit: 2,
1717 format,
1718 }
1719 }
1720
1721 fn trades_args(format: OhlcFormat) -> TradesArgs {
1722 TradesArgs {
1723 pair: "BTC".to_string(),
1724 venue: "binance".to_string(),
1725 token_contract: None,
1726 limit: 2,
1727 format,
1728 }
1729 }
1730
1731 async fn serve(path: &str, body: &str) -> mockito::ServerGuard {
1732 let mut server = mockito::Server::new_async().await;
1733 server
1734 .mock("GET", path)
1735 .match_query(mockito::Matcher::UrlEncoded(
1736 "symbol".into(),
1737 "BTCUSDT".into(),
1738 ))
1739 .with_body(body)
1740 .create_async()
1741 .await;
1742 server
1743 }
1744
1745 #[tokio::test]
1746 async fn test_ohlc_text_shows_every_candle() {
1747 let server = serve("/api/v3/klines", KLINES).await;
1748 let (out, cap) = Output::capture();
1749 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Text), &out)
1750 .await
1751 .unwrap();
1752 let text = crate::cli::ansi::strip(&cap.out());
1753 assert!(text.contains("OHLC — BTCUSDT (binance)"), "{text}");
1754 assert!(text.contains("1.250000"), "{text}");
1755 assert!(text.contains("1000.50"), "{text}");
1756 assert!(text.contains("2 candles returned"), "{text}");
1757 }
1758
1759 #[tokio::test]
1760 async fn test_ohlc_json_keeps_every_field() {
1761 let server = serve("/api/v3/klines", KLINES).await;
1762 let (out, cap) = Output::capture();
1763 run_ohlc_with(&binance_at(&server), ohlc_args(OhlcFormat::Json), &out)
1764 .await
1765 .unwrap();
1766 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1767 assert_eq!(v.as_array().unwrap().len(), 2);
1768 assert_eq!(v[0]["open_time"], 1_700_000_000_000u64);
1769 assert_eq!(v[1]["close"], 1.75);
1770 assert_eq!(v[1]["volume"], 20.25);
1771 }
1772
1773 #[tokio::test]
1774 async fn test_trades_text_shows_side_price_and_quantity() {
1775 let server = serve("/api/v3/trades", TRADES).await;
1776 let (out, cap) = Output::capture();
1777 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Text), &out)
1778 .await
1779 .unwrap();
1780 let text = crate::cli::ansi::strip(&cap.out());
1781 assert!(text.contains("Recent Trades — BTCUSDT (binance)"), "{text}");
1782 assert!(text.contains("SELL") && text.contains("BUY"), "{text}");
1784 assert!(text.contains("1.500000") && text.contains("2.50"), "{text}");
1785 assert!(text.contains("2 trades returned"), "{text}");
1786 }
1787
1788 #[tokio::test]
1789 async fn test_trades_json_keeps_every_field() {
1790 let server = serve("/api/v3/trades", TRADES).await;
1791 let (out, cap) = Output::capture();
1792 run_trades_with(&binance_at(&server), trades_args(OhlcFormat::Json), &out)
1793 .await
1794 .unwrap();
1795 let v: serde_json::Value = serde_json::from_str(&cap.out()).unwrap();
1796 assert_eq!(v[0]["side"], "sell");
1797 assert_eq!(v[1]["side"], "buy");
1798 assert_eq!(v[0]["id"], "1");
1799 assert_eq!(v[0]["quote_quantity"], 3.75);
1800 assert_eq!(v[1]["timestamp_ms"], 1_700_000_001_000u64);
1801 }
1802
1803 #[test]
1808 fn test_parse_duration_whitespace_empty() {
1809 assert!(parse_duration("").is_err());
1810 assert!(parse_duration(" ").is_err());
1811 }
1812
1813 #[test]
1814 fn test_parse_duration_sec_secs_second_seconds() {
1815 assert_eq!(parse_duration("1sec").unwrap(), 1);
1816 assert_eq!(parse_duration("2secs").unwrap(), 2);
1817 assert_eq!(parse_duration("1second").unwrap(), 1);
1818 assert_eq!(parse_duration("3seconds").unwrap(), 3);
1819 }
1820
1821 #[test]
1822 fn test_parse_duration_minute_minutes() {
1823 assert_eq!(parse_duration("1minute").unwrap(), 60);
1824 assert_eq!(parse_duration("2minutes").unwrap(), 120);
1825 }
1826
1827 #[test]
1828 fn test_parse_duration_hr_hrs_hour_hours() {
1829 assert_eq!(parse_duration("1hr").unwrap(), 3600);
1830 assert_eq!(parse_duration("2hrs").unwrap(), 7200);
1831 assert_eq!(parse_duration("1hour").unwrap(), 3600);
1832 assert_eq!(parse_duration("0.5hours").unwrap(), 1800);
1833 }
1834
1835 #[test]
1836 fn test_parse_duration_number_only_defaults_to_seconds() {
1837 assert_eq!(parse_duration("1.5").unwrap(), 1);
1838 assert_eq!(parse_duration("42").unwrap(), 42);
1839 }
1840
1841 #[test]
1842 fn test_parse_duration_trimmed_input() {
1843 assert_eq!(parse_duration(" 30s ").unwrap(), 30);
1844 assert_eq!(parse_duration(" 5m ").unwrap(), 300);
1845 }
1846
1847 #[test]
1848 fn test_parse_duration_invalid_number_format() {
1849 assert!(parse_duration("1.2.3s").is_err());
1850 assert!(parse_duration("abc").is_err());
1851 }
1852
1853 #[test]
1858 fn test_unknown_venue_is_not_a_dex_venue() {
1859 assert_eq!(DexVenue::parse("binance"), None);
1860 assert_eq!(DexVenue::parse("kraken"), None);
1861 assert_eq!(DexVenue::parse("unknown"), None);
1862 }
1863
1864 #[test]
1869 fn test_market_summary_to_markdown_mixed_pass_fail_checks() {
1870 use scope::market::{HealthCheck, MarketSummary};
1871 let summary = MarketSummary {
1872 pair: "TESTUSDT".to_string(),
1873 source: scope::market::BookSource::Exchange,
1874 peg_target: 1.0,
1875 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1876 best_bid: Some(0.9999),
1877 best_ask: Some(1.0001),
1878 mid_price: Some(1.0000),
1879 spread: Some(0.0002),
1880 spread_pct: None,
1881 top_depth: Default::default(),
1882 volume_24h: Some(500_000.0),
1883 bid_depth: 40_000.0,
1884 ask_depth: 45_000.0,
1885 bid_outliers: 0,
1886 ask_outliers: 0,
1887 healthy: false,
1888 bot_patterns: None,
1889 checks: vec![
1890 HealthCheck::Pass("Spread within range".to_string()),
1891 HealthCheck::Fail("Bid depth below minimum".to_string()),
1892 ],
1893 execution_10k_buy: None,
1894 execution_10k_sell: None,
1895 asks: vec![],
1896 bids: vec![],
1897 };
1898 let md = market_summary_to_markdown(&summary, "TestVenue", "TESTUSDT");
1899 assert!(md.contains("✓ Spread within range"));
1900 assert!(md.contains("✗ Bid depth below minimum"));
1901 assert!(md.contains("✗")); }
1903
1904 #[test]
1905 fn test_market_summary_to_markdown_empty_checks() {
1906 use scope::market::MarketSummary;
1907 let summary = MarketSummary {
1908 pair: "X".to_string(),
1909 source: scope::market::BookSource::Exchange,
1910 peg_target: 1.0,
1911 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
1912 best_bid: Some(1.0),
1913 best_ask: Some(1.0),
1914 mid_price: Some(1.0),
1915 spread: Some(0.0),
1916 spread_pct: None,
1917 top_depth: Default::default(),
1918 volume_24h: None,
1919 bid_depth: 100.0,
1920 ask_depth: 100.0,
1921 bid_outliers: 0,
1922 ask_outliers: 0,
1923 healthy: true,
1924 bot_patterns: None,
1925 checks: vec![],
1926 execution_10k_buy: None,
1927 execution_10k_sell: None,
1928 asks: vec![],
1929 bids: vec![],
1930 };
1931 let md = market_summary_to_markdown(&summary, "Venue", "X");
1932 assert!(md.contains("Market Health Report"));
1933 assert!(!md.contains("Health Checks:"));
1935 }
1936
1937 #[test]
1942 fn test_ohlc_format_partial_eq() {
1943 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
1944 assert_eq!(OhlcFormat::Json, OhlcFormat::Json);
1945 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
1946 }
1947
1948 #[test]
1949 fn test_summary_format_clone_copy() {
1950 let text = SummaryFormat::Text;
1951 let cloned = text;
1952 assert!(matches!(cloned, SummaryFormat::Text));
1953 assert!(matches!(text, SummaryFormat::Text));
1954 }
1955
1956 #[tokio::test]
1961 async fn test_run_summary_interval_zero_error() {
1962 let args = SummaryArgs {
1963 pair: "USDC".to_string(),
1964 venue: "eth".to_string(),
1965 token_contract: None,
1966 chain: None,
1967 health: HealthArgs {
1968 peg_target: Some(1.0),
1969 peg_range: Some(0.01),
1970 min_levels: Some(1),
1971 min_depth: Some(50.0),
1972 min_bid_ask_ratio: Some(0.1),
1973 max_bid_ask_ratio: Some(10.0),
1974 ..Default::default()
1975 },
1976 format: SummaryFormat::Text,
1977 every: Some("0.1s".to_string()),
1978 duration: Some("1m".to_string()),
1979 report: None,
1980 csv: None,
1981 };
1982 let http: std::sync::Arc<dyn scope::http::HttpClient> =
1983 std::sync::Arc::new(scope::http::NativeHttpClient::new().unwrap());
1984 let factory = DefaultClientFactory {
1985 chains_config: Default::default(),
1986 http,
1987 };
1988 let result = run_summary(args, &Config::default(), &factory, &quiet()).await;
1989 assert!(result.is_err());
1990 let err = result.unwrap_err().to_string();
1991 assert!(
1992 err.contains("Interval must be positive") || err.contains("positive"),
1993 "expected interval error, got: {}",
1994 err
1995 );
1996 }
1997
1998 #[test]
2003 fn test_market_commands_debug() {
2004 let cmd = MarketCommands::Summary(SummaryArgs {
2005 pair: "USDC".to_string(),
2006 venue: "binance".to_string(),
2007 token_contract: None,
2008 chain: None,
2009 health: HealthArgs {
2010 peg_target: Some(1.0),
2011 peg_range: Some(0.001),
2012 min_levels: Some(6),
2013 min_depth: Some(3000.0),
2014 min_bid_ask_ratio: Some(0.2),
2015 max_bid_ask_ratio: Some(5.0),
2016 ..Default::default()
2017 },
2018 format: SummaryFormat::Text,
2019 every: None,
2020 duration: None,
2021 report: None,
2022 csv: None,
2023 });
2024 let debug = format!("{:?}", cmd);
2025 assert!(debug.contains("Summary"));
2026
2027 let ohlc_cmd = MarketCommands::Ohlc(OhlcArgs {
2028 pair: "BTC".to_string(),
2029 venue: "binance".to_string(),
2030 token_contract: None,
2031 interval: "1h".to_string(),
2032 limit: 100,
2033 format: OhlcFormat::Text,
2034 });
2035 assert!(format!("{:?}", ohlc_cmd).contains("Ohlc"));
2036
2037 let trades_cmd = MarketCommands::Trades(TradesArgs {
2038 pair: "ETH".to_string(),
2039 venue: "binance".to_string(),
2040 token_contract: None,
2041 limit: 50,
2042 format: OhlcFormat::Json,
2043 });
2044 assert!(format!("{:?}", trades_cmd).contains("Trades"));
2045 }
2046
2047 #[test]
2048 fn test_summary_args_with_report_csv_options() {
2049 let args = SummaryArgs {
2050 pair: "DAI".to_string(),
2051 venue: "binance".to_string(),
2052 token_contract: None,
2053 chain: None,
2054 health: HealthArgs {
2055 peg_target: Some(1.0),
2056 peg_range: Some(0.001),
2057 min_levels: Some(6),
2058 min_depth: Some(3000.0),
2059 min_bid_ask_ratio: Some(0.2),
2060 max_bid_ask_ratio: Some(5.0),
2061 ..Default::default()
2062 },
2063 format: SummaryFormat::Json,
2064 every: Some("30s".to_string()),
2065 duration: Some("1h".to_string()),
2066 report: Some(std::path::PathBuf::from("/tmp/report.md")),
2067 csv: Some(std::path::PathBuf::from("/tmp/data.csv")),
2068 };
2069 assert_eq!(args.pair, "DAI");
2070 assert_eq!(args.venue, "binance");
2071 assert!(args.every.is_some());
2072 assert!(args.duration.is_some());
2073 assert!(args.report.is_some());
2074 assert!(args.csv.is_some());
2075 }
2076
2077 #[test]
2078 fn test_base_symbol_from_pair_4char_usdt() {
2079 assert_eq!(Pair::parse("USDT").base, "USDT");
2081 }
2082
2083 #[test]
2084 fn test_market_summary_to_markdown_unhealthy() {
2085 use scope::market::{HealthCheck, MarketSummary};
2086 let summary = MarketSummary {
2087 pair: "X".to_string(),
2088 source: scope::market::BookSource::Exchange,
2089 peg_target: 1.0,
2090 peg_range: scope::market::health::DEFAULT_PEG_RANGE,
2091 best_bid: Some(0.99),
2092 best_ask: Some(1.01),
2093 mid_price: Some(1.0),
2094 spread: Some(0.02),
2095 spread_pct: None,
2096 top_depth: Default::default(),
2097 volume_24h: None,
2098 bid_depth: 100.0,
2099 ask_depth: 100.0,
2100 bid_outliers: 0,
2101 ask_outliers: 0,
2102 healthy: false,
2103 bot_patterns: None,
2104 checks: vec![HealthCheck::Fail("Peg deviation too high".to_string())],
2105 execution_10k_buy: None,
2106 execution_10k_sell: None,
2107 asks: vec![],
2108 bids: vec![],
2109 };
2110 let md = market_summary_to_markdown(&summary, "Test", "X");
2111 assert!(md.contains("✗"));
2112 assert!(md.contains("Peg deviation too high"));
2113 }
2114
2115 #[test]
2116 fn test_ohlc_format_eq() {
2117 assert_eq!(OhlcFormat::Text, OhlcFormat::Text);
2118 assert_ne!(OhlcFormat::Text, OhlcFormat::Json);
2119 }
2120
2121 #[test]
2122 fn test_trades_args_default_venue() {
2123 let args = TradesArgs {
2124 pair: "USDC".to_string(),
2125 venue: "binance".to_string(),
2126 token_contract: None,
2127 limit: 50,
2128 format: OhlcFormat::Text,
2129 };
2130 assert_eq!(args.pair, "USDC");
2131 assert_eq!(args.venue, "binance");
2132 }
2133
2134 const USDC_ADDR: &str = "0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48";
2139
2140 fn dex_factory(pairs: &[(f64, f64)]) -> scope::chains::mocks::MockClientFactory {
2142 let mut factory = scope::chains::mocks::MockClientFactory::new();
2143 let data = factory.mock_dex.token_data.as_mut().unwrap();
2144 data.symbol = "USDC".to_string();
2145 data.pairs = pairs
2146 .iter()
2147 .enumerate()
2148 .map(|(i, &(liquidity_usd, volume_24h))| scope::chains::DexPair {
2149 dex_name: format!("DEX {i}"),
2150 pair_address: format!("0xpair{i}"),
2151 base_token: "USDC".to_string(),
2152 quote_token: "USDT".to_string(),
2153 price_usd: 1.0,
2154 volume_24h,
2155 liquidity_usd,
2156 liquidity_base: None,
2157 price_change_24h: 0.0,
2158 buys_24h: 0,
2159 sells_24h: 0,
2160 buys_6h: 0,
2161 sells_6h: 0,
2162 buys_1h: 0,
2163 sells_1h: 0,
2164 pair_created_at: None,
2165 url: None,
2166 })
2167 .collect();
2168 factory
2169 }
2170
2171 fn dex_args(format: SummaryFormat) -> SummaryArgs {
2172 SummaryArgs {
2173 pair: USDC_ADDR.to_string(),
2174 venue: "eth".to_string(),
2175 token_contract: None,
2176 chain: None,
2177 health: HealthArgs::default(),
2178 format,
2179 every: None,
2180 duration: None,
2181 report: None,
2182 csv: None,
2183 }
2184 }
2185
2186 fn two_chain_usdn(factory: &mut scope::chains::mocks::MockClientFactory) {
2187 let usdn = |chain: &str, address: &str| scope::chains::dex::TokenSearchResult {
2188 address: address.into(),
2189 symbol: "USDN".into(),
2190 name: "USDN".into(),
2191 chain: chain.into(),
2192 price_usd: Some(1.0),
2193 volume_24h: 0.0,
2194 liquidity_usd: 1.0,
2195 market_cap: None,
2196 };
2197 factory.mock_dex.search_results = vec![usdn("bsc", "0xB"), usdn("ethereum", "0xA")];
2198 }
2199
2200 #[tokio::test]
2201 async fn test_a_dex_venue_resolves_on_its_own_chain() {
2202 let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2204 two_chain_usdn(&mut factory);
2205 let mut args = dex_args(SummaryFormat::Text);
2206 args.pair = "USDN".into();
2207 let token = resolve_market_token(&args, &factory, &quiet())
2208 .await
2209 .unwrap();
2210 assert_eq!(token.as_deref(), Some("0xA"));
2211 }
2212
2213 #[tokio::test]
2214 async fn test_a_dex_venue_takes_the_token_flag_without_a_search() {
2215 let mut factory = dex_factory(&[(50_000.0, 777.0)]);
2217 two_chain_usdn(&mut factory);
2218 let mut args = dex_args(SummaryFormat::Text);
2219 args.pair = "USDN".into();
2220 args.token_contract = Some("0xT".into());
2221 let token = resolve_market_token(&args, &factory, &quiet())
2222 .await
2223 .unwrap();
2224 assert_eq!(token.as_deref(), Some("0xT"));
2225 }
2226
2227 #[tokio::test]
2228 async fn test_dex_book_uses_most_liquid_pair() {
2229 let factory = dex_factory(&[(1_000.0, 10.0), (50_000.0, 777.0), (2_000.0, 20.0)]);
2231 let (book, volume, trades) = fetch_book_and_volume(
2232 &dex_args(SummaryFormat::Text),
2233 &HealthThresholds::default(),
2234 &factory,
2235 &quiet(),
2236 )
2237 .await
2238 .unwrap();
2239 assert_eq!(volume, Some(777.0));
2240 assert_eq!(trades.unwrap_err(), "a DEX pool has no trade tape here");
2242 let depth = book.total_depth(scope::market::BookSide::Bid)
2243 + book.total_depth(scope::market::BookSide::Ask);
2244 assert!((depth - 50_000.0).abs() < 1.0, "depth {depth}");
2245 }
2246
2247 #[tokio::test]
2250 async fn test_venue_sol_reads_the_solana_dex() {
2251 let factory = dex_factory(&[(50_000.0, 777.0)]);
2252 let mut args = dex_args(SummaryFormat::Text);
2253 args.venue = "sol".to_string();
2254 let result =
2255 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet()).await;
2256 let (book, volume, _trades) = result.unwrap();
2257 assert_eq!(volume, Some(777.0));
2258 assert_eq!(book.pair, "USDC/USD");
2259 }
2260
2261 #[tokio::test]
2264 async fn test_chain_must_match_the_dex_venue() {
2265 let factory = dex_factory(&[(50_000.0, 1.0)]);
2266 let mut args = dex_args(SummaryFormat::Text);
2267 args.chain = Some("Solana".to_string());
2268 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2269 .await
2270 .unwrap_err();
2271 assert!(err.to_string().contains("reads chain ethereum"), "{err}");
2272
2273 args.chain = Some("ETH".to_string());
2274 assert!(
2275 fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2276 .await
2277 .is_ok()
2278 );
2279 }
2280
2281 #[tokio::test]
2282 async fn test_unknown_venue_is_an_error() {
2283 let factory = dex_factory(&[(50_000.0, 1.0)]);
2284 let mut args = dex_args(SummaryFormat::Text);
2285 args.venue = "polygon".to_string();
2286 let err = fetch_book_and_volume(&args, &HealthThresholds::default(), &factory, &quiet())
2287 .await
2288 .unwrap_err();
2289 assert!(
2290 err.to_string().contains("Unknown venue \"polygon\""),
2291 "{err}"
2292 );
2293 }
2294
2295 #[tokio::test]
2296 async fn test_dex_no_pairs_is_error() {
2297 let factory = dex_factory(&[]);
2298 let err = fetch_book_and_volume(
2299 &dex_args(SummaryFormat::Text),
2300 &HealthThresholds::default(),
2301 &factory,
2302 &quiet(),
2303 )
2304 .await
2305 .unwrap_err();
2306 assert!(err.to_string().contains("No DEX pairs found"), "{err}");
2307 }
2308
2309 #[tokio::test]
2310 async fn test_run_summary_once_json_reflects_resolved_thresholds() {
2311 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2312 let mut args = dex_args(SummaryFormat::Json);
2313 args.health = HealthArgs {
2315 min_levels: Some(1),
2316 min_top10_depth: Some(1.0),
2317 ..Default::default()
2318 };
2319 let thresholds = args.health.resolve(&Config::default());
2320 let (summary, _) = summary_run(&args, &factory, &thresholds, &[], Some(1), &quiet())
2321 .await
2322 .unwrap();
2323 assert!(summary.healthy, "{:?}", summary.checks);
2324
2325 let strict = HealthThresholds::default();
2326 let (summary, _) = summary_run(&args, &factory, &strict, &[], None, &quiet())
2327 .await
2328 .unwrap();
2329 assert!(!summary.healthy);
2330 }
2331
2332 #[tokio::test]
2333 async fn test_dex_json_shows_amm_source_and_na_checks() {
2334 let mut factory = dex_factory(&[(20_000_000.0, 1_000.0)]);
2336 factory.mock_dex.token_data.as_mut().unwrap().pairs[0].liquidity_base = Some(10_000_000.0);
2337 let args = dex_args(SummaryFormat::Json);
2338 let thresholds = args.health.resolve(&Config::default());
2339 let (summary, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
2340 .await
2341 .unwrap();
2342 assert!(summary.healthy, "{:?}", summary.checks);
2343 let v: serde_json::Value = serde_json::from_str(&report).unwrap();
2344 assert_eq!(v["book_source"], "synthetic_amm");
2345 let statuses: Vec<&str> = v["checks"]
2346 .as_array()
2347 .unwrap()
2348 .iter()
2349 .map(|c| c["status"].as_str().unwrap())
2350 .collect();
2351 assert_eq!(
2354 statuses.iter().filter(|s| **s == "n/a").count(),
2355 4,
2356 "{:?}",
2357 statuses
2358 );
2359 assert!(!statuses.contains(&"fail"));
2360 assert_eq!(
2361 v["checks"].as_array().unwrap().last().unwrap()["message"],
2362 "Bot patterns: not checked (a DEX pool has no trade tape here)"
2363 );
2364 assert_eq!(v["bot_patterns"]["book_checked"], false);
2365 }
2366
2367 #[tokio::test]
2368 async fn test_dex_without_reserves_says_estimate() {
2369 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2370 let args = dex_args(SummaryFormat::Text);
2371 let thresholds = args.health.resolve(&Config::default());
2372 let (_, report) = summary_run(&args, &factory, &thresholds, &[], None, &quiet())
2373 .await
2374 .unwrap();
2375 assert!(report.contains("pool reserves not reported"), "{report}");
2376 }
2377
2378 #[tokio::test]
2379 async fn test_run_summary_one_shot_writes_report() {
2380 let dir = tempfile::tempdir().unwrap();
2381 let report = dir.path().join("report.md");
2382 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2383 let mut args = dex_args(SummaryFormat::Text);
2384 args.report = Some(report.clone());
2385 run_summary(args, &Config::default(), &factory, &quiet())
2386 .await
2387 .unwrap();
2388 let md = std::fs::read_to_string(&report).unwrap();
2389 assert!(md.contains("Market Health Report"), "{md}");
2390 assert!(md.contains(USDC_ADDR), "{md}");
2391 }
2392
2393 #[tokio::test(start_paused = true)]
2396 async fn test_run_summary_repeat_mode_writes_csv_rows_and_report() {
2397 let dir = tempfile::tempdir().unwrap();
2398 let csv = dir.path().join("series.csv");
2399 let report = dir.path().join("final.md");
2400 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2401 let mut args = dex_args(SummaryFormat::Json);
2402 args.every = Some("1s".to_string());
2403 args.duration = Some("1s".to_string());
2404 args.csv = Some(csv.clone());
2405 args.report = Some(report.clone());
2406 run_summary(args, &Config::default(), &factory, &quiet())
2407 .await
2408 .unwrap();
2409
2410 let body = std::fs::read_to_string(&csv).unwrap();
2411 let mut lines = body.lines();
2412 let header = lines.next().unwrap();
2414 assert!(
2415 header.starts_with(
2416 "timestamp,run,best_bid,best_ask,mid_price,spread,bid_depth,ask_depth,healthy,"
2417 ),
2418 "{header}"
2419 );
2420 assert!(
2421 header.contains("spread_pct") && header.ends_with("ask_top10_depth"),
2422 "{header}"
2423 );
2424 let rows: Vec<_> = lines.collect();
2426 assert_eq!(rows.len(), 2, "{body}");
2427 assert!(rows[0].split(',').nth(1) == Some("1"));
2428 assert!(rows[1].split(',').nth(1) == Some("2"));
2429 assert!(report.exists());
2430 }
2431
2432 #[tokio::test(start_paused = true)]
2435 async fn test_run_summary_repeat_publishes_run_progress() {
2436 let (out, captured) = Output::capture_merged();
2439 let hook = out.progress_hook().expect("a merged capture has a hook");
2440 let factory = dex_factory(&[(50_000.0, 1_000.0)]);
2441 let mut args = dex_args(SummaryFormat::Json);
2442 args.every = Some("1s".to_string());
2443 args.duration = Some("2s".to_string());
2444 run_summary(args, &Config::default(), &factory, &out)
2445 .await
2446 .unwrap();
2447 let p = hook.lock().unwrap().clone();
2448 assert_eq!(p.total, Some(3), "{p:?}");
2450 assert_eq!(p.run, 3, "{p:?}");
2451 assert_eq!(p.next_at, None, "no next run after the last: {p:?}");
2452 assert!(captured.out().contains("every 1s for 2s"));
2453 assert!(captured.out().contains("Completed 3 run(s)"));
2454 }
2455
2456 #[tokio::test]
2457 async fn test_a_failed_run_ends_a_repeating_summary() {
2458 let (out, _captured) = Output::capture_merged();
2461 let hook = out.progress_hook().unwrap();
2462 let factory = dex_factory(&[]);
2463 let mut args = dex_args(SummaryFormat::Json);
2464 args.every = Some("1s".to_string());
2465 args.duration = Some("30s".to_string());
2466 let started = std::time::Instant::now();
2467 let result = run_summary(args, &Config::default(), &factory, &out).await;
2468 assert!(result.is_err(), "no DEX pairs is a failed run");
2469 assert_eq!(hook.lock().unwrap().run, 1, "no second run after the error");
2470 assert!(
2471 started.elapsed() < Duration::from_secs(5),
2472 "it did not sleep on"
2473 );
2474 }
2475}