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